Files
roboco/panel/src/components/metrics/delivery-tab.tsx
T
cea3e56628 feat(lifecycle): revision findings ledger — structured failure feedback, persisted and delivered down the chain (#486)
* feat(lifecycle): revision findings ledger — structured QA/PR/PM/CEO failure feedback, persisted and delivered down the chain

Every bounce used to survive only as flattened prose: rounds overwrote each
other in notes_structured, request_changes persisted nothing, two raw
dev_notes appends were silently destroyed by the next handoff note, and the
dev prompt pointed at fields (qa_notes via evidence(), pm_notes) the API
never delivered. Agents re-interpreted and re-discovered every failure
before they could start fixing it.

- task_review_findings (migration 071, append-only): file/line/severity/
  criterion(AC-id-validated)/expected/actual/fix/evidence per finding, with
  origin (qa|pr_gate|pm|ceo), round, and an open->addressed->verified
  lifecycle (waived reserved); new tasks.pm_notes + PmReviewContent give
  request_changes a structured home
- producers: fail_review/pr_fail/request_changes take findings=[...] (prose
  issues shimmed+merged for one release, deprecation-logged); ceo_reject
  validates its reason (no 500), lands an origin=ceo finding, and bumps
  round+audit on branchless coordination roots; guardrails at the verb
  chokepoint (nudge >5, hard reject >10, field caps, traversal-safe file);
  the dev_notes data-loss appends are removed; new task.request_changes +
  task.ceo_reject audit events close rework attribution
- delivery: qa_notes/pr_reviewer_notes/pm_notes carry the deterministic
  [F-id8] rendering; claim briefings, evidence(), the REVISION_REQUIRED
  spawn prompt, PM triage bounced-blocks, and A2A bodies deliver open
  findings; round-N+1 QA and gate reviewers get the full prior ledger;
  panel Findings tab + bounced-xN chip; metrics pm_rejects/ceo_rejects +
  findings counts; vault task notes render a Findings section (fail-open)
- resolution closes for every origin: i_am_done and submit_up/submit_root
  take resolved_findings gated by FINDINGS_ADDRESSED (owner-gated so a
  stale non-owner PM can never mutate the ledger); pass_review/pr_pass/
  complete verify-stamp same-transaction; ceo_approve stamps best-effort
- 24 real-DB integration tests drive the full loop through the real
  choreographer; full suite 12856 green

* docs: revision findings ledger sweep — CLAUDE.md, map, RAG corpus

- CLAUDE.md: new ledger section + corrected request_changes row
- docs/map/review-findings.md (new subsystem map) + surgical updates to
  task-service/pr-gate-review/metrics-observability/vault/panel maps
- docs/rag: producers' findings contract across qa/pr-reviewer/developer/
  cell-pm/main-pm/ceo role docs (the PM docs were missing request_changes
  entirely), verb references, and a new architecture/review-findings.md
  disambiguating ledger findings from convention findings

* test(e2e): resubmit resolves the pr_fail finding per the ledger contract

The scripted pr_fail revision loop resubmitted submit_up without
resolved_findings — correctly rejected now that FINDINGS_ADDRESSED gates
the PM resubmit verbs (green locally, red only in CI since the e2e suite
skips without ROBOCO_E2E_SMOKE=1). The scripted PM now reads the open
ledger row pr_fail persisted (new open_finding_ids arc helper) and
resolves it on resubmit, asserting the open set drains — exercising the
coordinator half of the new contract end to end.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-11 22:54:42 +02:00

329 lines
12 KiB
TypeScript

"use client";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import {
ResponsiveTable,
ResponsiveTableCardList,
ResponsiveTableCard,
ResponsiveTableCardRow,
} from "@/components/ui/responsive-table";
import {
useCycleTime,
useBottlenecks,
useRework,
useTeamScorecard,
} from "@/hooks/use-observability";
import type { Scorecard } from "@/types";
const CELLS = ["backend", "frontend", "ux_ui"] as const;
function label(status: string): string {
return status
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
function fmtDuration(seconds: number): string {
if (seconds >= 3600) return (seconds / 3600).toFixed(1) + "h";
if (seconds >= 60) return (seconds / 60).toFixed(0) + "m";
return seconds.toFixed(0) + "s";
}
function pct(rate: number): string {
return (rate * 100).toFixed(1) + "%";
}
// ─── Cycle time ───────────────────────────────────────────────────────────────
function CycleTimeCard() {
const { data, isLoading } = useCycleTime(30);
const chartData = (data ?? []).map((s) => ({
name: label(s.status),
Hours: Number((s.avg_seconds / 3600).toFixed(2)),
}));
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">
Cycle Time by Stage (avg, 30d)
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-52 w-full" />
) : chartData.length === 0 ? (
<p className="text-sm text-muted-foreground py-16 text-center">
No completed transitions in the window yet.
</p>
) : (
<ResponsiveContainer width="100%" height={208}>
<BarChart
data={chartData}
margin={{ top: 4, right: 8, left: 0, bottom: 24 }}
>
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
<XAxis
dataKey="name"
tick={{ fontSize: 10 }}
angle={-30}
textAnchor="end"
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 10 }}
axisLine={false}
tickLine={false}
width={36}
tickFormatter={(v) => v + "h"}
/>
<Tooltip
formatter={(value) => [value + "h", "Avg"]}
contentStyle={{ fontSize: 12 }}
/>
<Bar
dataKey="Hours"
fill="var(--chart-1)"
radius={[3, 3, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}
// ─── Bottlenecks ──────────────────────────────────────────────────────────────
function BottlenecksCard() {
const { data, isLoading } = useBottlenecks(30);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Bottlenecks</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{isLoading ? (
<Skeleton className="h-52 w-full" />
) : (
<>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Worst stage:</span>
{data?.worst_stage ? (
<Badge variant="destructive">{label(data.worst_stage)}</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
<span className="ml-auto text-muted-foreground">
{data?.active_blockers ?? 0} active blockers
</span>
</div>
<div className="space-y-2">
{(data?.by_stage ?? []).slice(0, 6).map((s) => (
<div key={s.status} className="space-y-1">
<div className="flex justify-between text-xs">
<span>{label(s.status)}</span>
<span className="text-muted-foreground">
{fmtDuration(s.cumulative_seconds)} · {s.parked_now}{" "}
parked
</span>
</div>
<div className="h-2 w-full rounded bg-muted">
<div
className="h-2 rounded bg-[var(--chart-2)]"
style={{ width: `${Math.round(s.pct_of_total * 100)}%` }}
/>
</div>
</div>
))}
{(data?.by_stage ?? []).length === 0 && (
<p className="text-sm text-muted-foreground py-12 text-center">
No stage data yet.
</p>
)}
</div>
</>
)}
</CardContent>
</Card>
);
}
// ─── Rework ───────────────────────────────────────────────────────────────────
function ReworkCard() {
const { data, isLoading } = useRework(30);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Rework (30d)</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<Skeleton className="h-52 w-full" />
) : (
<>
<div className="flex items-baseline gap-3">
<span className="text-3xl font-bold">{pct(data?.rate ?? 0)}</span>
<span className="text-sm text-muted-foreground">
{data?.total_reworked ?? 0}/{data?.total_completed ?? 0} bounced
· ${(data?.rework_cost_usd ?? 0).toFixed(2)} cost
</span>
</div>
<div className="flex gap-2 text-xs">
{(data?.by_team ?? []).map((t) => (
<Badge key={t.team} variant="secondary">
{label(t.team)} {pct(t.rate)}
</Badge>
))}
</div>
{(data?.by_agent ?? []).length > 0 && (
<ResponsiveTable
table={
<table className="w-full text-xs">
<thead className="text-muted-foreground">
<tr className="text-left">
<th className="py-1 font-medium">Agent</th>
<th className="py-1 font-medium text-right">Rate</th>
<th className="py-1 font-medium text-right">
QA fails
</th>
<th className="py-1 font-medium text-right">
PR fails
</th>
<th className="py-1 font-medium text-right">
PM rejects
</th>
<th className="py-1 font-medium text-right">
CEO rejects
</th>
</tr>
</thead>
<tbody>
{(data?.by_agent ?? []).slice(0, 8).map((a) => (
<tr
key={a.agent_slug}
className="border-t border-border/50"
>
<td className="py-1">{a.agent_slug}</td>
<td className="py-1 text-right">{pct(a.rate)}</td>
<td className="py-1 text-right">{a.qa_fails}</td>
<td className="py-1 text-right">{a.pr_fails}</td>
<td className="py-1 text-right">{a.pm_rejects}</td>
<td className="py-1 text-right">{a.ceo_rejects}</td>
</tr>
))}
</tbody>
</table>
}
cards={
<ResponsiveTableCardList>
{(data?.by_agent ?? []).slice(0, 8).map((a) => (
<ResponsiveTableCard key={a.agent_slug}>
<span className="text-sm font-medium">
{a.agent_slug}
</span>
<div className="mt-2 divide-y">
<ResponsiveTableCardRow label="Rate">
{pct(a.rate)}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="QA fails">
{a.qa_fails}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="PR fails">
{a.pr_fails}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="PM rejects">
{a.pm_rejects}
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="CEO rejects">
{a.ceo_rejects}
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
))}
</ResponsiveTableCardList>
}
/>
)}
</>
)}
</CardContent>
</Card>
);
}
// ─── Per-cell scorecards ────────────────────────────────────────────────────────
function CellScorecard({ team }: { team: string }) {
const { data, isLoading } = useTeamScorecard(team, 7);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">{label(team)} (7d)</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-24 w-full" />
) : (
<ScorecardBody card={data} />
)}
</CardContent>
</Card>
);
}
function ScorecardBody({ card }: { card: Scorecard | undefined }) {
const stat = (k: string, v: string) => (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{k}</span>
<span className="font-medium">{v}</span>
</div>
);
return (
<div className="space-y-1">
{stat("Completed", String(card?.tasks_completed ?? 0))}
{stat(
"Avg cycle",
card?.avg_cycle_hours != null
? card.avg_cycle_hours.toFixed(1) + "h"
: "—",
)}
{stat("Rework", pct(card?.rework_rate ?? 0))}
{stat("Cost", "$" + (card?.cost_usd ?? 0).toFixed(2))}
</div>
);
}
// ─── Tab ──────────────────────────────────────────────────────────────────────
export function DeliveryTabContent() {
return (
<div className="space-y-4">
<div className="grid gap-4 lg:grid-cols-2">
<CycleTimeCard />
<BottlenecksCard />
</div>
<ReworkCard />
<div className="grid gap-4 md:grid-cols-3">
{CELLS.map((team) => (
<CellScorecard key={team} team={team} />
))}
</div>
</div>
);
}