Add durable, scoped resolution states for workflow review findings. - Persist reviewer-applied and superseded finding receipts without making them actionable. - Scope supersession claims to a named prior workflow result, preserving duplicate IDs in other review lanes. - Render informational resolution badges and reject resolved items from revision requests. Files changed: .changeset/fn-8956-review-finding-resolution.md | 7 + docs/dashboard-guide.md | 2 +- docs/workflow-steps.md | 8 +- .../src/__tests__/review-severity-gate.test.ts | 25 ++++ .../src/__tests__/workflow-step-results.test.ts | 45 +++++- packages/core/src/index.gate.ts | 6 + packages/core/src/index.ts | 6 + packages/core/src/types.ts | 2 + packages/core/src/types/task/task-review.ts | 4 + packages/core/src/types/workflow/workflow-steps.ts | 15 +- .../src/workflows/builtin-code-review-group.ts | 2 +- .../src/workflows/builtin-plan-review-group.ts | 2 +- .../core/src/workflows/review-severity-gate.ts | 37 ++++- .../core/src/workflows/workflow-step-results.ts | 54 ++++++- packages/dashboard/app/api/agents/run-audit.ts | 1 + .../dashboard/app/components/TaskReviewTab.css | 22 +++ .../dashboard/app/components/TaskReviewTab.tsx | 36 +++-- .../components/__tests__/TaskReviewTab.test.tsx | 46 ++++++ .../dashboard/src/__tests__/routes-tasks.test.ts | 48 ++++++ .../src/routes/register-task-workflow-routes.ts | 14 +- .../__tests__/review-finding-supersession.test.ts | 163 +++++++++++++++++++++ .../__tests__/review-findings-injection.test.ts | 22 +++ .../workflow-step-verdict-parsing.test.ts | 16 +- .../engine/src/executor/execute-workflow-graph.ts | 129 ++++++++-------- .../engine/src/executor/execute-workflow-step.ts | 25 +++- .../engine/src/executor/run-graph-custom-node.ts | 7 + .../executor/workflow-step-failure-injection.ts | 8 +- .../engine/src/executor/workflow-step-verdict.ts | 17 ++- .../src/workflows/workflow-graph-executor.ts | 15 ++ packages/i18n/locales/en/app.json | 4 +- packages/i18n/locales/es/app.json | 4 +- packages/i18n/locales/fr/app.json | 4 +- packages/i18n/locales/ko/app.json | 4 +- packages/i18n/locales/pt-BR/app.json | 4 +- packages/i18n/locales/zh-CN/app.json | 4 +- packages/i18n/locales/zh-TW/app.json | 4 +- 36 files changed, 703 insertions(+), 109 deletions(-) Fusion-Task-Id: FN-8956 Fusion-Task-Lineage: 80568280-85aa-4a49-a60a-99b75f88f486 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
360 lines
13 KiB
TypeScript
360 lines
13 KiB
TypeScript
/**
|
|
* FNXC:CodeOrganization 2026-07-20-10:00:
|
|
* Run-audit, timeline, org tree, and task review client API peeled from legacy.ts.
|
|
*/
|
|
|
|
import type {
|
|
Agent,
|
|
AgentStats,
|
|
OrgTreeNode,
|
|
AgentLogEntry,
|
|
Task,
|
|
TaskReviewData,
|
|
} from "@fusion/core";
|
|
import { api, ApiRequestError } from "../client/client.js";
|
|
import type { FetchOptions } from "../client/client.js";
|
|
import { withProjectId } from "../client/health.js";
|
|
import { dedupe } from "../client/dedupe.js";
|
|
import type {
|
|
TaskReviewResponse,
|
|
RefreshTaskReviewResponse,
|
|
SelectedReviewItem,
|
|
ReviseTaskReviewResponse,
|
|
AddressPrFeedbackResponse,
|
|
} from "../tasks/tasks.js";
|
|
|
|
// ── Run-Audit & Timeline API ────────────────────────────────────────────────
|
|
|
|
/** Valid domain filters for run-audit queries. */
|
|
export type RunAuditDomainFilter = "database" | "git" | "filesystem" | "sandbox";
|
|
|
|
/** Filter options for run-audit queries. */
|
|
export interface RunAuditFilters {
|
|
/** Filter by task ID */
|
|
taskId?: string;
|
|
/** Filter by domain category */
|
|
domain?: RunAuditDomainFilter;
|
|
/** Start of time range (inclusive, ISO-8601) */
|
|
startTime?: string;
|
|
/** End of time range (inclusive, ISO-8601) */
|
|
endTime?: string;
|
|
/** Maximum number of events to return */
|
|
limit?: number;
|
|
}
|
|
|
|
/** Normalized run-audit event for UI consumption. */
|
|
export interface NormalizedRunAuditEvent {
|
|
id: string;
|
|
timestamp: string;
|
|
taskId?: string;
|
|
domain: "database" | "git" | "filesystem" | "sandbox";
|
|
mutationType: string;
|
|
target: string;
|
|
summary: string;
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
|
|
/** Response shape for run-audit endpoint. */
|
|
export interface RunAuditResponse {
|
|
runId: string;
|
|
events: NormalizedRunAuditEvent[];
|
|
filters: {
|
|
taskId?: string;
|
|
domain?: RunAuditDomainFilter;
|
|
startTime?: string;
|
|
endTime?: string;
|
|
};
|
|
totalCount: number;
|
|
hasMore: boolean;
|
|
}
|
|
|
|
/** Unified timeline entry that can represent either an audit event or an agent log entry. */
|
|
export interface TimelineEntry {
|
|
timestamp: string;
|
|
type: "audit" | "log";
|
|
sortKey: string;
|
|
audit?: NormalizedRunAuditEvent;
|
|
log?: AgentLogEntry;
|
|
}
|
|
|
|
/** Response shape for run-timeline endpoint. */
|
|
export interface RunTimelineResponse {
|
|
run: {
|
|
id: string;
|
|
agentId: string;
|
|
startedAt: string;
|
|
endedAt?: string;
|
|
status: string;
|
|
taskId?: string;
|
|
};
|
|
auditByDomain: {
|
|
database: NormalizedRunAuditEvent[];
|
|
git: NormalizedRunAuditEvent[];
|
|
filesystem: NormalizedRunAuditEvent[];
|
|
sandbox: NormalizedRunAuditEvent[];
|
|
};
|
|
counts: {
|
|
auditEvents: number;
|
|
logEntries: number;
|
|
};
|
|
timeline: TimelineEntry[];
|
|
}
|
|
|
|
/**
|
|
* Fetch normalized run-audit events for a specific agent run.
|
|
*
|
|
* @param agentId - The agent ID
|
|
* @param runId - The run ID
|
|
* @param filters - Optional filter parameters
|
|
* @param projectId - Optional project ID for multi-project workspaces
|
|
* @returns Promise resolving to RunAuditResponse with normalized events
|
|
* @throws Error if runId is blank or whitespace-only
|
|
*/
|
|
export function fetchAgentRunAudit(
|
|
agentId: string,
|
|
runId: string,
|
|
filters?: RunAuditFilters,
|
|
projectId?: string,
|
|
): Promise<RunAuditResponse> {
|
|
// Validate runId before making API call
|
|
if (!runId || runId.trim().length === 0) {
|
|
throw new Error("runId is required");
|
|
}
|
|
|
|
const params = new URLSearchParams();
|
|
if (filters?.taskId) params.set("taskId", filters.taskId);
|
|
if (filters?.domain) params.set("domain", filters.domain);
|
|
if (filters?.startTime) params.set("startTime", filters.startTime);
|
|
if (filters?.endTime) params.set("endTime", filters.endTime);
|
|
if (filters?.limit !== undefined) params.set("limit", String(filters.limit));
|
|
const query = params.size > 0 ? `?${params.toString()}` : "";
|
|
return api<RunAuditResponse>(
|
|
withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}/audit${query}`, projectId),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fetch a correlated timeline combining run-audit events and agent logs for a specific run.
|
|
*
|
|
* @param agentId - The agent ID
|
|
* @param runId - The run ID
|
|
* @param options - Optional parameters
|
|
* @param options.taskId - Override task ID for audit filtering (defaults to run's contextSnapshot.taskId)
|
|
* @param options.domain - Filter audit events by domain
|
|
* @param options.startTime - Start of time range (ISO-8601)
|
|
* @param options.endTime - End of time range (ISO-8601)
|
|
* @param options.includeLogs - Whether to include agent logs (default true)
|
|
* @param options.limit - Maximum audit events to return
|
|
* @param projectId - Optional project ID for multi-project workspaces
|
|
* @returns Promise resolving to RunTimelineResponse with merged timeline
|
|
* @throws Error if runId is blank or whitespace-only
|
|
*/
|
|
export function fetchAgentRunTimeline(
|
|
agentId: string,
|
|
runId: string,
|
|
options?: {
|
|
taskId?: string;
|
|
domain?: RunAuditDomainFilter;
|
|
startTime?: string;
|
|
endTime?: string;
|
|
includeLogs?: boolean;
|
|
limit?: number;
|
|
},
|
|
projectId?: string,
|
|
): Promise<RunTimelineResponse> {
|
|
// Validate runId before making API call
|
|
if (!runId || runId.trim().length === 0) {
|
|
throw new Error("runId is required");
|
|
}
|
|
|
|
const params = new URLSearchParams();
|
|
if (options?.taskId) params.set("taskId", options.taskId);
|
|
if (options?.domain) params.set("domain", options.domain);
|
|
if (options?.startTime) params.set("startTime", options.startTime);
|
|
if (options?.endTime) params.set("endTime", options.endTime);
|
|
if (options?.includeLogs !== undefined) params.set("includeLogs", String(options.includeLogs));
|
|
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
|
const query = params.size > 0 ? `?${params.toString()}` : "";
|
|
return api<RunTimelineResponse>(
|
|
withProjectId(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}/timeline${query}`, projectId),
|
|
);
|
|
}
|
|
|
|
/** Fetch aggregate agent stats */
|
|
export function fetchAgentStats(projectId?: string, options?: FetchOptions): Promise<AgentStats> {
|
|
const path = withProjectId("/agents/stats", projectId);
|
|
return dedupe(path, () => api<AgentStats>(path), options);
|
|
}
|
|
|
|
/** Fetch the chain of command for an agent (self → manager → grand-manager → ...) */
|
|
export function fetchChainOfCommand(agentId: string, projectId?: string): Promise<Agent[]> {
|
|
return api<Agent[]>(withProjectId(`/agents/${encodeURIComponent(agentId)}/chain-of-command`, projectId));
|
|
}
|
|
|
|
/** Fetch the full org tree as nested nodes */
|
|
export function fetchOrgTree(projectId?: string, options?: { includeEphemeral?: boolean }): Promise<OrgTreeNode[]> {
|
|
const params = new URLSearchParams();
|
|
if (projectId) params.set("projectId", projectId);
|
|
if (options?.includeEphemeral) params.set("includeEphemeral", "true");
|
|
const query = params.toString();
|
|
return api<OrgTreeNode[]>(`/agents/org-tree${query ? `?${query}` : ""}`);
|
|
}
|
|
|
|
/** Resolve an agent by shortname or ID */
|
|
export function resolveAgent(shortname: string, projectId?: string): Promise<{ agent: Agent }> {
|
|
return api<{ agent: Agent }>(withProjectId(`/agents/resolve/${encodeURIComponent(shortname)}`, projectId));
|
|
}
|
|
|
|
/** Fetch employees (agents that report to a given parent agent) */
|
|
export function fetchAgentChildren(agentId: string, projectId?: string): Promise<Agent[]> {
|
|
return api<Agent[]>(withProjectId(`/agents/${encodeURIComponent(agentId)}/children`, projectId)).catch((err: unknown) => {
|
|
/*
|
|
* FNXC:CodeOrganization 2026-07-20-12:00:
|
|
* Prefer HTTP status for 404 (agent may have been deleted); keep a
|
|
* case-insensitive message fallback for non-ApiRequestError throw sites.
|
|
*/
|
|
if (err instanceof ApiRequestError && err.status === 404) return [];
|
|
if (err instanceof Error && err.message.toLowerCase().includes("not found")) return [];
|
|
throw err;
|
|
});
|
|
}
|
|
|
|
/** Alias for fetchAgentChildren with employee-focused naming */
|
|
export const fetchAgentEmployees = fetchAgentChildren;
|
|
|
|
/** Assign or unassign a task to an explicit agent */
|
|
export function assignTask(taskId: string, agentId: string | null, projectId?: string): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/assign`, projectId), {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ agentId }),
|
|
});
|
|
}
|
|
|
|
/** Assign or unassign a task to a user (for review handoff) */
|
|
export function assignTaskToUser(taskId: string, userId: string | null, projectId?: string): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/assign-user`, projectId), {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ userId }),
|
|
});
|
|
}
|
|
|
|
/** Accept review - clear assignee and awaiting-user-review status, keep in in-review */
|
|
export function acceptTaskReview(taskId: string, projectId?: string): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/accept-review`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
function mapTaskReviewDataToLegacy(data: TaskReviewData): TaskReviewResponse {
|
|
const fetchedAt = data.fetchedAt ?? undefined;
|
|
const canonicalItems = data.items.map((item) => ({
|
|
id: item.itemId,
|
|
body: item.body,
|
|
author: { login: item.author },
|
|
createdAt: item.createdAt ?? new Date(0).toISOString(),
|
|
updatedAt: item.updatedAt ?? undefined,
|
|
path: item.filePath,
|
|
threadId: item.threadId,
|
|
htmlUrl: item.url,
|
|
state: item.reviewState ?? undefined,
|
|
verdict: item.verdict,
|
|
reviewType: item.reviewType,
|
|
resolution: item.resolution,
|
|
summary: item.title ?? undefined,
|
|
isResolved: item.isResolved,
|
|
...(typeof item.line === "number" ? { line: item.line } : {}),
|
|
}));
|
|
|
|
return {
|
|
reviewState: {
|
|
source: data.mode,
|
|
summary: data.summary ?? undefined,
|
|
items: canonicalItems,
|
|
addressing: data.items
|
|
.filter((item) => item.progressStatus != null)
|
|
.map((item) => ({
|
|
itemId: item.itemId,
|
|
status: item.progressStatus ?? "queued",
|
|
selectedAt: item.createdAt ?? fetchedAt ?? new Date(0).toISOString(),
|
|
snapshot: {
|
|
itemId: item.itemId,
|
|
sourceMode: item.sourceMode,
|
|
source: item.sourceMode === "pull-request" ? "pr-review" : "reviewer-agent",
|
|
summary: item.title || item.body.slice(0, 120),
|
|
body: item.body,
|
|
authorLogin: item.author,
|
|
filePath: item.filePath,
|
|
lineNumber: item.line,
|
|
threadId: item.threadId,
|
|
url: item.url,
|
|
},
|
|
})),
|
|
lastRefreshedAt: fetchedAt,
|
|
refreshStatus: "ready",
|
|
refreshSource: "initial-load",
|
|
},
|
|
automationStatus: null,
|
|
};
|
|
}
|
|
|
|
/** Fetch normalized task review data (PR mode or direct mode) */
|
|
export async function fetchTaskReview(taskId: string, projectId?: string): Promise<TaskReviewResponse> {
|
|
const data = await api<TaskReviewData>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review`, projectId));
|
|
return mapTaskReviewDataToLegacy(data);
|
|
}
|
|
|
|
/** Fetch canonical review payload for future review-tab rendering. */
|
|
export function fetchTaskReviewData(taskId: string, projectId?: string): Promise<TaskReviewData> {
|
|
return api<TaskReviewData>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review`, projectId));
|
|
}
|
|
|
|
/** Refresh normalized task review data (PR mode or direct mode) */
|
|
export async function refreshTaskReview(taskId: string, projectId?: string): Promise<RefreshTaskReviewResponse> {
|
|
const data = await api<TaskReviewData>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review/refresh`, projectId), {
|
|
method: "POST",
|
|
});
|
|
return mapTaskReviewDataToLegacy(data);
|
|
}
|
|
|
|
/** Refresh canonical review payload for future review-tab rendering. */
|
|
export function refreshTaskReviewData(taskId: string, projectId?: string): Promise<TaskReviewData> {
|
|
return api<TaskReviewData>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review/refresh`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Request an in-place revision pass for selected review items */
|
|
export function reviseTaskReviewItems(taskId: string, selectedItems: SelectedReviewItem[], projectId?: string): Promise<ReviseTaskReviewResponse> {
|
|
return api<ReviseTaskReviewResponse>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review/address`, projectId), {
|
|
method: "POST",
|
|
body: JSON.stringify({ selectedItems, tab: "review" }),
|
|
});
|
|
}
|
|
|
|
/** Request an AI pass that addresses open pull-request feedback for the task's primary PR. */
|
|
export function addressPrFeedback(taskId: string, projectId?: string): Promise<AddressPrFeedbackResponse> {
|
|
return api<AddressPrFeedbackResponse>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/pr/address-feedback`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/** Return task to agent - clear assignee and status, move to todo */
|
|
export function returnTaskToAgent(taskId: string, projectId?: string): Promise<Task> {
|
|
return api<Task>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/return-to-agent`, projectId), {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fetch an agent's explicitly assigned tasks plus its active workflow-linked task.
|
|
*
|
|
* FNXC:AgentTasks 2026-08-09-01:03:
|
|
* The endpoint resolves both durable `assignedAgentId` ownership and the
|
|
* runtime `agent.taskId` link within the selected project; callers receive a
|
|
* deduplicated task list regardless of the agent's role.
|
|
*/
|
|
export function fetchAgentTasks(agentId: string, projectId?: string): Promise<Task[]> {
|
|
return api<Task[]>(withProjectId(`/agents/${encodeURIComponent(agentId)}/tasks`, projectId));
|
|
}
|
|
|