Add skill version timeline (fingerprint lineage)

Task #14: show a full version timeline for each skill family, not just the
single most-similar prior scan.

What changed:
- OpenAPI spec (lib/api-spec/openapi.yaml): new GET /scans/{id}/lineage
  (operationId getScanLineage) returning an array of ScanLineageEntry
  (id, name, verdict, riskScore, relation, similarity, comparedScanId,
  fingerprint, createdAt). Regenerated api-zod + api-client-react via codegen.
- API (artifacts/api-server/src/routes/scans.ts): new lineage endpoint.
  Builds an undirected graph over all scans linked by the comparedScanId chain
  AND identical (non-empty) fingerprints, then BFS-walks the connected
  component containing the requested scan and returns it newest-first. Works
  purely from existing data, no re-scanning. 404 for unknown ids.
- UI (artifacts/skillguard/src/pages/scan-report.tsx): new VersionTimeline
  card rendering the family as a vertical timeline; each entry shows verdict,
  relation badge, similarity, risk score and date. The viewed scan is marked
  "Aktuell angezeigt"; every other entry links to the existing comparison view
  /vergleich/{viewedId}/{entryId}. Card hidden when the family has <=1 member.

Notes:
- Lineage = connected component, so any member returns the full family.
- Verified end-to-end locally (created new/modified/identical chain, checked
  lineage ordering + 404, confirmed timeline + compare links in the UI),
  then deleted the test scans.

Replit-Task-Id: c7f87ce6-59d8-4396-b16b-f20846f42f0b
This commit is contained in:
amertensreplit 2026-06-10 19:47:39 +00:00
parent ba9788a93c
commit 54323706b5
13 changed files with 477 additions and 2 deletions

View file

@ -2,3 +2,4 @@
- [OpenAI gpt-5 temperature](openai-temperature-gpt5.md) — gpt-5* reject `temperature != 1`; omit temperature in OpenAI-compatible clients or AI analysis silently fails.
- [NDJSON streaming on Replit](ndjson-streaming-express-replit.md) — use `res.on("close")`+`writableFinished` (NOT `req.on("close")`); persist on disconnect; proxy doesn't buffer; gate fallback to avoid dup rows.
- [Skill fingerprint & relation matching](skill-fingerprint-matching.md) — don't put display name in fingerprint path; match modified by file-path Jaccard (hash-Jaccard misses single-file edits), report content-aware similarity.
- [Testing api-server from shell](api-server-local-curl.md) — external `$REPLIT_DEV_DOMAIN/api` curl returns HTTP 000; curl `http://localhost:<PORT>/api` instead (port from workflow log).

View file

@ -0,0 +1,15 @@
---
name: Testing the SkillGuard api-server from the shell
description: How to reach the api-server with curl during development (external proxy fails).
---
# Testing the api-server from the shell
Curling the api-server through the external proxy (`$REPLIT_DEV_DOMAIN/api/...`)
fails during development — it returns HTTP 000 / connection refused (the proxy
routes by the selected artifact + mTLS, so a bare curl does not reach it).
**How to apply:** curl the api-server directly on its local port instead, e.g.
`http://localhost:8080/api/scans`. Confirm the port from the api-server workflow
log line `Server listening port: <PORT>` (it reads `PORT`, defaulting to 8080).
The web artifact's preview, however, reaches the API fine through the proxy.

View file

@ -18,6 +18,7 @@ import {
DeleteScanParams,
CompareScansParams,
CompareScansResponse,
GetScanLineageResponse,
} from "@workspace/api-zod";
import {
parseZip,
@ -639,6 +640,88 @@ router.get("/scans/:id/compare/:otherId", async (req, res) => {
);
});
router.get("/scans/:id/lineage", async (req, res) => {
const params = GetScanParams.safeParse(req.params);
if (!params.success)
return res.status(400).json({ message: "Ungültige ID" });
const [scan] = await db
.select()
.from(scansTable)
.where(eq(scansTable.id, params.data.id));
if (!scan) return res.status(404).json({ message: "Scan nicht gefunden" });
// Load only the columns needed to reconstruct the lineage graph for every
// stored scan, then walk the connected component containing this scan.
const all = await db
.select({
id: scansTable.id,
name: scansTable.name,
verdict: scansTable.verdict,
riskScore: scansTable.riskScore,
relation: scansTable.relation,
similarity: scansTable.similarity,
comparedScanId: scansTable.comparedScanId,
fingerprint: scansTable.fingerprint,
createdAt: scansTable.createdAt,
})
.from(scansTable);
const byId = new Map(all.map((s) => [s.id, s]));
// Build an undirected graph: scans are linked when one was compared against
// the other (comparedScanId chain) or when they share an identical
// fingerprint. The fingerprint family is the connected component.
const adjacency = new Map<number, Set<number>>();
const addEdge = (a: number, b: number) => {
if (!byId.has(a) || !byId.has(b) || a === b) return;
(adjacency.get(a) ?? adjacency.set(a, new Set()).get(a)!).add(b);
(adjacency.get(b) ?? adjacency.set(b, new Set()).get(b)!).add(a);
};
const byFingerprint = new Map<string, number[]>();
for (const s of all) {
if (s.comparedScanId != null) addEdge(s.id, s.comparedScanId);
if (s.fingerprint) {
const list = byFingerprint.get(s.fingerprint) ?? [];
list.push(s.id);
byFingerprint.set(s.fingerprint, list);
}
}
for (const ids of byFingerprint.values()) {
for (let i = 1; i < ids.length; i++) addEdge(ids[0], ids[i]);
}
const family = new Set<number>([scan.id]);
const queue: number[] = [scan.id];
while (queue.length > 0) {
const cur = queue.shift()!;
for (const next of adjacency.get(cur) ?? []) {
if (!family.has(next)) {
family.add(next);
queue.push(next);
}
}
}
const entries = Array.from(family)
.map((fid) => byId.get(fid)!)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.map((s) => ({
id: s.id,
name: s.name,
verdict: s.verdict,
riskScore: s.riskScore,
relation: s.relation,
similarity: s.similarity,
comparedScanId: s.comparedScanId,
fingerprint: s.fingerprint,
createdAt: s.createdAt.toISOString(),
}));
return res.json(GetScanLineageResponse.parse(entries));
});
router.delete("/scans/:id", async (req, res) => {
const params = DeleteScanParams.safeParse(req.params);
if (!params.success)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View file

@ -1,6 +1,11 @@
import { useState, useMemo } from "react";
import { useRoute, Link } from "wouter";
import { useGetScan, getGetScanQueryKey } from "@workspace/api-client-react";
import {
useGetScan,
getGetScanQueryKey,
useGetScanLineage,
getGetScanLineageQueryKey,
} from "@workspace/api-client-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
@ -11,7 +16,7 @@ import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { VerdictBadge, SeverityBadge, AxisBadge, CheckpointStatusBadge, CHECKPOINT_STATUS_LABELS, RelationBadge } from "@/components/ui-helpers";
import { formatDate } from "@/lib/format";
import { ShieldQuestion, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History } from "lucide-react";
import { ShieldQuestion, AlertTriangle, Download, FileCode, CheckCircle2, Code, Shield, FileDown, ListChecks, Fingerprint, GitCompare, History, GitCommitVertical } from "lucide-react";
import type { ScanDetail } from "@workspace/api-client-react";
export default function ScanReport() {
@ -265,6 +270,8 @@ export default function ScanReport() {
</CardContent>
</Card>
<VersionTimeline scanId={data.id} />
<Tabs defaultValue="findings" className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="findings" className="gap-2"><Shield className="w-4 h-4"/> Auffälligkeiten ({data.findings.length})</TabsTrigger>
@ -469,6 +476,87 @@ export default function ScanReport() {
);
}
function VersionTimeline({ scanId }: { scanId: number }) {
const { data, isLoading } = useGetScanLineage(scanId, {
query: {
enabled: Number.isFinite(scanId) && scanId > 0,
queryKey: getGetScanLineageQueryKey(scanId),
},
});
if (isLoading) {
return <Skeleton className="h-48 w-full" />;
}
// Nothing meaningful to show unless this skill has more than one known version.
if (!data || data.length <= 1) return null;
return (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<History className="w-5 h-5" /> Versionsverlauf
</CardTitle>
<CardDescription>
Alle bekannten Versionen dieses Skills (verknüpft über Fingerprint-Abstammung), neueste zuerst. Wählen Sie eine Version, um den Vergleich anzuzeigen.
</CardDescription>
</CardHeader>
<CardContent>
<ol className="relative border-l border-border ml-3 space-y-6">
{data.map((entry) => {
const isCurrent = entry.id === scanId;
return (
<li key={entry.id} className="ml-6">
<span
className={`absolute -left-[9px] flex h-4 w-4 items-center justify-center rounded-full ring-4 ring-background ${
isCurrent ? "bg-primary" : "bg-muted-foreground/40"
}`}
>
<GitCommitVertical className="w-3 h-3 text-background" />
</span>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between rounded-lg border bg-muted/20 p-3">
<div className="flex flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-2">
{isCurrent ? (
<span className="font-medium">{entry.name || `Scan #${entry.id}`}</span>
) : (
<Link href={`/berichte/${entry.id}`} className="font-medium hover:underline">
{entry.name || `Scan #${entry.id}`}
</Link>
)}
<VerdictBadge verdict={entry.verdict} />
<RelationBadge relation={entry.relation} />
{entry.relation === "modified" && entry.similarity != null && (
<Badge variant="outline" className="font-mono text-xs">{entry.similarity}% ähnlich</Badge>
)}
{isCurrent && (
<Badge variant="secondary" className="text-xs">Aktuell angezeigt</Badge>
)}
</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{formatDate(entry.createdAt)}</span>
<span>&middot;</span>
<span>Risiko {entry.riskScore} / 100</span>
</div>
</div>
{!isCurrent && (
<Button asChild variant="outline" size="sm" className="gap-2 shrink-0">
<Link href={`/vergleich/${scanId}/${entry.id}`}>
<GitCompare className="w-4 h-4" />
Vergleich
</Link>
</Button>
)}
</div>
</li>
);
})}
</ol>
</CardContent>
</Card>
);
}
const VERDICT_LABELS: Record<string, string> = {
pass: "Freigabe",
review: "Manuelle Prüfung",

View file

@ -286,6 +286,52 @@ export type ScanDetail = Scan & ({
comparedScan: ComparedScan | null;
});
export type ScanLineageEntryVerdict = typeof ScanLineageEntryVerdict[keyof typeof ScanLineageEntryVerdict];
export const ScanLineageEntryVerdict = {
pass: 'pass',
review: 'review',
block: 'block',
} as const;
/**
* Relation of this version to the one it was compared against
* @nullable
*/
export type ScanLineageEntryRelation = typeof ScanLineageEntryRelation[keyof typeof ScanLineageEntryRelation] | null;
export const ScanLineageEntryRelation = {
new: 'new',
identical: 'identical',
modified: 'modified',
} as const;
export interface ScanLineageEntry {
id: number;
name: string;
verdict: ScanLineageEntryVerdict;
riskScore: number;
/**
* Relation of this version to the one it was compared against
* @nullable
*/
relation: ScanLineageEntryRelation;
/**
* Content-aware similarity (0-100) to its compared version
* @nullable
*/
similarity: number | null;
/**
* The prior version this scan was compared against, if any
* @nullable
*/
comparedScanId: number | null;
fingerprint: string;
createdAt: string;
}
export type ScanComparisonSideVerdict = typeof ScanComparisonSideVerdict[keyof typeof ScanComparisonSideVerdict];

View file

@ -35,6 +35,7 @@ import type {
Scan,
ScanComparison,
ScanDetail,
ScanLineageEntry,
SkillScanInput
} from './api.schemas';
@ -438,6 +439,84 @@ export function useCompareScans<TData = Awaited<ReturnType<typeof compareScans>>
export const getGetScanLineageUrl = (id: number,) => {
return `/api/scans/${id}/lineage`
}
/**
* Returns every scan in the same fingerprint lineage as the given scan (linked by an identical fingerprint or by the comparedScanId chain), newest first, so the full version history of a skill can be shown on a timeline without re-scanning.
* @summary Get the version timeline for a skill family
*/
export const getScanLineage = async (id: number, options?: RequestInit): Promise<ScanLineageEntry[]> => {
return customFetch<ScanLineageEntry[]>(getGetScanLineageUrl(id),
{
...options,
method: 'GET'
}
);}
export const getGetScanLineageQueryKey = (id: number,) => {
return [
`/api/scans/${id}/lineage`
] as const;
}
export const getGetScanLineageQueryOptions = <TData = Awaited<ReturnType<typeof getScanLineage>>, TError = ErrorType<ApiError>>(id: number, options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getScanLineage>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetScanLineageQueryKey(id);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getScanLineage>>> = ({ signal }) => getScanLineage(id, { signal, ...requestOptions });
return { queryKey, queryFn, enabled: !!(id), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getScanLineage>>, TError, TData> & { queryKey: QueryKey }
}
export type GetScanLineageQueryResult = NonNullable<Awaited<ReturnType<typeof getScanLineage>>>
export type GetScanLineageQueryError = ErrorType<ApiError>
/**
* @summary Get the version timeline for a skill family
*/
export function useGetScanLineage<TData = Awaited<ReturnType<typeof getScanLineage>>, TError = ErrorType<ApiError>>(
id: number, options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getScanLineage>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetScanLineageQueryOptions(id,options)
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey };
}
export const getGetScanUrl = (id: number,) => {

View file

@ -125,6 +125,38 @@ paths:
schema:
$ref: "#/components/schemas/ApiError"
/scans/{id}/lineage:
get:
operationId: getScanLineage
tags: [scans]
summary: Get the version timeline for a skill family
description: >-
Returns every scan in the same fingerprint lineage as the given scan
(linked by an identical fingerprint or by the comparedScanId chain),
newest first, so the full version history of a skill can be shown on a
timeline without re-scanning.
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
"200":
description: Version timeline (most recent first)
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/ScanLineageEntry"
"404":
description: Not found
content:
application/json:
schema:
$ref: "#/components/schemas/ApiError"
/scans/{id}:
get:
operationId: getScan
@ -598,6 +630,43 @@ components:
createdAt:
type: string
ScanLineageEntry:
type: object
required:
- id
- name
- verdict
- riskScore
- relation
- similarity
- comparedScanId
- fingerprint
- createdAt
properties:
id:
type: integer
name:
type: string
verdict:
type: string
enum: [pass, review, block]
riskScore:
type: integer
relation:
type: ["string", "null"]
enum: [new, identical, modified, null]
description: Relation of this version to the one it was compared against
similarity:
type: ["integer", "null"]
description: Content-aware similarity (0-100) to its compared version
comparedScanId:
type: ["integer", "null"]
description: The prior version this scan was compared against, if any
fingerprint:
type: string
createdAt:
type: string
ScanComparisonSide:
type: object
required: [id, name, verdict, riskScore, fileCount, fingerprint, createdAt]

View file

@ -168,6 +168,28 @@ export const CompareScansResponse = zod.object({
})
/**
* Returns every scan in the same fingerprint lineage as the given scan (linked by an identical fingerprint or by the comparedScanId chain), newest first, so the full version history of a skill can be shown on a timeline without re-scanning.
* @summary Get the version timeline for a skill family
*/
export const GetScanLineageParams = zod.object({
"id": zod.coerce.number()
})
export const GetScanLineageResponseItem = zod.object({
"id": zod.number(),
"name": zod.string(),
"verdict": zod.enum(['pass', 'review', 'block']),
"riskScore": zod.number(),
"relation": zod.union([zod.literal('new'),zod.literal('identical'),zod.literal('modified'),zod.literal(null)]).nullable().describe('Relation of this version to the one it was compared against'),
"similarity": zod.number().nullable().describe('Content-aware similarity (0-100) to its compared version'),
"comparedScanId": zod.number().nullable().describe('The prior version this scan was compared against, if any'),
"fingerprint": zod.string(),
"createdAt": zod.string()
})
export const GetScanLineageResponse = zod.array(GetScanLineageResponseItem)
/**
* @summary Get a scan report with findings
*/

View file

@ -52,6 +52,9 @@ export * from './scanFile';
export * from './scanFileDiff';
export * from './scanFileDiffStatus';
export * from './scanFileKind';
export * from './scanLineageEntry';
export * from './scanLineageEntryRelation';
export * from './scanLineageEntryVerdict';
export * from './scanRelation';
export * from './scanSource';
export * from './scanStatus';

View file

@ -0,0 +1,33 @@
/**
* Generated by orval v8.9.1 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
import type { ScanLineageEntryRelation } from './scanLineageEntryRelation';
import type { ScanLineageEntryVerdict } from './scanLineageEntryVerdict';
export interface ScanLineageEntry {
id: number;
name: string;
verdict: ScanLineageEntryVerdict;
riskScore: number;
/**
* Relation of this version to the one it was compared against
* @nullable
*/
relation: ScanLineageEntryRelation;
/**
* Content-aware similarity (0-100) to its compared version
* @nullable
*/
similarity: number | null;
/**
* The prior version this scan was compared against, if any
* @nullable
*/
comparedScanId: number | null;
fingerprint: string;
createdAt: string;
}

View file

@ -0,0 +1,20 @@
/**
* Generated by orval v8.9.1 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
/**
* Relation of this version to the one it was compared against
* @nullable
*/
export type ScanLineageEntryRelation = typeof ScanLineageEntryRelation[keyof typeof ScanLineageEntryRelation] | null;
export const ScanLineageEntryRelation = {
new: 'new',
identical: 'identical',
modified: 'modified',
} as const;

View file

@ -0,0 +1,16 @@
/**
* Generated by orval v8.9.1 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
export type ScanLineageEntryVerdict = typeof ScanLineageEntryVerdict[keyof typeof ScanLineageEntryVerdict];
export const ScanLineageEntryVerdict = {
pass: 'pass',
review: 'review',
block: 'block',
} as const;