diff --git a/ui/src/components/dashboard/PipelineStatusCard.tsx b/ui/src/components/dashboard/PipelineStatusCard.tsx new file mode 100644 index 00000000..628cadda --- /dev/null +++ b/ui/src/components/dashboard/PipelineStatusCard.tsx @@ -0,0 +1,265 @@ +/** + * PipelineStatusCard — dashboard widget for the autonomous pipeline (v0.10.0). + * + * Shows: + * • Merge queue — PRs labeled merge-queue, ordered bottom-of-stack first + * • Stack stats — active stack count, stale PR count + * • Last sync — timestamp of the conductor's most recent git-spice repo sync + * + * States: + * loading — skeleton placeholder while fetching + * empty — conductor not yet active; shown when status is null + * populated — live merge queue and stats + * + * The card is intentionally narrow in scope: it surfaces actionable pipeline + * state at a glance. Deep stack inspection (dependency graph, per-PR diffs) + * belongs on a future /pipeline detail page. + * + * Accessibility: section landmark with labelled heading; all status + * indicators carry aria-labels; reduced-motion respected via CSS. + */ + +import { CheckCircle2, Circle, GitBranch, Loader2, RefreshCw, Workflow, XCircle } from 'lucide-react' +import { CardSkeleton } from '@/components/common/LoadingSkeleton' +import type { PipelineQueueItem, PipelineStatus } from '@/types/pipeline' + +// --------------------------------------------------------------------------- +// Sub-components +// --------------------------------------------------------------------------- + +/** Indented stack-depth prefix for queue items */ +function StackPrefix({ depth }: { depth: number }) { + if (depth === 0) return null + return ( + + ) +} + +/** CI status indicator */ +function CiIndicator({ passing }: { passing: boolean | null }) { + if (passing === null) { + return ( + + ) + } + if (passing) { + return + } + return +} + +/** Single row in the merge queue */ +function QueueRow({ item }: { item: PipelineQueueItem }) { + return ( +
  • + {/* Stack depth prefix */} + + + #{item.prNumber} + + + {/* Title */} + + {item.title} + + + {/* Approval dot */} + + + {/* CI status */} + + + +
  • + ) +} + +/** Empty state shown when no conductor has run yet */ +function EmptyState() { + return ( +
    +
    +
    +
    +

    + Conductor not yet active +

    +

    + Deploy{' '} + + conductor.yml + {' '} + to activate the autonomous pipeline. +

    +
    +
    + ) +} + +/** Populated state with merge queue and stats */ +function PipelineContent({ status }: { status: PipelineStatus }) { + const { mergeQueue, activeStackCount, staleCount } = status + + return ( + <> + {/* Merge queue */} +
    +
    +
    + + {mergeQueue.length === 0 ? ( +

    + Queue is empty +

    + ) : ( +
      + {mergeQueue.slice(0, 6).map((item) => ( + + ))} + {mergeQueue.length > 6 && ( +
    • + +{mergeQueue.length - 6} more +
    • + )} +
    + )} +
    + + {/* Stats row */} +
    + + + {staleCount > 0 && ( + + + )} +
    + + ) +} + +// --------------------------------------------------------------------------- +// PipelineStatusCard +// --------------------------------------------------------------------------- + +export interface PipelineStatusCardProps { + status: PipelineStatus | null + loading: boolean + error?: string + onRefetch?: () => void +} + +export function PipelineStatusCard({ + status, + loading, + error, + onRefetch, +}: PipelineStatusCardProps) { + const lastSync = status?.lastSyncAt ? formatRelativeTime(new Date(status.lastSyncAt)) : null + + return ( +
    + {/* Header */} +
    +

    + Pipeline +

    + +
    + {lastSync && ( + + Synced {lastSync} + + )} + {onRefetch && ( + + )} +
    +
    + + {/* Error */} + {error && ( +

    + {error} +

    + )} + + {/* Loading */} + {loading && !error && ( +
    + +
    + )} + + {/* Content */} + {!loading && !error && ( + status ? : + )} +
    + ) +} + +export default PipelineStatusCard + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatRelativeTime(date: Date): string { + const diffMs = Date.now() - date.getTime() + const diffSec = Math.floor(diffMs / 1000) + if (diffSec < 60) return 'just now' + const diffMin = Math.floor(diffSec / 60) + if (diffMin < 60) return `${diffMin} min ago` + const diffHr = Math.floor(diffMin / 60) + if (diffHr < 24) return `${diffHr}h ago` + return `${Math.floor(diffHr / 24)}d ago` +} diff --git a/ui/src/components/dashboard/index.ts b/ui/src/components/dashboard/index.ts index b2cf918c..dabb2dad 100644 --- a/ui/src/components/dashboard/index.ts +++ b/ui/src/components/dashboard/index.ts @@ -3,3 +3,5 @@ export { AgentSummary } from './AgentSummary' export { NotificationSummary } from './NotificationSummary' export { ActivityTimeline } from './ActivityTimeline' export type { ActivityEvent, ActivityEventType } from './ActivityTimeline' +export { PipelineStatusCard } from './PipelineStatusCard' +export type { PipelineStatusCardProps } from './PipelineStatusCard' diff --git a/ui/src/components/settings/PipelineGates.tsx b/ui/src/components/settings/PipelineGates.tsx new file mode 100644 index 00000000..7ee4beac --- /dev/null +++ b/ui/src/components/settings/PipelineGates.tsx @@ -0,0 +1,144 @@ +/** + * PipelineGates — read-only reference section for the autonomous pipeline + * human interaction gates (v0.10.0). + * + * Gates define exactly where humans remain in the loop. This section + * surfaces them visibly in Settings so operators know what the system + * will and will not do autonomously. + * + * Two categories: + * Always-human — hardcoded in the conductor system prompt; cannot be + * changed through the UI or config. + * Configurable — autonomous by default but can be set per-project in + * the agent YAML. The UI shows the default only; actual + * runtime value comes from the conductor YAML. + * + * This is intentionally read-only for now. Once the conductor exposes a + * runtime settings endpoint, the configurable gates can be made interactive. + */ + +import { Lock, Settings2 } from 'lucide-react' +import { PIPELINE_GATES } from '@/types/pipeline' +import type { PipelineGate } from '@/types/pipeline' + +// --------------------------------------------------------------------------- +// Gate row +// --------------------------------------------------------------------------- + +function GateRow({ gate }: { gate: PipelineGate }) { + const isAlways = gate.kind === 'always' + + return ( +
    + {/* Icon */} +
    + {isAlways ? ( +
    + + {/* Text */} +
    +
    +

    + {gate.label} +

    + + {isAlways + ? 'Always human' + : gate.defaultAutonomous + ? 'Autonomous by default' + : 'Human by default'} + +
    +

    + {gate.description} +

    +
    +
    + ) +} + +// --------------------------------------------------------------------------- +// PipelineGates +// --------------------------------------------------------------------------- + +export function PipelineGates() { + const alwaysGates = PIPELINE_GATES.filter((g) => g.kind === 'always') + const configurableGates = PIPELINE_GATES.filter((g) => g.kind === 'configurable') + + return ( +
    + {/* Explainer */} +

    + The autonomous pipeline (v0.10.0) defines explicit gates where human + approval is required. Always-human gates are hardcoded and cannot be + overridden. Configurable gates show their default; the actual runtime + value is set per-project in{' '} + + .agentd/agents/conductor.yml + + . +

    + + {/* Always-human gates */} +
    +

    +

    +
    + {alwaysGates.map((gate) => ( + + ))} +
    +
    + + {/* Configurable gates */} +
    +

    +

    +
    + {configurableGates.map((gate) => ( + + ))} +
    +

    + To change a configurable gate, update the{' '} + gates: section in conductor.yml and + redeploy the agent. +

    +
    +
    + ) +} + +export default PipelineGates diff --git a/ui/src/hooks/usePipelineStatus.ts b/ui/src/hooks/usePipelineStatus.ts new file mode 100644 index 00000000..505659b4 --- /dev/null +++ b/ui/src/hooks/usePipelineStatus.ts @@ -0,0 +1,49 @@ +/** + * usePipelineStatus — fetches the current autonomous pipeline state. + * + * Status: stub / not yet wired. + * + * The Conductor agent (v0.10.0, issue #603) will expose pipeline state by + * writing structured memories tagged conductor+pipeline after each run. + * This hook will be updated to query the memory service (or a dedicated + * orchestrator endpoint) once that work lands. + * + * Until then it returns { status: null, loading: false } so the + * PipelineStatusCard renders its "not yet active" empty state instead + * of a perpetual spinner. + */ + +import { useEffect, useState } from 'react' +import type { PipelineStatus } from '@/types/pipeline' + +export interface UsePipelineStatusResult { + status: PipelineStatus | null + loading: boolean + error?: string + /** Manually re-fetch */ + refetch: () => void +} + +export function usePipelineStatus(): UsePipelineStatusResult { + const [status] = useState(null) + const [loading] = useState(false) + const [error] = useState(undefined) + const [, setTick] = useState(0) + + // Placeholder effect — replace with real fetch when conductor endpoint exists. + useEffect(() => { + // TODO(v0.10.0): query memory service for latest conductor pipeline digest. + // Example query: + // memoryClient.search('pipeline state merge queue', { + // tags: ['conductor', 'pipeline'], + // limit: 1, + // }) + }, []) + + return { + status, + loading, + error, + refetch: () => setTick((t) => t + 1), + } +} diff --git a/ui/src/pages/DashboardPage.tsx b/ui/src/pages/DashboardPage.tsx index 5357f9cb..08aa569a 100644 --- a/ui/src/pages/DashboardPage.tsx +++ b/ui/src/pages/DashboardPage.tsx @@ -12,10 +12,12 @@ import { import { AgentSummary } from '@/components/dashboard/AgentSummary' import { NotificationSummary } from '@/components/dashboard/NotificationSummary' import { ActivityTimeline } from '@/components/dashboard/ActivityTimeline' +import { PipelineStatusCard } from '@/components/dashboard/PipelineStatusCard' import type { ActivityEvent } from '@/components/dashboard/ActivityTimeline' import { useServiceHealth } from '@/hooks/useServiceHealth' import { useAgentSummary } from '@/hooks/useAgentSummary' import { useNotificationSummary } from '@/hooks/useNotificationSummary' +import { usePipelineStatus } from '@/hooks/usePipelineStatus' // --------------------------------------------------------------------------- // Stub "Coming Soon" card @@ -48,6 +50,7 @@ export function DashboardPage() { const { services, loading: healthLoading, initializing: healthInit, refresh } = useServiceHealth() const agentSummary = useAgentSummary() const notifSummary = useNotificationSummary() + const { status: pipelineStatus, loading: pipelineLoading, error: pipelineError, refetch: pipelineRefetch } = usePipelineStatus() // Build a synthetic activity feed from available data const activityEvents: ActivityEvent[] = useMemo(() => { @@ -117,12 +120,22 @@ export function DashboardPage() { error={agentSummary.error} /> - {/* Stub sections */} + {/* Pipeline status + monitoring stub */}
    + } /> +
    + + {/* Remaining stubs */} +
    } />
    diff --git a/ui/src/pages/SettingsPage.tsx b/ui/src/pages/SettingsPage.tsx index 92075ea1..5b7f34fb 100644 --- a/ui/src/pages/SettingsPage.tsx +++ b/ui/src/pages/SettingsPage.tsx @@ -7,6 +7,7 @@ import { useRef, useState } from 'react' import { ServiceConfig } from '@/components/settings/ServiceConfig' import { UIPreferences } from '@/components/settings/UIPreferences' import { AboutSection } from '@/components/settings/AboutSection' +import { PipelineGates } from '@/components/settings/PipelineGates' import { useSettings } from '@/hooks/useSettings' import { resetSettings } from '@/stores/settingsStore' import type { Settings } from '@/stores/settingsStore' @@ -83,6 +84,17 @@ export function SettingsPage() { + {/* Pipeline Gates */} +
    +

    + Autonomous Pipeline Gates +

    +

    + v0.10.0 — defines where human approval is required in the autonomous pipeline. +

    + +
    + {/* About */}

    About

    diff --git a/ui/src/types/pipeline.ts b/ui/src/types/pipeline.ts new file mode 100644 index 00000000..eb66aa8b --- /dev/null +++ b/ui/src/types/pipeline.ts @@ -0,0 +1,164 @@ +/** + * Pipeline types for the v0.9.0 Autonomous Development Pipeline. + * + * The Conductor agent (`.agentd/agents/conductor.yml`, issue #620) manages + * the merge queue and git-spice stack state. The label-driven state machine + * is defined in issue #640; human approval gates are defined in issue #643. + * + * These types represent the data shape exposed to the UI. Until the conductor + * endpoint exists, usePipelineStatus() returns null and PipelineStatusCard + * renders its "not yet active" empty state. + */ + +// --------------------------------------------------------------------------- +// Pipeline stage state machine +// --------------------------------------------------------------------------- + +/** + * The 7-stage label-driven pipeline state machine (issue #640). + * + * Each issue advances through these stages as agents act on it: + * issue-created → triage → enrich → implement → review → merge → document + * + * The Conductor monitors stage transitions via GitHub label changes. + */ +export type PipelineStage = + | 'issue-created' + | 'triage' + | 'enrich' + | 'implement' + | 'review' + | 'merge' + | 'document' + +export const PIPELINE_STAGE_LABELS: Record = { + 'issue-created': 'Issue Created', + triage: 'Triage', + enrich: 'Enrich', + implement: 'Implement', + review: 'Review', + merge: 'Merge', + document: 'Document', +} + +export const PIPELINE_STAGE_ORDER: PipelineStage[] = [ + 'issue-created', + 'triage', + 'enrich', + 'implement', + 'review', + 'merge', + 'document', +] + +// --------------------------------------------------------------------------- +// Merge queue +// --------------------------------------------------------------------------- + +/** A single PR waiting in the merge queue */ +export interface PipelineQueueItem { + /** GitHub PR number */ + prNumber: number + /** PR title (truncated for display) */ + title: string + /** Head branch name */ + branch: string + /** Base branch (trunk = "main", or parent stack branch) */ + baseBranch: string + /** + * Stack depth: 0 = branches directly from trunk (main), + * N = Nth level in a git-spice stack. + */ + stackDepth: number + /** Approved by the reviewer agent */ + approved: boolean + /** CI status. null = checks still running */ + ciPassing: boolean | null +} + +/** Aggregate pipeline state posted by the Conductor every 4 hours */ +export interface PipelineStatus { + /** PRs in the merge queue, ordered bottom-of-stack first */ + mergeQueue: PipelineQueueItem[] + /** Number of distinct open stacks (groups of stacked branches) */ + activeStackCount: number + /** PRs with no activity (commits or review comments) for > 7 days */ + staleCount: number + /** ISO timestamp of the last `git-spice repo sync` run */ + lastSyncAt: string | null + /** ISO timestamp of the last full conductor run */ + conductorLastRunAt: string | null +} + +// --------------------------------------------------------------------------- +// Human interaction gates +// --------------------------------------------------------------------------- + +/** + * Human interaction gate definitions (issue #643). + * + * Always-human gates are hardcoded in the conductor system prompt and cannot + * be changed via the UI. Configurable gates default to autonomous but can + * be flipped per-project in the agent YAML. + */ +export interface PipelineGate { + id: string + label: string + description: string + /** 'always' = hardcoded human required; 'configurable' = default autonomous */ + kind: 'always' | 'configurable' + /** Default state for configurable gates */ + defaultAutonomous?: boolean +} + +export const PIPELINE_GATES: PipelineGate[] = [ + // Always-human gates + { + id: 'git-spice-auth', + label: 'git-spice authentication', + description: 'One-time GitHub auth for git-spice must be performed by a human operator.', + kind: 'always', + }, + { + id: 'production-deploy', + label: 'Production deployments', + description: 'Any deployment to a production environment requires human sign-off.', + kind: 'always', + }, + { + id: 'security-changes', + label: 'Security-sensitive changes', + description: + 'Changes to authentication, secrets handling, or cryptography require human review.', + kind: 'always', + }, + { + id: 'conflict-escalation', + label: 'Merge conflict escalation', + description: + 'When the Conductor cannot automatically restack a conflict, it escalates to a human.', + kind: 'always', + }, + // Configurable gates + { + id: 'pr-auto-merge', + label: 'PR auto-merge', + description: 'Conductor merges approved, CI-passing PRs without human confirmation.', + kind: 'configurable', + defaultAutonomous: true, + }, + { + id: 'issue-auto-close', + label: 'Issue auto-close', + description: 'Issues are automatically closed when their implementation PR merges.', + kind: 'configurable', + defaultAutonomous: true, + }, + { + id: 'new-dependency', + label: 'New dependency additions', + description: 'Adding a new crate or package dependency requires human approval.', + kind: 'configurable', + defaultAutonomous: false, + }, +]