From 98f3623c644b125f1b054f3eef71aa03e9d5626a Mon Sep 17 00:00:00 2001 From: "Andre.Nascimento" Date: Wed, 29 Apr 2026 17:52:53 -0300 Subject: [PATCH] feat(pipeline-monitor): per-scope progress tab with live ETA + stalled detection (FDD-OPS-015 UI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the FDD-OPS-015 deliverable list — backend (PR #6) shipped the API, this PR ships the operator-facing UI tab "Per-scope" no Pipeline Monitor. WHAT YOU SEE A 4ª aba "Per-scope" no `/pipeline-monitor` route mostra **live**: - Sumário no header: counts de "running", "stalled", "done", "failed" - Tabela com 1 row por scope ativo/recente: Scope (jira:project:BG / github:repo:foo/bar) Entity type Progress bar com itemsDone / itemsEstimate + percentage Status badge (running spinner, stalled warning, done check, failed X) Rate (items/sec) ETA formatado (90s, 5m 23s, 2h 14m) Last activity (relative: "12s ago", "3m ago") - Filters: by entity_type (issues/prs/deploys/sprints) + by status - Polling cada 5s (live update, stale at 2s) STALLED DETECTION Quando backend reporta `isStalled=true` (status='running' AND no progress for >60s), a row ganha: - Background tinted warning (subtle yellow) - AlertCircle icon - "STALLED" badge replaces normal status Operator vê em segundos qual scope precisa de atenção. NO-ESTIMATE GRACEFUL HANDLING Se backend não conseguiu pre-flight count (timeout, source unsupported), itemsEstimate=null: - Progress bar mostra stripe indeterminado (15% width como hint) - Pct label exibe "?" - ETA exibe "—" Não trava UI; operador vê "fetching, taxa X/s, total desconhecido". ANTI-SURVEILLANCE Schema Zod strict() em testes rejeita `author`/`assignee` no payload — matches o invariant em metrics-inconsistencies §8.9. FILES Frontend: - types/pipeline.ts: ProgressJob, ProgressJobStatus, ProgressJobPhase - lib/api/pipeline.ts: fetchPipelineJobs (com query params) - hooks/usePipeline.ts: usePipelineJobs (5s polling, 2s staleTime) - components/pipeline/PerScopeJobs.tsx (NEW, ~330 lines) - routes/_dashboard/pipeline-monitor.tsx: 4ª tab "Per-scope" Tests: - tests/contract/schemas/pipeline-jobs.schema.ts (Zod schema) - tests/contract/pipeline-jobs-contract.test.ts (13 tests): A-G: shape validity (running/no-estimate/stalled/failed/done/empty/array) H-I: anti-surveillance (rejects author/assignee) J-M: defensive bounds (negative items, >100 pct, unknown enums) VALIDATION ✅ TypeScript build clean (npx tsc --noEmit) ✅ ESLint clean (no new warnings) ✅ 163/163 frontend tests pass (13 novos + 150 anteriores) ✅ Live API smoke test: 10+ scopes returned, isStalled correctly computed ✅ JSON wire-shape matches schema (verified via curl during dev) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/pipeline/PerScopeJobs.tsx | 432 ++++++++++++++++++ .../pulse-web/src/hooks/usePipeline.ts | 22 + .../pulse-web/src/lib/api/pipeline.ts | 24 + .../routes/_dashboard/pipeline-monitor.tsx | 9 +- .../packages/pulse-web/src/types/pipeline.ts | 38 ++ .../contract/pipeline-jobs-contract.test.ts | 213 +++++++++ .../contract/schemas/pipeline-jobs.schema.ts | 53 +++ 7 files changed, 789 insertions(+), 2 deletions(-) create mode 100644 pulse/packages/pulse-web/src/components/pipeline/PerScopeJobs.tsx create mode 100644 pulse/packages/pulse-web/tests/contract/pipeline-jobs-contract.test.ts create mode 100644 pulse/packages/pulse-web/tests/contract/schemas/pipeline-jobs.schema.ts diff --git a/pulse/packages/pulse-web/src/components/pipeline/PerScopeJobs.tsx b/pulse/packages/pulse-web/src/components/pipeline/PerScopeJobs.tsx new file mode 100644 index 0000000..f03c84c --- /dev/null +++ b/pulse/packages/pulse-web/src/components/pipeline/PerScopeJobs.tsx @@ -0,0 +1,432 @@ +/** + * FDD-OPS-015 — Per-scope ingestion progress table. + * + * One row per active or recently-completed ingestion scope (Jira project, + * GitHub repo, Jenkins job). Backend orders running first; UI adds + * client-side filters by entity_type and status. Polling every 5s for + * live ETA updates. + * + * Operator goal: answer "is the BG project still progressing or stuck?" + * in seconds, without reading server logs. Stalled badge appears when + * the backend computes `isStalled=true` (status='running' AND no + * progress for >60s). + */ + +import { useMemo, useState } from 'react'; +import { AlertCircle, CheckCircle2, Clock, XCircle, Loader2, PauseCircle, Filter } from 'lucide-react'; +import { usePipelineJobs } from '@/hooks/usePipeline'; +import type { ProgressJob, ProgressJobStatus } from '@/types/pipeline'; + +type EntityFilter = 'all' | 'issues' | 'pull_requests' | 'deployments' | 'sprints'; +type StatusFilter = 'all' | ProgressJobStatus; + +const ENTITY_OPTIONS: Array<{ value: EntityFilter; label: string }> = [ + { value: 'all', label: 'All entities' }, + { value: 'issues', label: 'Issues' }, + { value: 'pull_requests', label: 'Pull Requests' }, + { value: 'deployments', label: 'Deployments' }, + { value: 'sprints', label: 'Sprints' }, +]; + +const STATUS_OPTIONS: Array<{ value: StatusFilter; label: string }> = [ + { value: 'all', label: 'All statuses' }, + { value: 'running', label: 'Running' }, + { value: 'done', label: 'Done' }, + { value: 'failed', label: 'Failed' }, + { value: 'paused', label: 'Paused' }, +]; + +// --------------------------------------------------------------------------- +// Formatting helpers +// --------------------------------------------------------------------------- + +function fmtETA(seconds: number | null): string { + if (seconds === null) return '—'; + if (seconds === 0) return 'done'; + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return s > 0 ? `${m}m ${s}s` : `${m}m`; + } + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return m > 0 ? `${h}h ${m}m` : `${h}h`; +} + +function fmtRate(rate: number): string { + if (rate === 0) return '—'; + if (rate < 1) return `${rate.toFixed(2)}/s`; + if (rate < 10) return `${rate.toFixed(1)}/s`; + return `${Math.round(rate)}/s`; +} + +function fmtRel(iso: string): string { + const dt = new Date(iso).getTime(); + const now = Date.now(); + const sec = Math.max(0, Math.floor((now - dt) / 1000)); + if (sec < 60) return `${sec}s ago`; + if (sec < 3600) return `${Math.floor(sec / 60)}m ago`; + if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`; + return `${Math.floor(sec / 86400)}d ago`; +} + +function fmtScope(scopeKey: string): { source: string; label: string } { + // 'jira:project:BG' → { source: 'jira', label: 'BG' } + // 'github:repo:webmotors-private/foo' → { source: 'github', label: 'webmotors-private/foo' } + const parts = scopeKey.split(':'); + if (parts.length >= 3) { + return { source: parts[0] ?? '?', label: parts.slice(2).join(':') }; + } + return { source: '?', label: scopeKey }; +} + +// --------------------------------------------------------------------------- +// Status icon + color +// --------------------------------------------------------------------------- + +function statusIcon(job: ProgressJob) { + if (job.isStalled) { + return ; + } + switch (job.status) { + case 'running': + return ; + case 'done': + return ; + case 'failed': + return ; + case 'paused': + case 'cancelled': + return ; + default: + return ; + } +} + +function statusLabel(job: ProgressJob): string { + if (job.isStalled) return 'STALLED'; + return job.status.toUpperCase(); +} + +function statusBadgeColor(job: ProgressJob): string { + if (job.isStalled) return 'bg-status-warning/10 text-status-warning border-status-warning/30'; + switch (job.status) { + case 'running': + return 'bg-status-info/10 text-status-info border-status-info/30'; + case 'done': + return 'bg-status-success/10 text-status-success border-status-success/30'; + case 'failed': + return 'bg-status-danger/10 text-status-danger border-status-danger/30'; + default: + return 'bg-surface-tertiary text-content-secondary border-border-default'; + } +} + +// --------------------------------------------------------------------------- +// Progress bar +// --------------------------------------------------------------------------- + +function ProgressBar({ job }: { job: ProgressJob }) { + const pct = job.progressPct ?? 0; + const hasEstimate = job.itemsEstimate !== null; + + // When no estimate available, show indeterminate stripe via diff color + let barClass = 'bg-status-info'; + if (job.status === 'failed') barClass = 'bg-status-danger'; + else if (job.status === 'done') barClass = 'bg-status-success'; + else if (job.isStalled) barClass = 'bg-status-warning'; + + return ( +
+
+ + {job.itemsDone.toLocaleString()} + {hasEstimate && ( + <> + {' / '} + + {job.itemsEstimate!.toLocaleString()} + + + )} + + + {hasEstimate ? `${pct.toFixed(0)}%` : '?'} + +
+
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Empty / loading +// --------------------------------------------------------------------------- + +function Skeleton() { + return ( +
+
+
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ); +} + +function EmptyState() { + return ( +
+ +

+ No active ingestion jobs +

+

+ Per-scope progress appears here when the sync worker is processing + scopes (Jira projects, GitHub repos). The page polls every 5 seconds. +

+
+ ); +} + +function ErrorState({ message }: { message?: string }) { + return ( +
+
+ +
+

+ Failed to load pipeline jobs +

+

+ {message || 'Try refreshing — the polling will retry automatically.'} +

+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Row component +// --------------------------------------------------------------------------- + +function JobRow({ job }: { job: ProgressJob }) { + const { source, label } = fmtScope(job.scopeKey); + const stalledClass = job.isStalled + ? 'bg-status-warning/5 hover:bg-status-warning/10' + : 'hover:bg-surface-tertiary/50'; + + return ( +
+ {/* Scope */} +
+
+ {label} +
+
+ {source} +
+
+ + {/* Entity type */} +
+ {job.entityType.replace('_', ' ')} +
+ + {/* Progress */} + + + {/* Status badge */} +
+ + {statusIcon(job)} + {statusLabel(job)} + + {job.lastError && ( + + {job.lastError} + + )} +
+ + {/* Rate */} +
+ {fmtRate(job.itemsPerSecond)} +
+ + {/* ETA */} +
+ {fmtETA(job.etaSeconds)} +
+ + {/* Last activity */} +
+ {fmtRel(job.lastProgressAt)} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export function PerScopeJobs() { + const [entityFilter, setEntityFilter] = useState('all'); + const [statusFilter, setStatusFilter] = useState('all'); + + // Backend supports filter params but we filter client-side too for + // instant UI feedback (no extra request) and because we need all rows + // for the count summary. + const { data: jobs, isLoading, isError, error } = usePipelineJobs({ limit: 200 }); + + const filtered = useMemo(() => { + if (!jobs) return []; + return jobs.filter((j) => { + if (entityFilter !== 'all' && j.entityType !== entityFilter) return false; + if (statusFilter !== 'all' && j.status !== statusFilter) return false; + return true; + }); + }, [jobs, entityFilter, statusFilter]); + + const counts = useMemo(() => { + if (!jobs) return { running: 0, done: 0, failed: 0, stalled: 0, total: 0 }; + return jobs.reduce( + (acc, j) => { + acc.total += 1; + if (j.isStalled) acc.stalled += 1; + else if (j.status === 'running') acc.running += 1; + else if (j.status === 'done') acc.done += 1; + else if (j.status === 'failed') acc.failed += 1; + return acc; + }, + { running: 0, done: 0, failed: 0, stalled: 0, total: 0 }, + ); + }, [jobs]); + + if (isLoading) return ; + if (isError) { + return ; + } + if (!jobs || jobs.length === 0) return ; + + return ( +
+ {/* Header with summary + filters */} +
+
+

+ Per-scope ingestion progress +

+
+ {counts.running > 0 && ( + + + {counts.running}{' '} + running + + )} + {counts.stalled > 0 && ( + + + {counts.stalled}{' '} + stalled + + )} + {counts.done > 0 && ( + + + {counts.done}{' '} + done + + )} + {counts.failed > 0 && ( + + + {counts.failed}{' '} + failed + + )} +
+
+ + {/* Filters */} +
+ + + +
+
+ + {/* Column headers */} +
+
Scope
+
Entity
+
Progress
+
Status
+
Rate
+
ETA
+
Last activity
+
+ + {/* Rows */} + {filtered.length === 0 ? ( +
+ No jobs match the current filters. +
+ ) : ( +
+ {filtered.map((j) => ( + + ))} +
+ )} + + {/* Footer */} +
+ Polling every 5 seconds. Stalled = running with no progress for >60s. +
+
+ ); +} diff --git a/pulse/packages/pulse-web/src/hooks/usePipeline.ts b/pulse/packages/pulse-web/src/hooks/usePipeline.ts index 050e9a4..99bfc6b 100644 --- a/pulse/packages/pulse-web/src/hooks/usePipeline.ts +++ b/pulse/packages/pulse-web/src/hooks/usePipeline.ts @@ -6,6 +6,7 @@ import { fetchPipelineTeams, fetchPipelineTimeline, fetchPipelineCoverage, + fetchPipelineJobs, } from '@/lib/api/pipeline'; import type { PipelineHealthResponse, @@ -14,6 +15,7 @@ import type { TeamHealth, TimelineEvent, CoverageResponse, + ProgressJob, } from '@/types/pipeline'; export function usePipelineHealth() { @@ -72,3 +74,23 @@ export function usePipelineCoverage() { staleTime: 30_000, }); } + +/** + * FDD-OPS-015 — Per-scope progress jobs. + * + * 5s polling for live operator visibility. The endpoint is cheap (single + * indexed table query) and the UI is the primary use case for this data, + * so polling at 5s keeps "is it stuck?" answerable in near real-time. + */ +export function usePipelineJobs(params?: { + status?: string; + entity_type?: string; + limit?: number; +}) { + return useQuery({ + queryKey: ['pipeline-jobs', params?.status, params?.entity_type, params?.limit], + queryFn: () => fetchPipelineJobs(params), + refetchInterval: 5_000, + staleTime: 2_000, + }); +} diff --git a/pulse/packages/pulse-web/src/lib/api/pipeline.ts b/pulse/packages/pulse-web/src/lib/api/pipeline.ts index 1887a9e..119bf3d 100644 --- a/pulse/packages/pulse-web/src/lib/api/pipeline.ts +++ b/pulse/packages/pulse-web/src/lib/api/pipeline.ts @@ -6,6 +6,7 @@ import type { TeamHealth, TimelineEvent, CoverageResponse, + ProgressJob, } from '@/types/pipeline'; export async function fetchPipelineHealth(): Promise { @@ -45,3 +46,26 @@ export async function fetchPipelineCoverage(): Promise { const response = await dataClient.get('/pipeline/coverage'); return response.data; } + +/** + * FDD-OPS-015 — per-scope ingestion progress (live + recently completed). + * + * Returns 1 row per active or recently-completed scope. Backend orders + * running first (most recent activity), then by last_progress_at desc. + * + * Used by PerScopeJobs tab in Pipeline Monitor with 5s polling. + */ +export async function fetchPipelineJobs(params?: { + status?: string; + entity_type?: string; + limit?: number; +}): Promise { + const response = await dataClient.get('/pipeline/jobs', { + params: { + ...(params?.status && { status: params.status }), + ...(params?.entity_type && { entity_type: params.entity_type }), + ...(params?.limit && { limit: params.limit }), + }, + }); + return response.data; +} diff --git a/pulse/packages/pulse-web/src/routes/_dashboard/pipeline-monitor.tsx b/pulse/packages/pulse-web/src/routes/_dashboard/pipeline-monitor.tsx index 8cce224..53e35ec 100644 --- a/pulse/packages/pulse-web/src/routes/_dashboard/pipeline-monitor.tsx +++ b/pulse/packages/pulse-web/src/routes/_dashboard/pipeline-monitor.tsx @@ -1,6 +1,6 @@ import { createRoute } from '@tanstack/react-router'; import { useState } from 'react'; -import { Activity, Workflow, Users } from 'lucide-react'; +import { Activity, Workflow, Users, Zap } from 'lucide-react'; import { rootRoute } from '../__root'; import { TrustStrip } from '@/components/pipeline/TrustStrip'; import { IntegrationBox } from '@/components/pipeline/IntegrationBox'; @@ -10,15 +10,17 @@ import { TeamHealthTable } from '@/components/pipeline/TeamHealthTable'; import { EntityDrawer } from '@/components/pipeline/EntityDrawer'; import { Timeline } from '@/components/pipeline/Timeline'; import { CoveragePanel } from '@/components/pipeline/CoveragePanel'; +import { PerScopeJobs } from '@/components/pipeline/PerScopeJobs'; import { usePipelineHealth, usePipelineSources } from '@/hooks/usePipeline'; import type { Source, Entity } from '@/types/pipeline'; -type TabId = 'overview' | 'pipeline' | 'teams'; +type TabId = 'overview' | 'pipeline' | 'teams' | 'jobs'; const TABS: Array<{ id: TabId; label: string; icon: typeof Activity }> = [ { id: 'overview', label: 'Visao geral', icon: Activity }, { id: 'pipeline', label: 'Pipeline', icon: Workflow }, { id: 'teams', label: 'Times', icon: Users }, + { id: 'jobs', label: 'Per-scope', icon: Zap }, ]; function EmptyState() { @@ -127,6 +129,9 @@ function PipelineMonitorPage() { {/* Teams tab */} {tab === 'teams' && } + + {/* Per-scope jobs tab (FDD-OPS-015) */} + {tab === 'jobs' && } )} diff --git a/pulse/packages/pulse-web/src/types/pipeline.ts b/pulse/packages/pulse-web/src/types/pipeline.ts index 7c9c558..674442b 100644 --- a/pulse/packages/pulse-web/src/types/pipeline.ts +++ b/pulse/packages/pulse-web/src/types/pipeline.ts @@ -138,3 +138,41 @@ export interface PipelineSourceRow { sourceName: string; phases: PipelinePhaseCell[]; } + +/* ── Per-scope progress (FDD-OPS-015) ── */ + +export type ProgressJobStatus = + | 'running' + | 'done' + | 'failed' + | 'paused' + | 'cancelled'; + +export type ProgressJobPhase = + | 'pre_flight' + | 'fetching' + | 'normalizing' + | 'persisting' + | 'done' + | 'failed'; + +/** + * One row in `GET /data/v1/pipeline/jobs` — per-scope ingestion progress. + * Backend computes `progressPct` and `isStalled`; UI just renders. + */ +export interface ProgressJob { + scopeKey: string; + entityType: string; + phase: ProgressJobPhase; + status: ProgressJobStatus; + itemsDone: number; + itemsEstimate: number | null; + progressPct: number | null; + itemsPerSecond: number; + etaSeconds: number | null; + startedAt: string; + lastProgressAt: string; + finishedAt: string | null; + isStalled: boolean; + lastError: string | null; +} diff --git a/pulse/packages/pulse-web/tests/contract/pipeline-jobs-contract.test.ts b/pulse/packages/pulse-web/tests/contract/pipeline-jobs-contract.test.ts new file mode 100644 index 0000000..aad8021 --- /dev/null +++ b/pulse/packages/pulse-web/tests/contract/pipeline-jobs-contract.test.ts @@ -0,0 +1,213 @@ +/** + * Contract tests: GET /data/v1/pipeline/jobs (FDD-OPS-015). + * + * Validates the Zod schema for the per-scope progress endpoint. Tests + * use synthetic fixtures — no live backend needed. + * + * Coverage: + * A. Valid running scope (with estimate + ETA) parses + * B. Valid scope without estimate (itemsEstimate=null, etaSeconds=null) parses + * C. Stalled scope (isStalled=true, status='running') parses + * D. Failed scope with error message parses + * E. Done scope with finishedAt parses + * F. Empty array parses (no jobs yet) + * G. Anti-surveillance: rejects payloads with author/assignee fields + * H. Schema rejects negative items_done (defensive) + * I. Schema rejects progressPct > 100 (computed bound) + */ + +import { describe, it, expect } from 'vitest'; +import { + PipelineJobsResponseSchema, + ProgressJobSchema, +} from './schemas/pipeline-jobs.schema'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const VALID_RUNNING_JOB = { + scopeKey: 'jira:project:BG', + entityType: 'issues', + phase: 'persisting', + status: 'running', + itemsDone: 12500, + itemsEstimate: 197043, + progressPct: 6.34, + itemsPerSecond: 84.2, + etaSeconds: 2191, + startedAt: '2026-04-29T10:00:00.000+00:00', + lastProgressAt: '2026-04-29T10:24:30.000+00:00', + finishedAt: null, + isStalled: false, + lastError: null, +}; + +const VALID_NO_ESTIMATE_JOB = { + scopeKey: 'jenkins:job:deploy-prod', + entityType: 'deployments', + phase: 'fetching', + status: 'running', + itemsDone: 50, + itemsEstimate: null, // pre-flight count failed/skipped + progressPct: null, // ⇒ null (no estimate) + itemsPerSecond: 5.0, + etaSeconds: null, // ⇒ null (no estimate) + startedAt: '2026-04-29T10:00:00.000+00:00', + lastProgressAt: '2026-04-29T10:10:00.000+00:00', + finishedAt: null, + isStalled: false, + lastError: null, +}; + +const VALID_STALLED_JOB = { + scopeKey: 'jira:project:OKM', + entityType: 'issues', + phase: 'fetching', + status: 'running', + itemsDone: 200, + itemsEstimate: 1500, + progressPct: 13.33, + itemsPerSecond: 0.0, + etaSeconds: null, + startedAt: '2026-04-29T10:00:00.000+00:00', + lastProgressAt: '2026-04-29T10:05:00.000+00:00', + finishedAt: null, + isStalled: true, // backend computed: running + >60s no progress + lastError: null, +}; + +const VALID_FAILED_JOB = { + scopeKey: 'github:repo:webmotors-private/foo', + entityType: 'pull_requests', + phase: 'failed', + status: 'failed', + itemsDone: 12, + itemsEstimate: 50, + progressPct: 24.0, + itemsPerSecond: 1.5, + etaSeconds: null, + startedAt: '2026-04-29T10:00:00.000+00:00', + lastProgressAt: '2026-04-29T10:08:00.000+00:00', + finishedAt: '2026-04-29T10:08:30.000+00:00', + isStalled: false, + lastError: 'GitHub GraphQL 401: Bad credentials', +}; + +const VALID_DONE_JOB = { + scopeKey: 'jira:project:DESC', + entityType: 'issues', + phase: 'done', + status: 'done', + itemsDone: 2485, + itemsEstimate: 2485, + progressPct: 100.0, + itemsPerSecond: 45.0, + etaSeconds: 0, + startedAt: '2026-04-29T10:00:00.000+00:00', + lastProgressAt: '2026-04-29T10:55:00.000+00:00', + finishedAt: '2026-04-29T10:55:30.000+00:00', + isStalled: false, + lastError: null, +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('GET /data/v1/pipeline/jobs (FDD-OPS-015)', () => { + it('A. Valid running scope (with estimate + ETA) parses', () => { + const parsed = ProgressJobSchema.safeParse(VALID_RUNNING_JOB); + expect(parsed.success).toBe(true); + }); + + it('B. Valid scope without estimate (null progress/ETA) parses', () => { + const parsed = ProgressJobSchema.safeParse(VALID_NO_ESTIMATE_JOB); + expect(parsed.success).toBe(true); + }); + + it('C. Stalled scope (isStalled=true, status=running) parses', () => { + const parsed = ProgressJobSchema.safeParse(VALID_STALLED_JOB); + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data.isStalled).toBe(true); + expect(parsed.success && parsed.data.status).toBe('running'); + }); + + it('D. Failed scope with error message parses', () => { + const parsed = ProgressJobSchema.safeParse(VALID_FAILED_JOB); + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data.lastError).toContain('Bad credentials'); + }); + + it('E. Done scope with finishedAt + 100% parses', () => { + const parsed = ProgressJobSchema.safeParse(VALID_DONE_JOB); + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data.progressPct).toBe(100); + expect(parsed.success && parsed.data.finishedAt).not.toBeNull(); + }); + + it('F. Empty array (no jobs yet) parses', () => { + const parsed = PipelineJobsResponseSchema.safeParse([]); + expect(parsed.success).toBe(true); + }); + + it('G. Multiple-job array parses', () => { + const parsed = PipelineJobsResponseSchema.safeParse([ + VALID_RUNNING_JOB, + VALID_NO_ESTIMATE_JOB, + VALID_STALLED_JOB, + VALID_FAILED_JOB, + VALID_DONE_JOB, + ]); + expect(parsed.success).toBe(true); + }); + + // ----------------------------------------------------------------------- + // Anti-surveillance: payload MUST NOT carry per-developer fields + // (matches the project-wide invariant in metrics-inconsistencies §8.9) + // ----------------------------------------------------------------------- + + it('H. Anti-surveillance: rejects extra `author` field (strict-ish)', () => { + // Zod by default strips unknown — but we want to flag if the wire + // shape ever leaks a field. Use safeParse + strict() on the schema. + const tainted = { ...VALID_RUNNING_JOB, author: 'alice@example.com' }; + const strict = ProgressJobSchema.strict(); + const parsed = strict.safeParse(tainted); + expect(parsed.success).toBe(false); + }); + + it('I. Anti-surveillance: rejects extra `assignee` field (strict-ish)', () => { + const tainted = { ...VALID_RUNNING_JOB, assignee: 'bob@example.com' }; + const strict = ProgressJobSchema.strict(); + const parsed = strict.safeParse(tainted); + expect(parsed.success).toBe(false); + }); + + // ----------------------------------------------------------------------- + // Defensive: bound checks + // ----------------------------------------------------------------------- + + it('J. Rejects negative items_done', () => { + const bad = { ...VALID_RUNNING_JOB, itemsDone: -1 }; + const parsed = ProgressJobSchema.safeParse(bad); + expect(parsed.success).toBe(false); + }); + + it('K. Rejects progressPct > 100', () => { + const bad = { ...VALID_RUNNING_JOB, progressPct: 150 }; + const parsed = ProgressJobSchema.safeParse(bad); + expect(parsed.success).toBe(false); + }); + + it('L. Rejects unknown phase', () => { + const bad = { ...VALID_RUNNING_JOB, phase: 'unknown_phase' }; + const parsed = ProgressJobSchema.safeParse(bad); + expect(parsed.success).toBe(false); + }); + + it('M. Rejects unknown status', () => { + const bad = { ...VALID_RUNNING_JOB, status: 'mystery' }; + const parsed = ProgressJobSchema.safeParse(bad); + expect(parsed.success).toBe(false); + }); +}); diff --git a/pulse/packages/pulse-web/tests/contract/schemas/pipeline-jobs.schema.ts b/pulse/packages/pulse-web/tests/contract/schemas/pipeline-jobs.schema.ts new file mode 100644 index 0000000..e4cfc3e --- /dev/null +++ b/pulse/packages/pulse-web/tests/contract/schemas/pipeline-jobs.schema.ts @@ -0,0 +1,53 @@ +/** + * Zod schema for GET /data/v1/pipeline/jobs (FDD-OPS-015). + * + * Source of truth: pulse/packages/pulse-data/src/contexts/pipeline/schemas.py + * ProgressJob (camelCase via _CamelModel) + * + * Wire-format notes: + * - All optional fields use nullable() (matching `int | None` in Pydantic) + * - `progressPct` is computed by the backend (0-100) when itemsEstimate + * is set, else null + * - `isStalled` is computed: status='running' AND lastProgressAt > 60s ago + * - Anti-surveillance: schema MUST NOT contain author/assignee/reporter + */ + +import { z } from 'zod'; + +export const ProgressJobStatusSchema = z.enum([ + 'running', + 'done', + 'failed', + 'paused', + 'cancelled', +]); + +export const ProgressJobPhaseSchema = z.enum([ + 'pre_flight', + 'fetching', + 'normalizing', + 'persisting', + 'done', + 'failed', +]); + +export const ProgressJobSchema = z.object({ + scopeKey: z.string(), + entityType: z.string(), + phase: ProgressJobPhaseSchema, + status: ProgressJobStatusSchema, + itemsDone: z.number().int().nonnegative(), + itemsEstimate: z.number().int().nonnegative().nullable(), + progressPct: z.number().min(0).max(100).nullable(), + itemsPerSecond: z.number().nonnegative(), + etaSeconds: z.number().int().nonnegative().nullable(), + startedAt: z.string(), + lastProgressAt: z.string(), + finishedAt: z.string().nullable(), + isStalled: z.boolean(), + lastError: z.string().nullable(), +}); + +export const PipelineJobsResponseSchema = z.array(ProgressJobSchema); + +export type ProgressJobShape = z.infer;