Files
fusion/packages/dashboard/app/utils/taskTokenCost.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

142 lines
5.7 KiB
TypeScript

import type { TaskDetail, TaskTokenUsagePerModel } from "@fusion/core";
import { costFor, type CostResult, type ModelPricingOverrides } from "../../../core/src/model-pricing";
export interface TokenCostRow {
key: string;
label: string;
modelProvider?: string;
modelId?: string;
inputTokens: number;
outputTokens: number;
cachedTokens: number;
cacheWriteTokens: number;
totalTokens: number;
cost: CostResult;
}
export function formatCount(n: number): string {
return Number.isFinite(n) ? Math.round(n).toLocaleString() : "0";
}
export function formatCost(usd: number | null, unavailable: boolean): string {
if (unavailable || usd === null || !Number.isFinite(usd)) {
return "—";
}
return `$${usd.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export function toTokenBucketKey(bucket: Pick<TaskTokenUsagePerModel, "modelProvider" | "modelId">): string {
return `${bucket.modelProvider ?? ""}:${bucket.modelId ?? ""}`;
}
export function toTokenCostRow(
bucket: Pick<TaskTokenUsagePerModel, "modelProvider" | "modelId" | "inputTokens" | "outputTokens" | "cachedTokens" | "cacheWriteTokens" | "totalTokens">,
unknownLabel: string,
now: number,
pricingOverrides?: ModelPricingOverrides,
): TokenCostRow {
const modelId = bucket.modelId?.trim() || undefined;
const modelProvider = bucket.modelProvider?.trim() || undefined;
const label = modelId ?? unknownLabel;
return {
key: toTokenBucketKey({ modelProvider, modelId }),
label,
modelProvider,
modelId,
inputTokens: bucket.inputTokens,
outputTokens: bucket.outputTokens,
cachedTokens: bucket.cachedTokens,
cacheWriteTokens: bucket.cacheWriteTokens,
totalTokens: bucket.totalTokens,
cost: costFor(
{
inputTokens: bucket.inputTokens,
outputTokens: bucket.outputTokens,
cachedTokens: bucket.cachedTokens,
cacheWriteTokens: bucket.cacheWriteTokens,
},
{ provider: modelProvider, model: modelId },
now,
pricingOverrides,
),
};
}
/**
* FNXC:TaskDetailCost 2026-07-11-12:00:
* Task cost is a read-time derivation shared by the done Summary tab, always-available Cost tab, and optional card badge. Keep the costFor/pricing-overrides path centralized here so unpriced or zero-usage states keep the guess-free “—” sentinel everywhere and derived USD is never persisted.
*
* FNXC:TaskDetailSummaryTokenCost 2026-06-27-00:00:
* FNXC:TaskCost 2026-08-01-10:00: tokenUsage is a full-task additive ledger;
* triage and graph Plan Review buckets intentionally have no role filter here.
* Done-task Summary shows durable token usage broken down by model with derived USD cost. Use already-loaded task.tokenUsage.perModel buckets plus costFor and global pricing overrides threaded from TaskDetailModal; do not fetch or persist cost here. Unpriced models render “—” instead of $0 and make the task total unavailable so estimates are never understated.
*/
export function buildTokenCostRows(task: TaskDetail, unknownLabel: string, pricingOverrides?: ModelPricingOverrides): TokenCostRow[] {
const tokenUsage = task.tokenUsage;
if (!tokenUsage) return [];
const buckets = tokenUsage.perModel?.length
? tokenUsage.perModel
: [
{
modelProvider: tokenUsage.modelProvider,
modelId: tokenUsage.modelId,
inputTokens: tokenUsage.inputTokens,
outputTokens: tokenUsage.outputTokens,
cachedTokens: tokenUsage.cachedTokens,
cacheWriteTokens: tokenUsage.cacheWriteTokens,
totalTokens: tokenUsage.totalTokens,
},
];
const merged = new Map<string, Pick<TaskTokenUsagePerModel, "modelProvider" | "modelId" | "inputTokens" | "outputTokens" | "cachedTokens" | "cacheWriteTokens" | "totalTokens">>();
buckets.forEach((bucket) => {
const modelProvider = bucket.modelProvider?.trim() || undefined;
const modelId = bucket.modelId?.trim() || undefined;
const key = toTokenBucketKey({ modelProvider, modelId });
const current = merged.get(key);
if (!current) {
merged.set(key, { ...bucket, modelProvider, modelId });
return;
}
current.inputTokens += bucket.inputTokens;
current.outputTokens += bucket.outputTokens;
current.cachedTokens += bucket.cachedTokens;
current.cacheWriteTokens += bucket.cacheWriteTokens;
current.totalTokens += bucket.totalTokens;
});
const now = Date.now();
return Array.from(merged.values()).map((bucket) => toTokenCostRow(bucket, unknownLabel, now, pricingOverrides));
}
export function totalCostForRows(rows: TokenCostRow[]): { usd: number | null; unavailable: boolean } {
let usd = 0;
let unavailable = false;
let hasPositiveUsage = false;
rows.forEach((row) => {
if (row.totalTokens <= 0) return;
hasPositiveUsage = true;
if (row.cost.unavailable || row.cost.usd === null || !Number.isFinite(row.cost.usd)) {
unavailable = true;
return;
}
usd += row.cost.usd;
});
return { usd: unavailable || !hasPositiveUsage ? null : usd, unavailable: unavailable || !hasPositiveUsage };
}
export function hasTaskCost(task: TaskDetail): boolean {
const tokenUsage = task.tokenUsage;
if (!tokenUsage) return false;
const totalTokens = tokenUsage.totalTokens
?? ((tokenUsage.inputTokens ?? 0) + (tokenUsage.outputTokens ?? 0) + (tokenUsage.cachedTokens ?? 0) + (tokenUsage.cacheWriteTokens ?? 0));
if (totalTokens > 0) return true;
return (tokenUsage.perModel ?? []).some((bucket) => bucket.totalTokens > 0);
}
export function taskTotalCost(task: TaskDetail, pricingOverrides?: ModelPricingOverrides): CostResult {
const total = totalCostForRows(buildTokenCostRows(task, "(unknown)", pricingOverrides));
return { usd: total.usd, unavailable: total.unavailable, stale: false };
}