fix(dashboard): guard task detail log entry rendering

This commit is contained in:
Phil Larson
2026-06-14 13:07:51 -07:00
parent 23c2bc935a
commit 84cf3ff6e5
6 changed files with 118 additions and 8 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Guard task detail activity-log rendering against legacy/operator log entries that use text/detail instead of action/outcome.

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import type { InReviewStallCode, Task } from "@fusion/core";
import { findInReviewStallLogEntry } from "../utils/findInReviewStallLogEntry";
import { getInReviewStallDeadlockCopy } from "../utils/inReviewStallCopy";
import { getTaskLogEntryAction, getTaskLogEntryOutcome } from "../utils/taskLogEntryDisplay";
describe("task log entry display helpers", () => {
it("falls back to text/detail for legacy or operator-shaped log entries", () => {
const entry = {
timestamp: "2026-06-14T18:50:17Z",
text: "Operator parked incomplete stuck-loop-exhausted task",
detail: "Backups preserved before recovery",
type: "operator",
};
expect(getTaskLogEntryAction(entry)).toBe("Operator parked incomplete stuck-loop-exhausted task");
expect(getTaskLogEntryOutcome(entry)).toBe("Backups preserved before recovery");
});
it("returns safe empty display values for malformed entries", () => {
expect(getTaskLogEntryAction({ timestamp: "now" })).toBe("");
expect(getTaskLogEntryOutcome({ timestamp: "now" })).toBeUndefined();
expect(getTaskLogEntryAction(undefined)).toBe("");
});
it("falls back from blank action/outcome strings to legacy fields", () => {
const entry = {
timestamp: "2026-06-14T18:50:17Z",
action: " ",
outcome: "",
text: "Legacy action text",
detail: "Legacy detail text",
};
expect(getTaskLogEntryAction(entry)).toBe("Legacy action text");
expect(getTaskLogEntryOutcome(entry)).toBe("Legacy detail text");
});
it("does not throw while scanning logs that contain entries without action", () => {
const task = {
log: [
{ timestamp: "2026-06-14T18:50:17Z", text: "operator note", type: "operator" },
{ timestamp: "2026-06-14T18:51:17Z", action: "In-review stall surfaced [merge-retries-exhausted]" },
],
} as unknown as Pick<Task, "log">;
const code: InReviewStallCode = "merge-retries-exhausted";
expect(findInReviewStallLogEntry(task, code)?.reversedIndex).toBe(0);
});
it("does not throw while checking deadlock copy logs that contain entries without action", () => {
const task = {
pausedReason: undefined,
log: [
{ timestamp: "2026-06-14T18:50:17Z", text: "operator note", type: "operator" },
{ timestamp: "2026-06-14T18:51:17Z", action: "In-review stall auto-disposed [merge-blocker]" },
],
} as unknown as Pick<Task, "pausedReason" | "log">;
expect(getInReviewStallDeadlockCopy(task)?.headline).toBe("In-review deadlock auto-disposed");
});
});

View File

@@ -60,6 +60,7 @@ import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inR
import { getStalePausedReviewCopy, shouldShowStalePausedReviewBadge } from "../utils/stalePausedReviewCopy"; import { getStalePausedReviewCopy, shouldShowStalePausedReviewBadge } from "../utils/stalePausedReviewCopy";
import { getTaskAgeStalenessCopy } from "../utils/taskAgeStalenessCopy"; import { getTaskAgeStalenessCopy } from "../utils/taskAgeStalenessCopy";
import { findInReviewStallLogEntry, IN_REVIEW_STALL_LOG_REGEX } from "../utils/findInReviewStallLogEntry"; import { findInReviewStallLogEntry, IN_REVIEW_STALL_LOG_REGEX } from "../utils/findInReviewStallLogEntry";
import { getTaskLogEntryAction, getTaskLogEntryOutcome } from "../utils/taskLogEntryDisplay";
interface ModelSelection { interface ModelSelection {
provider?: string; provider?: string;
@@ -3236,10 +3237,13 @@ export function TaskDetailContent({
) : workingTask.log && workingTask.log.length > 0 ? ( ) : workingTask.log && workingTask.log.length > 0 ? (
<div className="detail-activity-list" ref={activityListRef}> <div className="detail-activity-list" ref={activityListRef}>
{(() => { {(() => {
// FNXC:TaskDetail 2026-06-14-13:43 Activity rendering must tolerate legacy `text`/`detail` log entries.
let highlightedOnce = false; let highlightedOnce = false;
return [...workingTask.log].reverse().map((entry, i) => { return [...workingTask.log].reverse().map((entry, i) => {
const stallMatch = entry.action.match(IN_REVIEW_STALL_LOG_REGEX) const action = getTaskLogEntryAction(entry);
?? entry.action.match(STALE_PAUSED_REVIEW_LOG_REGEX); const outcome = getTaskLogEntryOutcome(entry);
const stallMatch = action.match(IN_REVIEW_STALL_LOG_REGEX)
?? action.match(STALE_PAUSED_REVIEW_LOG_REGEX);
const isHighlighted = !highlightedOnce const isHighlighted = !highlightedOnce
&& highlightStallCode != null && highlightStallCode != null
&& stallMatch?.[1] === highlightStallCode; && stallMatch?.[1] === highlightStallCode;
@@ -3256,10 +3260,10 @@ export function TaskDetailContent({
<span className="detail-log-timestamp"> <span className="detail-log-timestamp">
{formatTimestamp(entry.timestamp)} {formatTimestamp(entry.timestamp)}
</span> </span>
<span className="detail-log-action">{entry.action}</span> <span className="detail-log-action">{action}</span>
</div> </div>
{entry.outcome && ( {outcome && (
<div className="detail-log-outcome">{entry.outcome}</div> <div className="detail-log-outcome">{outcome}</div>
)} )}
</div> </div>
); );
@@ -3337,7 +3341,7 @@ export function TaskDetailContent({
{shouldShowStalePausedReviewBadge(workingTask) && workingTask.stalePausedReview && (() => { {shouldShowStalePausedReviewBadge(workingTask) && workingTask.stalePausedReview && (() => {
const copy = getStalePausedReviewCopy(workingTask.stalePausedReview); const copy = getStalePausedReviewCopy(workingTask.stalePausedReview);
const logMatch = [...(workingTask.log ?? [])].reverse().find((entry) => { const logMatch = [...(workingTask.log ?? [])].reverse().find((entry) => {
const match = entry.action.match(STALE_PAUSED_REVIEW_LOG_REGEX); const match = getTaskLogEntryAction(entry).match(STALE_PAUSED_REVIEW_LOG_REGEX);
return match?.[1] === workingTask.stalePausedReview?.code; return match?.[1] === workingTask.stalePausedReview?.code;
}); });
return ( return (

View File

@@ -1,4 +1,5 @@
import type { InReviewStallCode, Task, TaskLogEntry } from "@fusion/core"; import type { InReviewStallCode, Task, TaskLogEntry } from "@fusion/core";
import { getTaskLogEntryAction } from "./taskLogEntryDisplay";
export const IN_REVIEW_STALL_LOG_PREFIX = "In-review stall surfaced ["; export const IN_REVIEW_STALL_LOG_PREFIX = "In-review stall surfaced [";
export const IN_REVIEW_STALL_LOG_REGEX = /^In-review stall surfaced \[([^\]]+)\]/; export const IN_REVIEW_STALL_LOG_REGEX = /^In-review stall surfaced \[([^\]]+)\]/;
@@ -19,7 +20,7 @@ export function findInReviewStallLogEntry(
const reversed = [...task.log].reverse(); const reversed = [...task.log].reverse();
for (const [reversedIndex, entry] of reversed.entries()) { for (const [reversedIndex, entry] of reversed.entries()) {
const match = entry.action.match(IN_REVIEW_STALL_LOG_REGEX); const match = getTaskLogEntryAction(entry).match(IN_REVIEW_STALL_LOG_REGEX);
if (!match || match[1] !== code) { if (!match || match[1] !== code) {
continue; continue;
} }

View File

@@ -1,6 +1,7 @@
import type { InReviewStallCode, InReviewStallSignal, Task } from "@fusion/core"; import type { InReviewStallCode, InReviewStallSignal, Task } from "@fusion/core";
import { MAX_AUTO_MERGE_RETRIES } from "../hooks/useBlockerFanout"; import { MAX_AUTO_MERGE_RETRIES } from "../hooks/useBlockerFanout";
import { getTaskLogEntryAction } from "./taskLogEntryDisplay";
export interface InReviewStallCopy { export interface InReviewStallCopy {
badgeLabel: string; badgeLabel: string;
@@ -109,12 +110,15 @@ const IN_REVIEW_STALL_DEADLOCK_COPY: InReviewStallDeadlockCopy = {
"Inspect the merge blocker/branch conflict, recover manually, then unpause to retry. If recovery needs extra implementation, create a follow-up with fn_task_refine.", "Inspect the merge blocker/branch conflict, recover manually, then unpause to retry. If recovery needs extra implementation, create a follow-up with fn_task_refine.",
}; };
/**
* FNXC:TaskLogs 2026-06-14-13:51 Detects in-review deadlock logs while tolerating legacy entries without `action`.
*/
export function getInReviewStallDeadlockCopy(task: Pick<Task, "pausedReason" | "log">): InReviewStallDeadlockCopy | undefined { export function getInReviewStallDeadlockCopy(task: Pick<Task, "pausedReason" | "log">): InReviewStallDeadlockCopy | undefined {
if (task.pausedReason === "in-review-stall-deadlock") { if (task.pausedReason === "in-review-stall-deadlock") {
return IN_REVIEW_STALL_DEADLOCK_COPY; return IN_REVIEW_STALL_DEADLOCK_COPY;
} }
const hasDeadlockLog = task.log?.some((entry) => entry.action.startsWith(IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX)) ?? false; const hasDeadlockLog = task.log?.some((entry) => getTaskLogEntryAction(entry).startsWith(IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX)) ?? false;
return hasDeadlockLog ? IN_REVIEW_STALL_DEADLOCK_COPY : undefined; return hasDeadlockLog ? IN_REVIEW_STALL_DEADLOCK_COPY : undefined;
} }

View File

@@ -0,0 +1,34 @@
import type { TaskLogEntry } from "@fusion/core";
export type TaskLogEntryLike = Omit<Partial<TaskLogEntry>, "action" | "outcome"> & {
action?: unknown;
outcome?: unknown;
text?: unknown;
detail?: unknown;
};
/**
* FNXC:TaskDetail 2026-06-14-13:43 Safely extract an activity-log action string with legacy `text` fallback.
*/
export function getTaskLogEntryAction(entry: TaskLogEntryLike | null | undefined): string {
if (typeof entry?.action === "string" && entry.action.trim().length > 0) {
return entry.action;
}
if (typeof entry?.text === "string" && entry.text.trim().length > 0) {
return entry.text;
}
return "";
}
/**
* FNXC:TaskDetail 2026-06-14-13:43 Safely extract an activity-log outcome string with legacy `detail` fallback.
*/
export function getTaskLogEntryOutcome(entry: TaskLogEntryLike | null | undefined): string | undefined {
if (typeof entry?.outcome === "string" && entry.outcome.trim().length > 0) {
return entry.outcome;
}
if (typeof entry?.detail === "string" && entry.detail.trim().length > 0) {
return entry.detail;
}
return undefined;
}