fix(dashboard): guard task detail log entry rendering
This commit is contained in:
5
.changeset/guard-task-detail-log-entry-shape.md
Normal file
5
.changeset/guard-task-detail-log-entry-shape.md
Normal 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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
<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;
|
||||
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({
|
||||
<span className="detail-log-timestamp">
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
<span className="detail-log-action">{entry.action}</span>
|
||||
<span className="detail-log-action">{action}</span>
|
||||
</div>
|
||||
{entry.outcome && (
|
||||
<div className="detail-log-outcome">{entry.outcome}</div>
|
||||
{outcome && (
|
||||
<div className="detail-log-outcome">{outcome}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Task, "pausedReason" | "log">): 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;
|
||||
}
|
||||
|
||||
|
||||
34
packages/dashboard/app/utils/taskLogEntryDisplay.ts
Normal file
34
packages/dashboard/app/utils/taskLogEntryDisplay.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user