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 { 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 (
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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