Files
fusion/packages/dashboard/app/utils/taskTiming.ts
gsxdsm 1d4e8afa7b FN-8444: include planning time in task metrics
Track active planning time alongside execution time for costs, analytics, and task displays.

- Persist planning timing state across task lifecycle transitions and recovery
- Include planning activity in token cost, analytics, and dashboard timing displays
- Add PostgreSQL migration support using the configured migration directory

Files changed:
 .changeset/fn-8444-planning-time-cost.md           |  7 +++
 docs/dashboard-guide.md                            |  3 ++
 docs/task-management.md                            |  5 ++
 packages/core/src/index.ts                         |  1 +
 .../migrations/0029_planning_active_timing.sql     |  3 ++
 packages/core/src/postgres/schema-applier.ts       | 14 ++++-
 packages/core/src/postgres/schema/project.ts       |  2 +
 packages/core/src/productivity-analytics.ts        | 29 +++++-----
 packages/core/src/store.ts                         |  2 +-
 .../core/src/task-store/archive-lifecycle-2.ts     |  2 +
 packages/core/src/task-store/moves.ts              |  7 +++
 packages/core/src/task-store/persistence.ts        |  4 ++
 packages/core/src/task-store/remaining-ops-2.ts    |  2 +-
 packages/core/src/task-store/serialization.ts      |  7 +++
 packages/core/src/task-store/task-row-mappers.ts   |  2 +-
 packages/core/src/task-store/task-update.ts        | 10 ++++
 packages/core/src/task-timing.ts                   | 35 ++++++++++++
 packages/core/src/types.ts                         | 12 +++++
 packages/dashboard/app/components/TaskCard.tsx     | 13 ++---
 .../app/components/TaskTokenStatsPanel.tsx         |  6 ++-
 .../app/components/__tests__/TaskCard.test.tsx     | 17 ++++++
 .../app/utils/__tests__/taskTiming.test.ts         |  9 +++-
 packages/dashboard/app/utils/taskTiming.ts         | 14 +++++
 packages/dashboard/app/utils/taskTokenCost.ts      |  2 +
 .../dashboard/src/task-planner-chat-metrics.ts     | 14 ++++-
 packages/engine/src/__tests__/self-healing.test.ts | 61 +++++++++++++++++++++
 packages/engine/src/executor.ts                    | 50 +++++++++++++++++
 packages/engine/src/runtimes/in-process-runtime.ts |  3 ++
 packages/engine/src/self-healing.ts                | 62 ++++++++++++++++++++++
 packages/engine/src/triage.ts                      | 10 ++++
 packages/i18n/locales/en/app.json                  |  2 +-
 packages/i18n/locales/es/app.json                  |  2 +-
 packages/i18n/locales/fr/app.json                  |  2 +-
 packages/i18n/locales/ko/app.json                  |  2 +-
 packages/i18n/locales/zh-CN/app.json               |  2 +-
 packages/i18n/locales/zh-TW/app.json               |  2 +-
 36 files changed, 384 insertions(+), 36 deletions(-)

Fusion-Task-Id: FN-8444

Fusion-Task-Lineage: 0178e0a7-3018-4ef4-be9b-6de5f964fb58

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-20 13:40:50 -07:00

143 lines
4.8 KiB
TypeScript

import type { Task, TaskLogEntry, WorkflowStepResult } from "@fusion/core";
export interface TimingEvent {
timestamp: string;
durationMs?: number;
summary: string;
}
function summarizeTimingLabel(entry: TaskLogEntry): string {
const timingText = entry.action || entry.outcome || "";
const stripped = timingText
.replace(/^\[timing\]\s*/i, "")
.replace(/^\[[^\]]+\]\s*/i, "")
.replace(/\s+in\s+\d+(?:\.\d+)?ms\b/i, "")
.replace(/\s+after\s+\d+(?:\.\d+)?ms\b/i, "")
.trim();
return stripped || "Timing event";
}
export function extractTimingEvents(logEntries: TaskLogEntry[]): TimingEvent[] {
return logEntries
.filter((entry) => {
const actionText = typeof entry.action === "string" ? entry.action : "";
const outcomeText = typeof entry.outcome === "string" ? entry.outcome : "";
return actionText.includes("[timing]") || outcomeText.includes("[timing]");
})
.map((entry) => {
const haystack = `${entry.action ?? ""}\n${entry.outcome ?? ""}`;
const durationMatch = haystack.match(/(\d+(?:\.\d+)?)ms\b/i);
const durationMs = durationMatch ? Number(durationMatch[1]) : undefined;
return {
timestamp: entry.timestamp,
durationMs: Number.isFinite(durationMs) ? durationMs : undefined,
summary: summarizeTimingLabel(entry),
};
});
}
export function getTimedDurationMs(logEntries: TaskLogEntry[] | undefined): number | null {
if (!logEntries || logEntries.length === 0) return null;
let total = 0;
let counted = 0;
for (const event of extractTimingEvents(logEntries)) {
if (typeof event.durationMs !== "number") continue;
total += event.durationMs;
counted += 1;
}
return counted > 0 ? total : null;
}
export function parseTimestampToMs(value?: string): number | null {
if (!value) return null;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
}
export function getWorkflowRuntimeMs(results: WorkflowStepResult[] | undefined, nowMs: number): number | null {
if (!results || results.length === 0) return null;
let total = 0;
let counted = 0;
for (const step of results) {
if (!step.startedAt) continue;
const startedMs = parseTimestampToMs(step.startedAt);
if (startedMs == null) continue;
let endMs: number;
if (step.completedAt) {
const completedMs = parseTimestampToMs(step.completedAt);
if (completedMs == null || completedMs < startedMs) continue;
endMs = completedMs;
} else {
endMs = Math.max(startedMs, nowMs);
}
total += endMs - startedMs;
counted += 1;
}
return counted > 0 ? total : null;
}
export function getEndToEndDurationMs(
executionStartedAt: string | undefined,
executionCompletedAt: string | undefined,
nowMs: number,
): number | null {
const startedMs = parseTimestampToMs(executionStartedAt);
if (startedMs == null) return null;
const completedMs = parseTimestampToMs(executionCompletedAt);
const endMs = completedMs != null && completedMs >= startedMs ? completedMs : nowMs;
return Math.max(0, endMs - startedMs);
}
export function getActiveRuntimeMs(
task: Pick<Task, "column" | "cumulativeActiveMs" | "executionStartedAt" | "columnMovedAt">,
nowMs: number,
): number | null {
const persisted = task.cumulativeActiveMs;
const base = persisted ?? 0;
if (task.column === "in-progress") {
const startedMs = parseTimestampToMs(task.executionStartedAt);
if (startedMs != null) {
return base + Math.max(0, nowMs - startedMs);
}
}
if (persisted != null) {
return Math.max(0, persisted);
}
return null;
}
/** FNXC:TaskTiming 2026-08-01-10:00: rendered task totals include planning AI
* segments while getActiveRuntimeMs intentionally remains execution-only. */
export function getTotalAgentActiveMs(
task: Pick<Task, "column" | "cumulativeActiveMs" | "executionStartedAt" | "cumulativePlanningMs" | "planningStartedAt">,
nowMs: number,
): number | null {
const execution = getActiveRuntimeMs(task, nowMs) ?? 0;
const planningStart = parseTimestampToMs(task.planningStartedAt);
const planning = Math.max(0, task.cumulativePlanningMs ?? 0) + (planningStart != null ? Math.max(0, nowMs - planningStart) : 0);
return task.cumulativeActiveMs != null || task.cumulativePlanningMs != null || (task.column === "in-progress" && parseTimestampToMs(task.executionStartedAt) != null) || planningStart != null
? execution + planning
: null;
}
export function getWallClockSinceFirstExecutionMs(
firstExecutionAt: string | undefined,
executionCompletedAt: string | undefined,
nowMs: number,
): number | null {
const firstMs = parseTimestampToMs(firstExecutionAt);
if (firstMs == null) return null;
const completedMs = parseTimestampToMs(executionCompletedAt);
const endMs = completedMs != null ? completedMs : nowMs;
return Math.max(0, endMs - firstMs);
}