diff --git a/.changeset/guard-task-detail-log-entry-shape.md b/.changeset/guard-task-detail-log-entry-shape.md new file mode 100644 index 0000000000..31ef6ab0ea --- /dev/null +++ b/.changeset/guard-task-detail-log-entry-shape.md @@ -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. diff --git a/packages/dashboard/app/__tests__/task-log-entry-display.test.ts b/packages/dashboard/app/__tests__/task-log-entry-display.test.ts new file mode 100644 index 0000000000..24af305c8a --- /dev/null +++ b/packages/dashboard/app/__tests__/task-log-entry-display.test.ts @@ -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; + + 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; + + expect(getInReviewStallDeadlockCopy(task)?.headline).toBe("In-review deadlock auto-disposed"); + }); +}); diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 2d2865e2ae..7f33c9702c 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -60,6 +60,7 @@ import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inR import { getStalePausedReviewCopy, shouldShowStalePausedReviewBadge } from "../utils/stalePausedReviewCopy"; import { getTaskAgeStalenessCopy } from "../utils/taskAgeStalenessCopy"; import { findInReviewStallLogEntry, IN_REVIEW_STALL_LOG_REGEX } from "../utils/findInReviewStallLogEntry"; +import { getTaskLogEntryAction, getTaskLogEntryOutcome } from "../utils/taskLogEntryDisplay"; interface ModelSelection { provider?: string; @@ -3236,10 +3237,13 @@ export function TaskDetailContent({ ) : workingTask.log && workingTask.log.length > 0 ? (
{(() => { + // FNXC:TaskDetail 2026-06-14-13:43 Activity rendering must tolerate legacy `text`/`detail` log entries. let highlightedOnce = false; return [...workingTask.log].reverse().map((entry, i) => { - const stallMatch = entry.action.match(IN_REVIEW_STALL_LOG_REGEX) - ?? entry.action.match(STALE_PAUSED_REVIEW_LOG_REGEX); + const action = getTaskLogEntryAction(entry); + const outcome = getTaskLogEntryOutcome(entry); + const stallMatch = action.match(IN_REVIEW_STALL_LOG_REGEX) + ?? action.match(STALE_PAUSED_REVIEW_LOG_REGEX); const isHighlighted = !highlightedOnce && highlightStallCode != null && stallMatch?.[1] === highlightStallCode; @@ -3256,10 +3260,10 @@ export function TaskDetailContent({ {formatTimestamp(entry.timestamp)} - {entry.action} + {action}
- {entry.outcome && ( -
{entry.outcome}
+ {outcome && ( +
{outcome}
)} ); @@ -3337,7 +3341,7 @@ export function TaskDetailContent({ {shouldShowStalePausedReviewBadge(workingTask) && workingTask.stalePausedReview && (() => { const copy = getStalePausedReviewCopy(workingTask.stalePausedReview); 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 ( diff --git a/packages/dashboard/app/utils/findInReviewStallLogEntry.ts b/packages/dashboard/app/utils/findInReviewStallLogEntry.ts index 6563dc9869..546a006d39 100644 --- a/packages/dashboard/app/utils/findInReviewStallLogEntry.ts +++ b/packages/dashboard/app/utils/findInReviewStallLogEntry.ts @@ -1,4 +1,5 @@ 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_REGEX = /^In-review stall surfaced \[([^\]]+)\]/; @@ -19,7 +20,7 @@ export function findInReviewStallLogEntry( const reversed = [...task.log].reverse(); 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) { continue; } diff --git a/packages/dashboard/app/utils/inReviewStallCopy.ts b/packages/dashboard/app/utils/inReviewStallCopy.ts index cee550a734..e2ae577754 100644 --- a/packages/dashboard/app/utils/inReviewStallCopy.ts +++ b/packages/dashboard/app/utils/inReviewStallCopy.ts @@ -1,6 +1,7 @@ import type { InReviewStallCode, InReviewStallSignal, Task } from "@fusion/core"; import { MAX_AUTO_MERGE_RETRIES } from "../hooks/useBlockerFanout"; +import { getTaskLogEntryAction } from "./taskLogEntryDisplay"; export interface InReviewStallCopy { 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.", }; +/** + * FNXC:TaskLogs 2026-06-14-13:51 Detects in-review deadlock logs while tolerating legacy entries without `action`. + */ export function getInReviewStallDeadlockCopy(task: Pick): InReviewStallDeadlockCopy | undefined { if (task.pausedReason === "in-review-stall-deadlock") { 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; } diff --git a/packages/dashboard/app/utils/taskLogEntryDisplay.ts b/packages/dashboard/app/utils/taskLogEntryDisplay.ts new file mode 100644 index 0000000000..078367594b --- /dev/null +++ b/packages/dashboard/app/utils/taskLogEntryDisplay.ts @@ -0,0 +1,34 @@ +import type { TaskLogEntry } from "@fusion/core"; + +export type TaskLogEntryLike = Omit, "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; +}