feat(FN-3276): add task review tab with metadata persistence and desktop la

This merge lands three major features and a significant dashboard enhancement. FN-3276 adds a full Review tab to the task detail modal with multi-step lifecycle: review metadata persistence in the task store, new task workflow routes for refresh and same-task revision, and the review tab UI surface

Fusion-Task-Id: FN-3276
This commit is contained in:
Fusion
2026-05-07 21:28:56 -07:00
committed by gsxdsm
parent a1354685d0
commit ecbf1f829c
23 changed files with 905 additions and 24 deletions

View File

@@ -4088,6 +4088,76 @@ describe("TaskStore", () => {
expect(restored.sourceIssue).toEqual(sourceIssue);
});
it("persists review metadata on create, update, and reload", async () => {
const review: NonNullable<Task["review"]> = {
mode: "direct",
source: "reviewer-agent",
decision: "changes-requested",
summary: "Address reviewer findings",
latestRefreshAt: new Date().toISOString(),
selectedItemIds: ["rvw-1"],
items: [
{
id: "rvw-1",
source: "reviewer-agent",
status: "queued",
summary: "Fix failing assertion",
body: "Assertion in task detail modal test is stale.",
reviewer: "reviewer",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
};
const created = await store.createTask({ description: "Task with review metadata" });
const updated = await store.updateTask(created.id, { review });
expect(updated.review).toEqual(review);
const reloaded = await store.getTask(created.id);
expect(reloaded.review).toEqual(review);
const cleared = await store.updateTask(created.id, { review: null });
expect(cleared.review).toBeUndefined();
});
it("preserves review metadata through archive and unarchive", async () => {
const review: NonNullable<Task["review"]> = {
mode: "pull-request",
source: "github-pr",
decision: "pending",
summary: "PR review feedback",
latestRefreshAt: new Date().toISOString(),
selectedItemIds: ["gh-1"],
items: [
{
id: "gh-1",
source: "github-pr",
status: "in-progress",
summary: "Address thread in src/file.ts",
filePath: "src/file.ts",
line: 42,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
};
const task = await store.createTask({ description: "Archive review persistence" });
await store.updateTask(task.id, { review });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, false);
const archived = await store.getTask(task.id);
expect(archived.review).toEqual(review);
const restored = await store.unarchiveTask(task.id);
expect(restored.review).toEqual(review);
});
it("sets and clears mission linkage fields via updateTask", async () => {
const task = await createTestTask();

View File

@@ -201,6 +201,7 @@ CREATE TABLE IF NOT EXISTS tasks (
attachments TEXT DEFAULT '[]',
steeringComments TEXT DEFAULT '[]',
comments TEXT DEFAULT '[]',
review TEXT,
workflowStepResults TEXT DEFAULT '[]',
prInfo TEXT,
issueInfo TEXT,
@@ -1189,6 +1190,7 @@ export class Database {
if (this.hasTable("tasks")) {
this.addColumnIfMissing("tasks", "executionStartBranch", "TEXT");
this.addColumnIfMissing("tasks", "review", "TEXT");
}
if (version >= SCHEMA_VERSION) return;

View File

@@ -90,6 +90,7 @@ interface TaskRow {
attachments: string | null;
steeringComments: string | null;
comments: string | null;
review: string | null;
workflowStepResults: string | null;
prInfo: string | null;
issueInfo: string | null;
@@ -751,6 +752,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
});
return deduped.length > 0 ? deduped : undefined;
})(),
review: fromJson<import("./types.js").TaskReview>(row.review) ?? undefined,
workflowStepResults: (() => { const w = fromJson<import("./types.js").WorkflowStepResult[]>(row.workflowStepResults); return w && w.length > 0 ? w : undefined; })(),
prInfo: fromJson<import("./types.js").PrInfo>(row.prInfo),
issueInfo: fromJson<import("./types.js").IssueInfo>(row.issueInfo),
@@ -813,6 +815,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
sourceIssue: slim ? undefined : entry.sourceIssue,
attachments: slim ? undefined : entry.attachments,
comments: entry.comments,
review: slim ? undefined : entry.review,
log: slim ? [] : entry.log ?? [],
timedExecutionMs: slim ? this.computeTimedExecutionMs(entry.log) : undefined,
createdAt: entry.createdAt,
@@ -936,6 +939,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
sourceIssue: task.sourceIssue,
attachments: task.attachments,
comments: task.comments,
review: task.review,
prompt,
...agentLogFields,
log: [{ timestamp: archivedAt, action: "Task archived" }],
@@ -1014,7 +1018,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"error", "summary", "thinkingLevel", "executionMode",
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt",
"createdAt", "updatedAt", "columnMovedAt", "executionStartedAt", "executionCompletedAt",
"dependencies", "steps", "comments", "workflowStepResults", "steeringComments",
"dependencies", "steps", "comments", "review", "workflowStepResults", "steeringComments",
"attachments", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
@@ -1064,7 +1068,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt",
"createdAt", "updatedAt", "columnMovedAt", "executionStartedAt", "executionCompletedAt",
"dependencies", "steps", "attachments", "steeringComments",
"comments", "workflowStepResults", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"comments", "review", "workflowStepResults", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
"missionId", "sliceId", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
@@ -1107,11 +1111,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
tokenUsageTotalTokens, tokenUsageFirstUsedAt, tokenUsageLastUsedAt, createdAt, updatedAt, columnMovedAt,
executionStartedAt, executionCompletedAt,
dependencies, steps, log, attachments, steeringComments,
comments, workflowStepResults, prInfo, issueInfo,
comments, review, workflowStepResults, prInfo, issueInfo,
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
@@ -1166,6 +1170,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
attachments = excluded.attachments,
steeringComments = excluded.steeringComments,
comments = excluded.comments,
review = excluded.review,
workflowStepResults = excluded.workflowStepResults,
prInfo = excluded.prInfo,
issueInfo = excluded.issueInfo,
@@ -1249,6 +1254,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
toJson(task.attachments || []),
toJson(task.steeringComments || []),
toJson(task.comments || []),
toJsonNullable(task.review),
toJson(task.workflowStepResults || []),
toJsonNullable(task.prInfo),
toJsonNullable(task.issueInfo),
@@ -3170,7 +3176,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; assigneeUserId?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; assigneeUserId?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
runContext?: RunMutationContext,
): Promise<Task> {
return this.withTaskLock(id, async () => {
@@ -3440,6 +3446,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} else if (updates.executionCompletedAt !== undefined) {
task.executionCompletedAt = updates.executionCompletedAt;
}
if (updates.review === null) {
task.review = undefined;
} else if (updates.review !== undefined) {
task.review = updates.review;
}
if (updates.workflowStepResults === null) {
task.workflowStepResults = undefined;
} else if (updates.workflowStepResults !== undefined) {
@@ -6088,6 +6099,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
size: entry.size,
reviewLevel: entry.reviewLevel,
prInfo: entry.prInfo,
review: entry.review,
issueInfo: entry.issueInfo,
sourceIssue: entry.sourceIssue,
attachments: entry.attachments,

View File

@@ -699,6 +699,37 @@ export interface TaskCommentInput {
author: string;
}
export type TaskReviewMode = "pull-request" | "direct";
export type TaskReviewSource = "github-pr" | "reviewer-agent";
export type TaskReviewDecision = "approved" | "changes-requested" | "commented" | "pending";
export type TaskReviewItemStatus = "queued" | "in-progress" | "addressed" | "failed";
export interface TaskReviewItem {
id: string;
source: TaskReviewSource;
status: TaskReviewItemStatus;
summary: string;
body?: string;
filePath?: string;
line?: number;
commentUrl?: string;
reviewer?: string;
createdAt: string;
updatedAt: string;
addressedAt?: string;
failedReason?: string;
}
export interface TaskReview {
mode: TaskReviewMode;
source: TaskReviewSource;
decision: TaskReviewDecision;
summary?: string;
latestRefreshAt?: string;
selectedItemIds?: string[];
items: TaskReviewItem[];
}
export interface TaskDocument {
/** UUID primary key */
id: string;
@@ -918,6 +949,8 @@ export interface Task {
attachments?: TaskAttachment[];
steeringComments?: SteeringComment[];
comments?: TaskComment[];
/** Structured review metadata shown in the Review tab. */
review?: TaskReview;
/** PR information for tasks linked to GitHub pull requests */
prInfo?: PrInfo;
mergeDetails?: MergeDetails;
@@ -2339,6 +2372,8 @@ export interface ArchivedTaskEntry {
attachments?: TaskAttachment[];
/** User and agent comments remain searchable in the archive DB. */
comments?: TaskComment[];
/** Structured review metadata shown in the Review tab. */
review?: TaskReview;
/** Reconstructed prompt content at archive time, without attachment blobs. */
prompt?: string;
/** Agent log retention mode used when this archive entry was written. */