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>
143 lines
4.8 KiB
TypeScript
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);
|
|
}
|