fix(runs): count a model run once when the terminal node carries its result on
ci / check (push) Successful in 1m35s
ci / publish (push) Successful in 1m33s

A terminal node's result is the record it received, so after post_upgrade the run
listed the same post-upgrade check twice and counted its cost twice (run view, run
list and model costs). Identical calls now count once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
homelab-updaterandClaude Opus 5.5 committed 2026-10-07 11:31:30 +02:00
1 parent d05b6c27f3
commit bcd144fbdc
2 files changed
+46 -23

No files matched your search

+14
View File
@@ -27,4 +27,18 @@ describe('workflowModelCalls', () => {
expect(calls[2]).toMatchObject({ instanceId: 'run-1', workflowId: 'update-radarr', summary: 'Clean start.', actions: ['hl ps'], at: '2026-09-22T21:35:00.000Z' });
expect(totalCostUsd(calls)).toBeCloseTo(0.82);
});
it('counts a model run once when a later node carries its result on, as the terminal node does', () => {
const postUpgrade = { action: 'model.postUpgrade', state: 'completed', report: { outcome: 'nothing_to_do', summary: 'Clean start.', actions: ['hl ps'], model: 'sonnet', costUsd: 0.2 } };
const notes = { verdict: 'attention', summary: 'A migration runs.', model: 'opus', costUsd: 0.12 };
const calls = workflowModelCalls({ id: 'run-1', workflowId: 'update-radarr' }, [
attempt(1, 'release_notes', notes),
attempt(2, 'gate', notes),
attempt(3, 'post_upgrade', postUpgrade),
attempt(4, 'terminal_updated', postUpgrade),
]);
expect(calls.map((call) => call.nodeId)).toEqual(['release_notes', 'post_upgrade']);
expect(totalCostUsd(calls)).toBeCloseTo(0.32);
});
});
+32 -23
View File
@@ -22,34 +22,43 @@ const cost = (value: unknown): number => (typeof value === 'number' && Number.is
/**
* The model runs a workflow run made, read from its node attempts: a model action's result carries `report`
* (app/privileged-action-executor-adapter), the release notes operation's result carries `verdict` and `model`.
* A node that passes the record on (a terminal node's result is its input) repeats that result; the same call counts once.
*/
export function workflowModelCalls(instance: { readonly id: string; readonly workflowId: string }, attempts: readonly NodeAttempt[]): WorkflowModelCall[] {
return attempts.flatMap((attempt): WorkflowModelCall[] => {
const result = record(attempt.result);
const at = attempt.finishedAt ?? attempt.startedAt;
const base = { instanceId: instance.id, workflowId: instance.workflowId, nodeId: attempt.nodeId, at };
const action = text(result['action']);
if (action === 'model.postUpgrade' || action === 'model.repair') {
const report = record(result['report']);
if (Object.keys(report).length === 0) return [];
return [{
...base, kind: action === 'model.repair' ? 'repair' : 'postUpgrade', model: text(report['model']),
outcome: text(report['outcome']) ?? 'unknown', summary: text(report['summary']) ?? '',
actions: Array.isArray(report['actions']) ? report['actions'].filter((item): item is string => typeof item === 'string') : [],
costUsd: cost(report['costUsd']),
}];
}
// Only a release notes result that reached a model names one; "no source" and unreadable notes cost nothing.
if ('verdict' in result && text(result['model']) !== null) {
return [{
...base, kind: 'releaseNotes', model: text(result['model']), outcome: text(result['verdict']) ?? 'unknown',
summary: text(result['summary']) ?? '', actions: [], costUsd: cost(result['costUsd']),
}];
}
return [];
const seen = new Set<string>();
return attempts.flatMap((attempt) => modelCallOf(instance, attempt)).filter(({ nodeId: _nodeId, at: _at, ...call }) => {
const key = JSON.stringify(call);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function modelCallOf(instance: { readonly id: string; readonly workflowId: string }, attempt: NodeAttempt): WorkflowModelCall[] {
const result = record(attempt.result);
const at = attempt.finishedAt ?? attempt.startedAt;
const base = { instanceId: instance.id, workflowId: instance.workflowId, nodeId: attempt.nodeId, at };
const action = text(result['action']);
if (action === 'model.postUpgrade' || action === 'model.repair') {
const report = record(result['report']);
if (Object.keys(report).length === 0) return [];
return [{
...base, kind: action === 'model.repair' ? 'repair' : 'postUpgrade', model: text(report['model']),
outcome: text(report['outcome']) ?? 'unknown', summary: text(report['summary']) ?? '',
actions: Array.isArray(report['actions']) ? report['actions'].filter((item): item is string => typeof item === 'string') : [],
costUsd: cost(report['costUsd']),
}];
}
// Only a release notes result that reached a model names one; "no source" and unreadable notes cost nothing.
if ('verdict' in result && text(result['model']) !== null) {
return [{
...base, kind: 'releaseNotes', model: text(result['model']), outcome: text(result['verdict']) ?? 'unknown',
summary: text(result['summary']) ?? '', actions: [], costUsd: cost(result['costUsd']),
}];
}
return [];
}
export function totalCostUsd(calls: readonly WorkflowModelCall[]): number {
return calls.reduce((sum, call) => sum + call.costUsd, 0);
}