/** * execution-poll-cost.ts — the bill on an execution report's poll block. * * A transport states what the provider charged as `VideoExecutionPollResult. * actualCost`; this module carries it onto `report.poll` unchanged, and for a * per-scene candidate poll sums the scenes' bills into one — stated only when * EVERY completed scene stated one in ONE currency, because a partial sum * would read as the whole bill. Extracted from execution-status.ts (which sits * at the module-size ceiling) so the rule lives in one small, testable place. */ import type { VideoExecutionPollResult, VideoExecutionReport } from './types.js'; export type ExecutionBill = NonNullable; export interface JobCostAggregate { /** * Record a COMPLETED provider JOB's poll, once per job. A transport's * `actualCost` is the whole job's bill (every scene it carried), so a job * shared by several scenes is added once, never once per scene. A poll * without a bill still counts as a completed job. */ add(poll: Pick): void; /** The summed bill, or undefined when any completed job stated none or the currencies differ. */ total(): ExecutionBill | undefined; /** Why `total()` is undefined although at least one bill was stated; undefined when nothing was dropped. */ droppedReason(): string | undefined; } export function createJobCostAggregate(): JobCostAggregate { let completedJobs = 0; let costedJobs = 0; let sum = 0; const currencies = new Set(); return { add(poll) { completedJobs += 1; if (!poll.actualCost) return; costedJobs += 1; sum += poll.actualCost.amount; currencies.add(poll.actualCost.currency); }, total() { if (completedJobs === 0 || costedJobs !== completedJobs || currencies.size !== 1) return undefined; return { currency: [...currencies][0]!, amount: Math.round(sum * 10_000) / 10_000 }; }, droppedReason() { if (costedJobs === 0) return undefined; if (currencies.size > 1) return `the completed jobs stated bills in ${currencies.size} currencies (${[...currencies].join(', ')}), which cannot be summed`; if (costedJobs !== completedJobs) return `${completedJobs - costedJobs} of ${completedJobs} completed job(s) stated no bill, so no partial sum is stated`; return undefined; }, }; } /** * Poll a provider job at most once per pass. Several scenes can share one job * (execute.ts gives every task of a submission the same externalJobId), and a * transport's `actualCost` is that whole job's bill, so the second scene reuses * the first scene's answer instead of asking the provider again and counting * the bill twice. A failed poll is memoised too, so every scene on that job * reports the same failure. On the first sight of a completed job its bill is * added to `costs`. */ export async function pollJobOnce( memo: Map, jobId: string, costs: JobCostAggregate, poll: () => Promise, ): Promise<{ result: VideoExecutionPollResult | Error; first: boolean }> { const seen = memo.get(jobId); if (seen) return { result: seen, first: false }; try { const result = await poll(); memo.set(jobId, result); if (result.status === 'completed') costs.add(result); return { result, first: true }; } catch (error) { const failure = error instanceof Error ? error : new Error(String(error)); memo.set(jobId, failure); return { result: failure, first: true }; } } /** * True when this status check would only repeat what the report already * recorded: the same job, already terminal with the same status. A repeated * `execute-status` on a finished job must not append another telemetry event * carrying the same bill — the preview portal sums `cost.usd` across events. * * Invariant this leans on: `execute.ts` builds a FRESH report for every * submission and never carries a previous `poll` block forward, so a * non-pending `previous` always belongs to `previousJobId`. A front door that * ever preserved `poll` across submissions would have to stamp the job id into * the block, or this guard would suppress a second job's first bill. */ export function pollTelemetryAlreadyRecorded( previous: VideoExecutionReport['poll'] | undefined, previousJobId: string | null | undefined, next: { status: VideoExecutionPollResult['status']; externalJobId: string | null }, ): boolean { if (!previous || previous.status === 'pending') return false; return previous.status === next.status && (previousJobId ?? null) === next.externalJobId; } /** The report's poll block from a poll result: status, issues, ingested count, raw result, and the bill when stated. */ export function reportPollBlock(input: { lastCheckedAt: string; status: VideoExecutionPollResult['status']; issues: string[]; outputsIngested?: number; rawResult: unknown; actualCost?: ExecutionBill; }): NonNullable { return { lastCheckedAt: input.lastCheckedAt, status: input.status, issues: input.issues, ...(input.status === 'completed' && input.outputsIngested !== undefined ? { outputsIngested: input.outputsIngested } : {}), rawResult: input.rawResult, ...(input.actualCost ? { actualCost: input.actualCost } : {}), }; }