FN-8793: restore workflow review feedback selection
Restore canonical workflow review feedback so revisions can select server-owned reviewer items. - Normalize current Code Review and Plan Review workflow results into stable review items. - Preserve verdict and reviewer type through review API mappings and reject forged client feedback. - Add regression coverage and a patch changeset. Files changed: .changeset/fn-8793-workflow-review-items.md | 7 + packages/core/src/index.gate.ts | 2 +- packages/core/src/index.ts | 2 +- packages/core/src/types/task/task-review.ts | 6 +- packages/dashboard/app/api/agents/run-audit.ts | 2 + .../dashboard/src/__tests__/routes-tasks.test.ts | 121 +++++++++++++++++ .../src/routes/register-task-workflow-routes.ts | 148 ++++++++++++++++++--- 7 files changed, 264 insertions(+), 24 deletions(-) Fusion-Task-Id: FN-8793 Fusion-Task-Lineage: 25da04f9-5c30-4ad8-9f82-e1ac91ec481f Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8793-workflow-review-items.md
Normal file
7
.changeset/fn-8793-workflow-review-items.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Restore workflow review feedback selection for same-task revisions.
|
||||||
|
category: fix
|
||||||
|
dev: Canonical workflow-step review items now prevent forged client feedback from reaching snapshots or steering.
|
||||||
@@ -172,7 +172,7 @@ export {
|
|||||||
type ResolveAgentMemoryInclusionModeInput,
|
type ResolveAgentMemoryInclusionModeInput,
|
||||||
type ResolvedAgentMemoryInclusionMode,
|
type ResolvedAgentMemoryInclusionMode,
|
||||||
} from "./agents/agent-memory-mode.js";
|
} from "./agents/agent-memory-mode.js";
|
||||||
export type { TaskReviewData, TaskReviewSummary, TaskReviewItem } from "./types.js";
|
export type { TaskReviewData, TaskReviewSummary, TaskReviewItem, TaskReviewVerdict, TaskReviewerType } from "./types.js";
|
||||||
export type {
|
export type {
|
||||||
TaskCommitAssociation,
|
TaskCommitAssociation,
|
||||||
TaskCommitAssociationConfidence,
|
TaskCommitAssociationConfidence,
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ export {
|
|||||||
type ResolveAgentMemoryInclusionModeInput,
|
type ResolveAgentMemoryInclusionModeInput,
|
||||||
type ResolvedAgentMemoryInclusionMode,
|
type ResolvedAgentMemoryInclusionMode,
|
||||||
} from "./agents/agent-memory-mode.js";
|
} from "./agents/agent-memory-mode.js";
|
||||||
export type { TaskReviewData, TaskReviewSummary, TaskReviewItem } from "./types.js";
|
export type { TaskReviewData, TaskReviewSummary, TaskReviewItem, TaskReviewVerdict, TaskReviewerType } from "./types.js";
|
||||||
/* FNXC:TaskVerificationRequest 2026-07-30-00:00: FN-8296 makes the persisted verification read model available to dashboard task and Command Center surfaces without exporting a subprocess runner. */
|
/* FNXC:TaskVerificationRequest 2026-07-30-00:00: FN-8296 makes the persisted verification read model available to dashboard task and Command Center surfaces without exporting a subprocess runner. */
|
||||||
export type { TaskVerificationRequest, TaskVerificationResultSummary, TaskVerificationStatus, TaskVerificationProfile } from "./types.js";
|
export type { TaskVerificationRequest, TaskVerificationResultSummary, TaskVerificationStatus, TaskVerificationProfile } from "./types.js";
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
export type TaskReviewMode = "pull-request" | "direct";
|
export type TaskReviewMode = "pull-request" | "direct";
|
||||||
export type TaskReviewSource = "github-pr" | "reviewer-agent";
|
export type TaskReviewSource = "github-pr" | "reviewer-agent";
|
||||||
export type TaskReviewDecision = "approved" | "changes-requested" | "commented" | "pending";
|
export type TaskReviewDecision = "approved" | "changes-requested" | "commented" | "pending";
|
||||||
export type TaskReviewVerdict = "APPROVE" | "REVISE" | "RETHINK" | "UNAVAILABLE";
|
export type TaskReviewVerdict = "APPROVE" | "APPROVE_WITH_NOTES" | "REVISE" | "RETHINK" | "UNAVAILABLE";
|
||||||
export type TaskReviewerType = "plan" | "code";
|
export type TaskReviewerType = "plan" | "code";
|
||||||
export type TaskReviewItemStatus = "queued" | "in-progress" | "addressed" | "failed";
|
export type TaskReviewItemStatus = "queued" | "in-progress" | "addressed" | "failed";
|
||||||
|
|
||||||
@@ -163,6 +163,10 @@ export interface TaskReviewDataItem {
|
|||||||
line?: number;
|
line?: number;
|
||||||
threadId?: string;
|
threadId?: string;
|
||||||
reviewState?: string | null;
|
reviewState?: string | null;
|
||||||
|
/** Machine-readable reviewer verdict when the source supplied one. */
|
||||||
|
verdict?: TaskReviewVerdict;
|
||||||
|
/** Review lane that produced the item when known. */
|
||||||
|
reviewType?: TaskReviewerType;
|
||||||
isResolved?: boolean;
|
isResolved?: boolean;
|
||||||
progressStatus?: "queued" | "in-progress" | "addressed" | "failed" | null;
|
progressStatus?: "queued" | "in-progress" | "addressed" | "failed" | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,6 +257,8 @@ function mapTaskReviewDataToLegacy(data: TaskReviewData): TaskReviewResponse {
|
|||||||
threadId: item.threadId,
|
threadId: item.threadId,
|
||||||
htmlUrl: item.url,
|
htmlUrl: item.url,
|
||||||
state: item.reviewState ?? undefined,
|
state: item.reviewState ?? undefined,
|
||||||
|
verdict: item.verdict,
|
||||||
|
reviewType: item.reviewType,
|
||||||
summary: item.title ?? undefined,
|
summary: item.title ?? undefined,
|
||||||
isResolved: item.isResolved,
|
isResolved: item.isResolved,
|
||||||
...(typeof item.line === "number" ? { line: item.line } : {}),
|
...(typeof item.line === "number" ? { line: item.line } : {}),
|
||||||
|
|||||||
@@ -2749,6 +2749,65 @@ describe("POST /tasks/:id/review/address", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it("normalizes current workflow review results into stable canonical items", async () => {
|
||||||
|
const taskWithWorkflowReviews = {
|
||||||
|
...FAKE_TASK_DETAIL,
|
||||||
|
id: "FN-009",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
workflowStepResults: [
|
||||||
|
{ workflowStepId: "code-review", workflowStepName: "Code Review", phase: "pre-merge", status: "passed", verdict: "APPROVE_WITH_NOTES", output: "1. Keep the assertion focused.", completedAt: "2026-01-02T00:00:00.000Z" },
|
||||||
|
{ workflowStepId: "plan-review", workflowStepName: "Plan Review", phase: "pre-merge", status: "failed", verdict: "REVISE", notes: "Clarify the rollback plan.", startedAt: "2026-01-03T00:00:00.000Z" },
|
||||||
|
{ workflowStepId: "code-review", workflowStepName: "Code Review", phase: "pre-merge", status: "advisory_failure", verdict: "APPROVE", completedAt: "2026-01-04T00:00:00.000Z" },
|
||||||
|
{ workflowStepId: "custom-review", workflowStepName: "Custom Review", status: "passed", verdict: "REVISE", output: "Must not be inferred." },
|
||||||
|
{ workflowStepId: "code-review", workflowStepName: "Code Review", status: "pending", verdict: "REVISE" },
|
||||||
|
{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "skipped", verdict: "REVISE" },
|
||||||
|
{ workflowStepId: "code-review", workflowStepName: "Code Review", status: "passed", verdict: "REVISE", supersededAt: "2026-01-05T00:00:00.000Z" },
|
||||||
|
{ workflowStepId: "plan-review", workflowStepName: "Plan Review", status: "failed", verdict: "REVISE", bypassedAt: "2026-01-05T00:00:00.000Z" },
|
||||||
|
],
|
||||||
|
log: [{ timestamp: reviewerBlockTimestamp, action: "code review Step 1: REVISE - legacy fallback must not duplicate structured data" }],
|
||||||
|
};
|
||||||
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(taskWithWorkflowReviews);
|
||||||
|
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([{ agent: "reviewer", type: "text", text: "## Code Review:\\n### Verdict: REVISE" }]);
|
||||||
|
|
||||||
|
const first = await REQUEST(buildApp(), "GET", "/api/tasks/FN-009/review");
|
||||||
|
const refreshed = await REQUEST(buildApp(), "POST", "/api/tasks/FN-009/review/refresh");
|
||||||
|
|
||||||
|
expect(first.status).toBe(200);
|
||||||
|
expect(refreshed.status).toBe(200);
|
||||||
|
expect(first.body.items).toHaveLength(3);
|
||||||
|
expect(first.body.items).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ title: "Code Review APPROVE_WITH_NOTES", body: "1. Keep the assertion focused.", verdict: "APPROVE_WITH_NOTES", reviewType: "code" }),
|
||||||
|
expect.objectContaining({ title: "Plan Review REVISE", body: "Clarify the rollback plan.", verdict: "REVISE", reviewType: "plan" }),
|
||||||
|
expect.objectContaining({ verdict: "APPROVE", body: "No written feedback was provided by this review step." }),
|
||||||
|
]));
|
||||||
|
expect(first.body.summary).toEqual(expect.objectContaining({ verdict: "APPROVE" }));
|
||||||
|
expect(first.body.items.map((item: { itemId: string }) => item.itemId)).toEqual(refreshed.body.items.map((item: { itemId: string }) => item.itemId));
|
||||||
|
expect(store.getAgentLogs).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses legacy activity review feedback when workflow results are absent or unsupported", async () => {
|
||||||
|
const taskWithUnsupportedWorkflowReview = {
|
||||||
|
...FAKE_TASK_DETAIL,
|
||||||
|
id: "FN-010",
|
||||||
|
workflowStepResults: [{ workflowStepId: "custom-review", workflowStepName: "Custom Review", status: "passed", verdict: "REVISE", output: "not a current built-in review node" }],
|
||||||
|
log: [{ timestamp: fallbackTimestamp, action: "plan review Step 2: RETHINK - revise the approach" }],
|
||||||
|
};
|
||||||
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(taskWithUnsupportedWorkflowReview);
|
||||||
|
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-010/review");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.items).toEqual([expect.objectContaining({
|
||||||
|
itemId: fallbackItemId,
|
||||||
|
title: "plan review RETHINK",
|
||||||
|
body: "plan review Step 2: RETHINK - revise the approach",
|
||||||
|
verdict: "RETHINK",
|
||||||
|
reviewType: "plan",
|
||||||
|
})]);
|
||||||
|
expect(store.getAgentLogs).toHaveBeenCalledWith("FN-010");
|
||||||
|
});
|
||||||
|
|
||||||
it("resumes reviewer-agent in-review tasks using canonical log-derived review ids when reviewState is absent", async () => {
|
it("resumes reviewer-agent in-review tasks using canonical log-derived review ids when reviewState is absent", async () => {
|
||||||
const taskWithoutPersistedItems = {
|
const taskWithoutPersistedItems = {
|
||||||
...FAKE_TASK_DETAIL,
|
...FAKE_TASK_DETAIL,
|
||||||
@@ -2785,6 +2844,68 @@ describe("POST /tasks/:id/review/address", () => {
|
|||||||
expect(store.updateStep).toHaveBeenCalledWith("FN-001", 0, "pending");
|
expect(store.updateStep).toHaveBeenCalledWith("FN-001", 0, "pending");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("addresses workflow review items by canonical id without trusting forged client feedback", async () => {
|
||||||
|
const taskWithWorkflowReview = {
|
||||||
|
...FAKE_TASK_DETAIL,
|
||||||
|
id: "FN-009",
|
||||||
|
column: "in-review",
|
||||||
|
status: "awaiting-user-review",
|
||||||
|
assignedAgentId: null,
|
||||||
|
sessionFile: null,
|
||||||
|
workflowStepResults: [{
|
||||||
|
workflowStepId: "code-review",
|
||||||
|
workflowStepName: "Code Review",
|
||||||
|
phase: "pre-merge",
|
||||||
|
status: "passed",
|
||||||
|
verdict: "APPROVE_WITH_NOTES",
|
||||||
|
output: "Canonical advisory: preserve this text.",
|
||||||
|
completedAt: "2026-01-05T00:00:00.000Z",
|
||||||
|
}],
|
||||||
|
reviewState: undefined,
|
||||||
|
};
|
||||||
|
const movedTask = { ...taskWithWorkflowReview, column: "in-progress", status: null };
|
||||||
|
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(taskWithWorkflowReview);
|
||||||
|
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "sc-1" });
|
||||||
|
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||||||
|
|
||||||
|
const review = await REQUEST(buildApp(), "GET", "/api/tasks/FN-009/review");
|
||||||
|
const itemId = review.body.items[0].itemId;
|
||||||
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/FN-009/review/address", JSON.stringify({
|
||||||
|
selectedItems: [{
|
||||||
|
id: itemId,
|
||||||
|
source: "reviewer-agent",
|
||||||
|
summary: "FORGED summary",
|
||||||
|
body: "FORGED body",
|
||||||
|
author: "attacker",
|
||||||
|
filePath: "forged.ts",
|
||||||
|
lineNumber: 999,
|
||||||
|
url: "https://invalid.example/forged",
|
||||||
|
}],
|
||||||
|
}), { "Content-Type": "application/json" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith("FN-009", {
|
||||||
|
reviewState: expect.objectContaining({
|
||||||
|
addressing: [expect.objectContaining({
|
||||||
|
itemId,
|
||||||
|
snapshot: expect.objectContaining({
|
||||||
|
summary: "Code Review APPROVE_WITH_NOTES",
|
||||||
|
body: "Canonical advisory: preserve this text.",
|
||||||
|
authorLogin: "reviewer-agent",
|
||||||
|
filePath: undefined,
|
||||||
|
lineNumber: undefined,
|
||||||
|
url: undefined,
|
||||||
|
}),
|
||||||
|
})],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const steering = (store.addSteeringComment as ReturnType<typeof vi.fn>).mock.calls[0][1] as string;
|
||||||
|
expect(steering).toContain("Canonical advisory: preserve this text.");
|
||||||
|
expect(steering).not.toContain("FORGED");
|
||||||
|
expect(steering).not.toContain("invalid.example");
|
||||||
|
expect(store.moveTask).toHaveBeenCalledWith("FN-009", "in-progress", { preserveProgress: true });
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts reviewer-agent fallback log review ids when no reviewer text block exists", async () => {
|
it("accepts reviewer-agent fallback log review ids when no reviewer text block exists", async () => {
|
||||||
const taskWithFallbackLog = {
|
const taskWithFallbackLog = {
|
||||||
...FAKE_TASK_DETAIL,
|
...FAKE_TASK_DETAIL,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const severityAuditLog = createLogger("dashboard-register-task-workflow-routes")
|
|||||||
* than turning one board load into thousands of file reads. Truncation is logged, never silent.
|
* than turning one board load into thousands of file reads. Truncation is logged, never silent.
|
||||||
*/
|
*/
|
||||||
const AWAITING_PLANNING_ENRICH_LIMIT = 200;
|
const AWAITING_PLANNING_ENRICH_LIMIT = 200;
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { createReadStream } from "node:fs";
|
import { createReadStream } from "node:fs";
|
||||||
import { readFile, stat } from "node:fs/promises";
|
import { readFile, stat } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
@@ -21,6 +22,8 @@ import type {
|
|||||||
TaskReviewData,
|
TaskReviewData,
|
||||||
TaskReviewItem,
|
TaskReviewItem,
|
||||||
TaskReviewSummary,
|
TaskReviewSummary,
|
||||||
|
TaskReviewVerdict,
|
||||||
|
WorkflowStepResult,
|
||||||
GithubIssueAction,
|
GithubIssueAction,
|
||||||
DuplicateCandidate,
|
DuplicateCandidate,
|
||||||
DuplicateMatch,
|
DuplicateMatch,
|
||||||
@@ -810,7 +813,101 @@ function buildReviewerAgentItemId(input: { index: number; reviewType: "plan" | "
|
|||||||
return `reviewer-${input.reviewType}-${stepPart}-${verdictPart}-${timePart}-${input.index + 1}`;
|
return `reviewer-${input.reviewType}-${stepPart}-${verdictPart}-${timePart}-${input.index + 1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CURRENT_WORKFLOW_REVIEW_STEP_IDS = new Set(["code-review", "plan-review"]);
|
||||||
|
const CURRENT_WORKFLOW_REVIEW_STATUSES = new Set<WorkflowStepResult["status"]>(["passed", "failed", "advisory_failure"]);
|
||||||
|
function parseTaskReviewVerdict(value: string | undefined): TaskReviewVerdict | undefined {
|
||||||
|
switch (value) {
|
||||||
|
case "APPROVE":
|
||||||
|
case "APPROVE_WITH_NOTES":
|
||||||
|
case "REVISE":
|
||||||
|
case "RETHINK":
|
||||||
|
case "UNAVAILABLE":
|
||||||
|
return value;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:TaskReview 2026-08-05-01:57:
|
||||||
|
* The current graph-owned Code Review and Plan Review ids are the only structured review sources:
|
||||||
|
* current, terminal, verdict-bearing results win over compatibility-only reviewer prose/activity
|
||||||
|
* parsing. Their persisted identity produces canonical ids so GET, refresh, and address reconstruct
|
||||||
|
* the same server-owned feedback; address snapshots and steering must never trust client prose.
|
||||||
|
* Custom step names, historical attempts, pending/skipped, superseded, and bypassed results remain
|
||||||
|
* non-selectable until workflow results gain an authoritative persisted review-kind marker.
|
||||||
|
*/
|
||||||
|
function isCurrentWorkflowReviewResult(result: WorkflowStepResult): result is WorkflowStepResult & { verdict: TaskReviewVerdict } {
|
||||||
|
return CURRENT_WORKFLOW_REVIEW_STEP_IDS.has(result.workflowStepId)
|
||||||
|
&& CURRENT_WORKFLOW_REVIEW_STATUSES.has(result.status)
|
||||||
|
&& result.verdict !== undefined
|
||||||
|
&& result.supersededAt === undefined
|
||||||
|
&& result.bypassedBy === undefined
|
||||||
|
&& result.bypassedAt === undefined
|
||||||
|
&& result.bypassReason === undefined
|
||||||
|
&& result.bypassedFromStatus === undefined
|
||||||
|
&& result.bypassedFromVerdict === undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWorkflowReviewItemId(task: Task, result: WorkflowStepResult): string {
|
||||||
|
const identity = JSON.stringify({
|
||||||
|
taskId: task.id,
|
||||||
|
workflowStepId: result.workflowStepId,
|
||||||
|
workflowStepName: result.workflowStepName,
|
||||||
|
phase: result.phase,
|
||||||
|
status: result.status,
|
||||||
|
verdict: result.verdict,
|
||||||
|
completedAt: result.completedAt,
|
||||||
|
startedAt: result.startedAt,
|
||||||
|
output: result.output,
|
||||||
|
notes: result.notes,
|
||||||
|
});
|
||||||
|
return `workflow-review-${createHash("sha256").update(identity).digest("hex").slice(0, 24)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWorkflowReviewItems(task: Task): TaskReviewItem[] {
|
||||||
|
return (task.workflowStepResults ?? [])
|
||||||
|
.filter(isCurrentWorkflowReviewResult)
|
||||||
|
.map((result): TaskReviewItem => {
|
||||||
|
const reviewType = result.workflowStepId === "plan-review" ? "plan" as const : "code" as const;
|
||||||
|
const body = result.output?.trim() || result.notes?.trim() || "No written feedback was provided by this review step.";
|
||||||
|
const timestamp = result.completedAt ?? result.startedAt ?? task.updatedAt ?? task.createdAt;
|
||||||
|
return {
|
||||||
|
itemId: buildWorkflowReviewItemId(task, result),
|
||||||
|
sourceMode: "reviewer-agent",
|
||||||
|
title: `${result.workflowStepName || result.workflowStepId} ${result.verdict}`,
|
||||||
|
body,
|
||||||
|
author: "reviewer-agent",
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
reviewState: result.verdict,
|
||||||
|
verdict: result.verdict,
|
||||||
|
reviewType,
|
||||||
|
progressStatus: null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => Date.parse(b.createdAt ?? "") - Date.parse(a.createdAt ?? "") || a.itemId.localeCompare(b.itemId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDirectReviewSummary(items: TaskReviewItem[]): TaskReviewSummary | null {
|
||||||
|
const latest = items[0];
|
||||||
|
return latest
|
||||||
|
? { summary: latest.title, verdict: latest.verdict }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
async function buildDirectTaskReviewData(task: Task, store: TaskStore): Promise<TaskReviewData> {
|
async function buildDirectTaskReviewData(task: Task, store: TaskStore): Promise<TaskReviewData> {
|
||||||
|
const structuredItems = buildWorkflowReviewItems(task);
|
||||||
|
if (structuredItems.length > 0) {
|
||||||
|
return {
|
||||||
|
mode: "reviewer-agent",
|
||||||
|
refreshable: true,
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
summary: buildDirectReviewSummary(structuredItems),
|
||||||
|
items: structuredItems,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const agentLogs = await store.getAgentLogs(task.id);
|
const agentLogs = await store.getAgentLogs(task.id);
|
||||||
const reviewerText = agentLogs.filter((entry) => entry.agent === "reviewer" && entry.type === "text").map((entry) => entry.text).join("\n");
|
const reviewerText = agentLogs.filter((entry) => entry.agent === "reviewer" && entry.type === "text").map((entry) => entry.text).join("\n");
|
||||||
const fallbackLogs = (task.log ?? []).filter((entry) => REVIEW_STEP_RE.test(entry.action));
|
const fallbackLogs = (task.log ?? []).filter((entry) => REVIEW_STEP_RE.test(entry.action));
|
||||||
@@ -833,6 +930,8 @@ async function buildDirectTaskReviewData(task: Task, store: TaskStore): Promise<
|
|||||||
createdAt,
|
createdAt,
|
||||||
updatedAt: createdAt,
|
updatedAt: createdAt,
|
||||||
reviewState: verdict ?? null,
|
reviewState: verdict ?? null,
|
||||||
|
verdict: parseTaskReviewVerdict(verdict),
|
||||||
|
reviewType,
|
||||||
progressStatus: null,
|
progressStatus: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -851,25 +950,20 @@ async function buildDirectTaskReviewData(task: Task, store: TaskStore): Promise<
|
|||||||
createdAt: entry.timestamp,
|
createdAt: entry.timestamp,
|
||||||
updatedAt: entry.timestamp,
|
updatedAt: entry.timestamp,
|
||||||
reviewState: verdict ?? null,
|
reviewState: verdict ?? null,
|
||||||
|
verdict: parseTaskReviewVerdict(verdict),
|
||||||
|
reviewType,
|
||||||
progressStatus: null,
|
progressStatus: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = [...items].sort((a, b) => Date.parse(b.createdAt ?? "") - Date.parse(a.createdAt ?? ""));
|
const sorted = [...items].sort((a, b) => Date.parse(b.createdAt ?? "") - Date.parse(a.createdAt ?? "") || a.itemId.localeCompare(b.itemId));
|
||||||
const latest = sorted[0];
|
|
||||||
const summary: TaskReviewSummary | null = latest
|
|
||||||
? {
|
|
||||||
summary: latest.title,
|
|
||||||
verdict: (latest.reviewState as "APPROVE" | "REVISE" | "RETHINK" | "UNAVAILABLE" | null | undefined) ?? undefined,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode: "reviewer-agent",
|
mode: "reviewer-agent",
|
||||||
refreshable: true,
|
refreshable: true,
|
||||||
fetchedAt: new Date().toISOString(),
|
fetchedAt: new Date().toISOString(),
|
||||||
summary,
|
summary: buildDirectReviewSummary(sorted),
|
||||||
items: sorted,
|
items: sorted,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -6046,20 +6140,13 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
type SelectedReviewItem = {
|
type SelectedReviewItem = {
|
||||||
id: string;
|
id: string;
|
||||||
source: "pr-review" | "reviewer-agent";
|
source: "pr-review" | "reviewer-agent";
|
||||||
threadId?: string;
|
|
||||||
filePath?: string;
|
|
||||||
lineNumber?: number;
|
|
||||||
author?: string;
|
|
||||||
summary: string;
|
|
||||||
body: string;
|
|
||||||
url?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedItems: SelectedReviewItem[] = Array.isArray(req.body?.selectedItems)
|
const selectedItems: SelectedReviewItem[] = Array.isArray(req.body?.selectedItems)
|
||||||
? req.body.selectedItems.filter((value: unknown): value is SelectedReviewItem => {
|
? req.body.selectedItems.filter((value: unknown): value is SelectedReviewItem => {
|
||||||
if (!value || typeof value !== "object") return false;
|
if (!value || typeof value !== "object") return false;
|
||||||
const item = value as Record<string, unknown>;
|
const item = value as Record<string, unknown>;
|
||||||
return typeof item.id === "string" && item.id.trim().length > 0 && typeof item.summary === "string" && typeof item.body === "string";
|
return typeof item.id === "string" && item.id.trim().length > 0 && typeof item.source === "string";
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
@@ -6098,6 +6185,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
state: item.reviewState ?? undefined,
|
state: item.reviewState ?? undefined,
|
||||||
isResolved: item.isResolved,
|
isResolved: item.isResolved,
|
||||||
source: item.sourceMode === "reviewer-agent" ? "reviewer-agent" as const : "github-pr" as const,
|
source: item.sourceMode === "reviewer-agent" ? "reviewer-agent" as const : "github-pr" as const,
|
||||||
|
verdict: item.verdict,
|
||||||
|
reviewType: item.reviewType,
|
||||||
}));
|
}));
|
||||||
const reviewState = {
|
const reviewState = {
|
||||||
source: canonicalReviewData.mode,
|
source: canonicalReviewData.mode,
|
||||||
@@ -6117,7 +6206,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const selectedSet = new Set(selectedItems.map((item: SelectedReviewItem) => item.id));
|
const selectedSet = new Set(selectedItems.map((item: SelectedReviewItem) => item.id));
|
||||||
const canonicalIds = new Set(reviewState.items.map((item) => item.id));
|
const canonicalIds = new Set(reviewState.items.map((item) => item.id));
|
||||||
const expectedSource = canonicalReviewData.mode === "pull-request" ? "pr-review" : "reviewer-agent";
|
const expectedSource: "pr-review" | "reviewer-agent" = canonicalReviewData.mode === "pull-request" ? "pr-review" : "reviewer-agent";
|
||||||
const reviewSourceMismatch = selectedItems.find((item) => canonicalIds.has(item.id) && item.source !== expectedSource);
|
const reviewSourceMismatch = selectedItems.find((item) => canonicalIds.has(item.id) && item.source !== expectedSource);
|
||||||
if (reviewSourceMismatch) {
|
if (reviewSourceMismatch) {
|
||||||
throw badRequest("Selected review source does not match task review mode");
|
throw badRequest("Selected review source does not match task review mode");
|
||||||
@@ -6127,8 +6216,25 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
throw badRequest("selectedItems must reference existing review items");
|
throw badRequest("selectedItems must reference existing review items");
|
||||||
}
|
}
|
||||||
|
|
||||||
const modeSummary = `${reviewState.source === "pull-request" ? "pull-request" : "reviewer-agent"} · ${selectedItems.length} selected item(s)`;
|
const canonicalById = new Map(reviewState.items.map((item) => [item.id, item] as const));
|
||||||
const steeringItems = selectedItems.map((item: SelectedReviewItem, index: number) => {
|
const canonicalSelections = selectedItems.map((selected) => {
|
||||||
|
const item = canonicalById.get(selected.id);
|
||||||
|
if (!item) throw badRequest("selectedItems must reference existing review items");
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
source: expectedSource,
|
||||||
|
summary: item.summary ?? item.body.slice(0, 120),
|
||||||
|
body: item.body,
|
||||||
|
author: item.author.login,
|
||||||
|
filePath: item.path,
|
||||||
|
lineNumber: item.line,
|
||||||
|
threadId: item.threadId,
|
||||||
|
url: item.htmlUrl,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const modeSummary = `${reviewState.source === "pull-request" ? "pull-request" : "reviewer-agent"} · ${canonicalSelections.length} selected item(s)`;
|
||||||
|
const steeringItems = canonicalSelections.map((item, index) => {
|
||||||
const location = item.filePath ? `${item.filePath}${typeof item.lineNumber === "number" ? `:${item.lineNumber}` : ""}` : undefined;
|
const location = item.filePath ? `${item.filePath}${typeof item.lineNumber === "number" ? `:${item.lineNumber}` : ""}` : undefined;
|
||||||
const snippetSource = item.body.trim() || item.summary.trim();
|
const snippetSource = item.body.trim() || item.summary.trim();
|
||||||
const snippet = snippetSource.length > 220 ? `${snippetSource.slice(0, 220)}…` : snippetSource;
|
const snippet = snippetSource.length > 220 ? `${snippetSource.slice(0, 220)}…` : snippetSource;
|
||||||
@@ -6140,7 +6246,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
const priorAddressingById = new Map(reviewState.addressing.map((record) => [record.itemId, record] as const));
|
const priorAddressingById = new Map(reviewState.addressing.map((record) => [record.itemId, record] as const));
|
||||||
const nextAddressing = [
|
const nextAddressing = [
|
||||||
...reviewState.addressing.filter((record) => !selectedSet.has(record.itemId)),
|
...reviewState.addressing.filter((record) => !selectedSet.has(record.itemId)),
|
||||||
...selectedItems.map((item: SelectedReviewItem) => {
|
...canonicalSelections.map((item) => {
|
||||||
const existing = priorAddressingById.get(item.id);
|
const existing = priorAddressingById.get(item.id);
|
||||||
return {
|
return {
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user