feat(FN-3998): add task lineage commit associations API, UI, and tests

Adds task lineage commit associations: a new API route stores and exposes which commits belong to which task, the `TaskChangesTab` surfaces these lineage links visually, and documentation covers the reconciliation model. Includes comprehensive tests for both the route and component.

Fusion-Task-Id: FN-3998
This commit is contained in:
Fusion
2026-05-11 04:58:02 -07:00
committed by gsxdsm
parent f870f07cab
commit fe8c186527
19 changed files with 1217 additions and 12 deletions

View File

@@ -83,6 +83,7 @@ import {
type ExecutorStats,
type ExecutorState,
triggerInsightRun,
fetchTaskCommitAssociations,
} from "../api";
import type { Task, TaskDetail, BatchStatusResponse, MergeResult } from "@fusion/core";
import { clearAuthToken } from "../auth";
@@ -222,6 +223,43 @@ describe("fetchTaskDetail", () => {
});
});
describe("fetchTaskCommitAssociations", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("requests the commit-associations endpoint", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {
taskId: "FN-001",
lineageId: "lineage-1",
associations: [],
}));
await fetchTaskCommitAssociations("FN-001");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/commit-associations", {
headers: { "Content-Type": "application/json" },
});
});
it("adds projectId query when provided", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {
taskId: "FN-001",
lineageId: "lineage-1",
associations: [],
}));
await fetchTaskCommitAssociations("FN-001", "project-abc");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/tasks/FN-001/commit-associations?projectId=project-abc",
{ headers: { "Content-Type": "application/json" } },
);
});
});
describe("uploadAttachment", () => {
const originalFetch = globalThis.fetch;

View File

@@ -6267,6 +6267,27 @@ export function fetchTaskDiff(taskId: string, worktree?: string, projectId?: str
return api<TaskDiff>(`/tasks/${encodeURIComponent(taskId)}/diff${query}`);
}
export interface TaskCommitAssociationRow {
commitSha: string;
commitSubject: string;
authoredAt: string;
matchedBy: "canonical-lineage-trailer" | "legacy-task-id-trailer" | "legacy-subject" | "manual-reconciliation";
confidence: "canonical" | "legacy" | "ambiguous";
taskIdSnapshot: string;
note?: string;
}
export interface TaskCommitAssociationsResponse {
taskId: string;
lineageId: string | null;
associations: TaskCommitAssociationRow[];
}
/** Fetch lineage commit associations for a task */
export function fetchTaskCommitAssociations(taskId: string, projectId?: string): Promise<TaskCommitAssociationsResponse> {
return api<TaskCommitAssociationsResponse>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/commit-associations`, projectId));
}
/** Individual file diff */
export interface TaskFileDiff {
path: string;

View File

@@ -34,3 +34,104 @@
.changes-file-header--static:focus-visible {
box-shadow: none;
}
.task-lineage-associations {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-bottom: var(--space-md);
padding: var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
}
.task-lineage-associations-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.task-lineage-associations-header h4 {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
margin: 0;
}
.task-lineage-id,
.task-lineage-sha {
font-family: var(--font-mono);
font-size: 0.75rem;
}
.task-lineage-associations-empty {
margin: 0;
color: var(--text-muted);
}
.task-lineage-associations-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.task-lineage-association {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-sm);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--card);
}
.task-lineage-association--legacy,
.task-lineage-association--ambiguous {
opacity: 0.78;
}
.task-lineage-association-main {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.task-lineage-subject {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-lineage-association-meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
color: var(--text-muted);
font-size: 0.75rem;
}
.task-lineage-note {
margin: 0;
color: var(--text-dim);
font-size: 0.75rem;
}
@media (max-width: 768px) {
.task-lineage-associations-header {
flex-direction: column;
align-items: flex-start;
}
.task-lineage-association-main {
flex-wrap: wrap;
}
.task-lineage-subject {
white-space: normal;
overflow: visible;
text-overflow: clip;
}
}

View File

@@ -2,7 +2,12 @@ import { useState, useEffect, useCallback } from "react";
import { FileCode, ChevronDown, ChevronRight, ChevronLeft, AlertCircle, GitCommit, WrapText, Maximize2 } from "lucide-react";
import type { MergeDetails, Column } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import { fetchTaskDiff, type TaskDiff } from "../api";
import {
fetchTaskDiff,
fetchTaskCommitAssociations,
type TaskDiff,
type TaskCommitAssociationRow,
} from "../api";
import { highlightDiff } from "../utils/highlightDiff";
import { ChangesDiffModal } from "./ChangesDiffModal";
import "./TaskDiffShared.css";
@@ -118,6 +123,8 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
const [stats, setStats] = useState<{ filesChanged: number; additions: number; deletions: number }>({ filesChanged: 0, additions: 0, deletions: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [commitAssociations, setCommitAssociations] = useState<TaskCommitAssociationRow[]>([]);
const [lineageId, setLineageId] = useState<string | null>(null);
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
const [currentFileIndex, setCurrentFileIndex] = useState<number | null>(null);
const [wordWrap, setWordWrap] = useState(true);
@@ -131,7 +138,7 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
const canLoad = (column === "in-progress" || column === "in-review") || isDoneWithCommit;
const loadDiff = useCallback(async () => {
if (!canLoad) {
if (!canLoad && !isDone) {
setLoading(false);
return;
}
@@ -139,6 +146,16 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
try {
setLoading(true);
setError(null);
const associationsData = await fetchTaskCommitAssociations(taskId, projectId);
setLineageId(associationsData.lineageId);
setCommitAssociations(associationsData.associations);
if (!canLoad) {
setFiles([]);
setStats({ filesChanged: 0, additions: 0, deletions: 0 });
return;
}
const data: TaskDiff = await fetchTaskDiff(taskId, undefined, projectId);
const normalized: NormalizedFile[] = data.files.map((f) => ({
path: f.path,
@@ -154,11 +171,11 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
setCurrentFileIndex(0);
}
} catch (err) {
setError(getErrorMessage(err) || "Failed to load diff");
setError(getErrorMessage(err) || "Failed to load task changes");
} finally {
setLoading(false);
}
}, [taskId, projectId, canLoad]);
}, [taskId, projectId, canLoad, isDone]);
useEffect(() => {
loadDiff();
@@ -261,13 +278,55 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
);
}
const renderCommitAssociations = () => (
<section className="task-lineage-associations" aria-label="Task commit associations">
<div className="task-lineage-associations-header">
<h4>
<GitCommit size={16} />
Lineage commit associations
</h4>
{lineageId && (
<code className="task-lineage-id">{lineageId}</code>
)}
</div>
{commitAssociations.length === 0 ? (
<p className="task-lineage-associations-empty">No associated commits recorded yet.</p>
) : (
<div className="task-lineage-associations-list">
{commitAssociations.map((association) => {
const matchedLabel = association.matchedBy.replace(/-/g, " ");
return (
<article
key={`${association.commitSha}-${association.matchedBy}`}
className={`task-lineage-association task-lineage-association--${association.confidence}`}
>
<div className="task-lineage-association-main">
<code className="task-lineage-sha">{association.commitSha.slice(0, 7)}</code>
<span className="task-lineage-subject">{association.commitSubject}</span>
</div>
<div className="task-lineage-association-meta">
<span>{new Date(association.authoredAt).toLocaleString()}</span>
<span>Confidence: {association.confidence}</span>
<span>Match: {matchedLabel}</span>
<span>Task snapshot: {association.taskIdSnapshot}</span>
</div>
{association.note && <p className="task-lineage-note">{association.note}</p>}
</article>
);
})}
</div>
)}
</section>
);
if (files.length === 0) {
if (modifiedFiles && modifiedFiles.length > 0) {
return renderModifiedFilesFallback(modifiedFiles, isDone, mergeDetails);
}
return (
<div className="detail-section">
<div className="detail-section task-changes-tab">
{renderCommitAssociations()}
<div className="task-changes-state task-changes-state--empty">
<FileCode size={24} />
<p>No files modified.</p>
@@ -283,6 +342,7 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
return (
<div className="detail-section task-changes-tab">
{renderCommitAssociations()}
{/* Commit metadata for done tasks */}
{isDone && mergeDetails && (
<div className="commit-diff-meta">

View File

@@ -5,9 +5,11 @@ import { TaskChangesTab } from "../TaskChangesTab";
import type { MergeDetails, Column } from "@fusion/core";
const mockFetchTaskDiff = vi.fn();
const mockFetchTaskCommitAssociations = vi.fn();
vi.mock("../../api", () => ({
fetchTaskDiff: (...args: any[]) => mockFetchTaskDiff(...args),
fetchTaskCommitAssociations: (...args: any[]) => mockFetchTaskCommitAssociations(...args),
}));
vi.mock("lucide-react", () => ({
@@ -49,6 +51,52 @@ const MERGE_DETAILS: MergeDetails = {
beforeEach(() => {
mockFetchTaskDiff.mockReset();
mockFetchTaskCommitAssociations.mockReset();
mockFetchTaskCommitAssociations.mockResolvedValue({
taskId: "FN-001",
lineageId: "lineage-1",
associations: [],
});
});
describe("TaskChangesTab — commit associations", () => {
it("renders empty-state copy when no commit associations exist", async () => {
mockFetchTaskDiff.mockResolvedValue({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } });
render(<TaskChangesTab taskId="FN-001" worktree="/path/to/worktree" column="in-progress" />);
await waitFor(() => {
expect(screen.getByText("No associated commits recorded yet.")).toBeTruthy();
});
});
it("renders populated commit association rows with confidence metadata", async () => {
mockFetchTaskDiff.mockResolvedValue({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } });
mockFetchTaskCommitAssociations.mockResolvedValue({
taskId: "FN-001",
lineageId: "lineage-1",
associations: [
{
commitSha: "abc1234567",
commitSubject: "feat: lineage",
authoredAt: "2026-05-11T00:00:00.000Z",
matchedBy: "manual-reconciliation",
confidence: "ambiguous",
taskIdSnapshot: "FN-3953",
note: "legacy mismatch",
},
],
});
const { container } = render(<TaskChangesTab taskId="FN-001" worktree="/path/to/worktree" column="in-progress" />);
await waitFor(() => {
expect(screen.getByText("feat: lineage")).toBeTruthy();
});
expect(screen.getByText("Confidence: ambiguous")).toBeTruthy();
expect(screen.getByText("Match: manual reconciliation")).toBeTruthy();
expect(container.querySelector(".task-lineage-association--ambiguous")).toBeTruthy();
});
});
describe("TaskChangesTab — worktree-backed (non-done tasks)", () => {

View File

@@ -0,0 +1,149 @@
import { describe, it, expect } from "vitest";
import { EventEmitter } from "node:events";
import type { Task, TaskCommitAssociation } from "@fusion/core";
import { createServer } from "../server.js";
class MockStore extends EventEmitter {
private tasks = new Map<string, Task>();
private associations = new Map<string, TaskCommitAssociation[]>();
getRootDir(): string {
return "/tmp/fn-3998";
}
getFusionDir(): string {
return "/tmp/fn-3998/.fusion";
}
getDatabase() {
return {
exec: () => undefined,
prepare: () => ({ run: () => ({ changes: 0 }), get: () => undefined, all: () => [] }),
};
}
getMissionStore() {
return {
listMissions: async () => [],
createMission: () => undefined,
getMission: () => undefined,
updateMission: () => undefined,
deleteMission: () => undefined,
listTemplates: async () => [],
createTemplate: () => undefined,
getTemplate: () => undefined,
updateTemplate: () => undefined,
deleteTemplate: () => undefined,
instantiateMission: () => undefined,
};
}
async listTasks(): Promise<Task[]> {
return Array.from(this.tasks.values());
}
getTask(id: string): Task | undefined {
return this.tasks.get(id);
}
addTask(task: Task): void {
this.tasks.set(task.id, task);
}
setAssociations(lineageId: string, rows: TaskCommitAssociation[]): void {
this.associations.set(lineageId, rows);
}
async getTaskCommitAssociationsByLineageId(lineageId: string): Promise<TaskCommitAssociation[]> {
return this.associations.get(lineageId) ?? [];
}
}
function createTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-3998",
title: "Lineage task",
description: "Test",
column: "done",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-05-11T00:00:00.000Z",
updatedAt: "2026-05-11T00:00:00.000Z",
columnMovedAt: "2026-05-11T00:00:00.000Z",
lineageId: "lineage-1",
...overrides,
};
}
async function getCommitAssociations(
app: Parameters<typeof import("../test-request.js").get>[0],
taskId: string,
): Promise<{ status: number; body: any }> {
const { get } = await import("../test-request.js");
return get(app, `/api/tasks/${taskId}/commit-associations`);
}
describe("GET /api/tasks/:id/commit-associations", () => {
it("returns 404 when task is unknown", async () => {
const app = createServer(new MockStore() as any);
const response = await getCommitAssociations(app, "FN-UNKNOWN");
expect(response.status).toBe(404);
expect(response.body).toEqual({ error: "Task not found" });
});
it("returns lineage associations for a known task", async () => {
const store = new MockStore();
store.addTask(createTask({ id: "FN-2000", lineageId: "lineage-2000" }));
store.setAssociations("lineage-2000", [
{
id: "assoc-1",
taskLineageId: "lineage-2000",
taskIdSnapshot: "FN-2000",
commitSha: "abc1234def",
commitSubject: "feat(FN-2000): add lineage API",
authoredAt: "2026-05-11T02:00:00.000Z",
matchedBy: "canonical-lineage-trailer",
confidence: "canonical",
note: "primary commit",
createdAt: "2026-05-11T02:01:00.000Z",
updatedAt: "2026-05-11T02:01:00.000Z",
},
]);
const app = createServer(store as any);
const response = await getCommitAssociations(app, "FN-2000");
expect(response.status).toBe(200);
expect(response.body).toEqual({
taskId: "FN-2000",
lineageId: "lineage-2000",
associations: [
{
commitSha: "abc1234def",
commitSubject: "feat(FN-2000): add lineage API",
authoredAt: "2026-05-11T02:00:00.000Z",
matchedBy: "canonical-lineage-trailer",
confidence: "canonical",
taskIdSnapshot: "FN-2000",
note: "primary commit",
},
],
});
});
it("returns empty associations for known task with no rows", async () => {
const store = new MockStore();
store.addTask(createTask({ id: "FN-2001", lineageId: "lineage-2001" }));
const app = createServer(store as any);
const response = await getCommitAssociations(app, "FN-2001");
expect(response.status).toBe(200);
expect(response.body).toEqual({
taskId: "FN-2001",
lineageId: "lineage-2001",
associations: [],
});
});
});

View File

@@ -46,6 +46,7 @@ const fileDiffsCache = new Map<
* - GET /tasks/:id/session-files
* - GET /tasks/:id/diff
* - GET /tasks/:id/file-diffs
* - GET /tasks/:id/commit-associations
*/
export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRouteDeps): void {
const { getProjectContext } = deps;
@@ -518,4 +519,47 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute
rethrowAsApiError(err, "Internal server error");
}
});
router.get("/tasks/:id/commit-associations", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
if (!task) {
res.status(404).json({ error: "Task not found" });
return;
}
if (!task.lineageId) {
res.json({
taskId: task.id,
lineageId: null,
associations: [],
});
return;
}
const associations = await scopedStore.getTaskCommitAssociationsByLineageId(task.lineageId);
res.json({
taskId: task.id,
lineageId: task.lineageId,
associations: associations.map((association) => ({
commitSha: association.commitSha,
commitSubject: association.commitSubject,
authoredAt: association.authoredAt,
matchedBy: association.matchedBy,
confidence: association.confidence,
taskIdSnapshot: association.taskIdSnapshot,
note: association.note,
})),
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw notFound(`Task ${req.params.id} not found`);
}
rethrowAsApiError(err, "Internal server error");
}
});
}

View File

@@ -21,7 +21,7 @@ const qualityAppTests = [
const qualityApiTests = [
// Critical HTTP/server behavior: auth, task/project/settings mutation,
// git/GitHub, agents, nodes, chat/files, realtime, and isolation guards.
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,project-routes,project-store-resolver,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-git,routes-github,routes-nodes,routes-settings,routes-tasks,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket}.test.ts",
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,project-routes,project-store-resolver,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-git,routes-github,routes-nodes,routes-settings,routes-task-commit-associations,routes-tasks,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket}.test.ts",
"src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes,stash-recovery-routes}.test.ts",
];