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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user