@coreplane/switchboard 1.253.0 → 1.254.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/config/config.example.yaml +40 -11
- package/dist/assets/deploy/cloudflare-memory/worker.ts +310 -7
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/core/authz/grants.ts +6 -0
- package/dist/assets/src/core/authz/policy.ts +11 -0
- package/dist/assets/src/core/budgets.ts +8 -0
- package/dist/assets/src/core/coordinator/driver.ts +9 -0
- package/dist/assets/src/core/costs.ts +221 -2
- package/dist/assets/src/core/costsSnapshotStore.ts +22 -1
- package/dist/assets/src/core/pipelineStanding.ts +294 -0
- package/dist/assets/src/core/plane/decide.ts +203 -0
- package/dist/assets/src/core/provider.ts +11 -0
- package/dist/assets/src/core/runEvents.ts +26 -0
- package/dist/assets/src/core/runLedger/transcript.ts +23 -3
- package/dist/assets/src/core/runRecord.ts +15 -0
- package/dist/assets/src/core/runUsage.ts +41 -9
- package/dist/assets/src/core/ship/coordinator.ts +45 -9
- package/dist/assets/web/dist/.vite/manifest.json +450 -430
- package/dist/assets/web/dist/assets/{AppShell-Bmz8nzz3.js → AppShell-CSXDCV4x.js} +1 -1
- package/dist/assets/web/dist/assets/CostChart-DsuPS2g7.js +2 -0
- package/dist/assets/web/dist/assets/CostsPage-BuKjw3nv.js +1 -0
- package/dist/assets/web/dist/assets/{DeliveryPage-C7jaEJip.js → DeliveryPage-ngPsO2to.js} +1 -1
- package/dist/assets/web/dist/assets/{HomePage-cWbupQzy.js → HomePage-DvxTHzPx.js} +3 -3
- package/dist/assets/web/dist/assets/{InputMenu-C9E7YoII.js → InputMenu-BdZXnibG.js} +1 -1
- package/dist/assets/web/dist/assets/MetricsPage-o5fSaCTe.js +1 -0
- package/dist/assets/web/dist/assets/{NotFoundPage-4nlrq7DP.js → NotFoundPage-DHuWyjlq.js} +1 -1
- package/dist/assets/web/dist/assets/PendingTurnRow-ZYIRCCZ2.js +1 -0
- package/dist/assets/web/dist/assets/{PlanePage-BNy4tSgJ.js → PlanePage-DpWfiX4C.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentDetailPage-Dbtiq6_J.js → ResidentDetailPage-DG86v39Y.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-CKtz9H1o.js → ResidentsIndexPage-x6p689VH.js} +1 -1
- package/dist/assets/web/dist/assets/RunFoldRow-DG29LOTs.js +1 -0
- package/dist/assets/web/dist/assets/{RunRoutePage-Clh68nw9.js → RunRoutePage-ysJBY8xQ.js} +4 -4
- package/dist/assets/web/dist/assets/RunsIndexPage-CuzFchcn.js +1 -0
- package/dist/assets/web/dist/assets/{RunsTabs-DotpuCSP.js → RunsTabs-kHbbxVHx.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-C8FYoZy5.js → ScheduledPage-BuLmfcbG.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingSelect-CSiBQCSX.js → SettingSelect-BfID5nF4.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-CkYIhLUl.js → SettingsPage-BujWkdU_.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-C0TUgnC1.js → StatusDot-C8Bc0pTX.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-CN5cZdl3.js → Tooltip-BWwJx27K.js} +1 -1
- package/dist/assets/web/dist/assets/{UnitRoutePage-D_EtAl8r.js → UnitRoutePage-B9kjA1AT.js} +1 -1
- package/dist/assets/web/dist/assets/{angular-html-qW40ZL9x.js → angular-html-C8b5YTHf.js} +1 -1
- package/dist/assets/web/dist/assets/{angular-ts-PUtZU8Cq.js → angular-ts-C3f4WYNe.js} +1 -1
- package/dist/assets/web/dist/assets/{apl-D3fC7Qjd.js → apl-DSHxUJc3.js} +1 -1
- package/dist/assets/web/dist/assets/{astro-Bi3JFHhh.js → astro-BSGCCBP6.js} +1 -1
- package/dist/assets/web/dist/assets/{blade-CSpsf9Z9.js → blade-DFeYrMKk.js} +1 -1
- package/dist/assets/web/dist/assets/{c-DE_b1oWP.js → c-L_QOnFVv.js} +1 -1
- package/dist/assets/web/dist/assets/{chapel-CWXd9giL.js → chapel-DLQWUwhu.js} +1 -1
- package/dist/assets/web/dist/assets/{cobol-CnMOmWNF.js → cobol-1DlMmrEO.js} +1 -1
- package/dist/assets/web/dist/assets/{coffee-B9WvURu8.js → coffee-BTB6os6N.js} +1 -1
- package/dist/assets/web/dist/assets/{cpp-BZAJXlWI.js → cpp-BvJI2z3u.js} +1 -1
- package/dist/assets/web/dist/assets/{crystal-Bm48wAlb.js → crystal-B11wGW_h.js} +1 -1
- package/dist/assets/web/dist/assets/{css-WtMeC3ab.js → css-BiIWx5Pw.js} +1 -1
- package/dist/assets/web/dist/assets/{dist-B1Mf2JbM.js → dist-CpnyQGOb.js} +2 -2
- package/dist/assets/web/dist/assets/{edge-BmwNa6Ss.js → edge-nSglEXaD.js} +1 -1
- package/dist/assets/web/dist/assets/{elixir-BotfP-Mn.js → elixir-x7o8qMl8.js} +1 -1
- package/dist/assets/web/dist/assets/{elm-DAFc6laN.js → elm-DGrRyqTQ.js} +1 -1
- package/dist/assets/web/dist/assets/{erb-QkOON3bB.js → erb--_dh58Nw.js} +1 -1
- package/dist/assets/web/dist/assets/{git-rebase-BPs-dd73.js → git-rebase-DbpUXwyg.js} +1 -1
- package/dist/assets/web/dist/assets/{glimmer-js-D8eooepk.js → glimmer-js-BG0CuZG5.js} +1 -1
- package/dist/assets/web/dist/assets/{glimmer-ts-B0bRiDVg.js → glimmer-ts-CQIl5qyR.js} +1 -1
- package/dist/assets/web/dist/assets/{glsl-Bv6bMe1n.js → glsl-9PJNQTcw.js} +1 -1
- package/dist/assets/web/dist/assets/{graphql-Bh9y-n9o.js → graphql-CzVTzjEI.js} +1 -1
- package/dist/assets/web/dist/assets/{hack-DSEbn7Ir.js → hack-BhCmAz3Q.js} +1 -1
- package/dist/assets/web/dist/assets/{haml-0NW1-z1f.js → haml-BmI12COR.js} +1 -1
- package/dist/assets/web/dist/assets/{handlebars-BfgCHFbK.js → handlebars-Cib-ISTS.js} +1 -1
- package/dist/assets/web/dist/assets/{html-CQHc0gQ5.js → html-D3lDapvI.js} +1 -1
- package/dist/assets/web/dist/assets/{html-derivative-B0Tm7wLP.js → html-derivative-DRPY8vOK.js} +1 -1
- package/dist/assets/web/dist/assets/{http-GAuU3aOF.js → http-B9VttK6m.js} +1 -1
- package/dist/assets/web/dist/assets/{hurl-CrDFbF5B.js → hurl-C0saNJYj.js} +1 -1
- package/dist/assets/web/dist/assets/{indexRow-BxKswdlK.js → indexRow-DABQtONT.js} +1 -1
- package/dist/assets/web/dist/assets/{java-DYv5KhUe.js → java-BCFbtYAj.js} +1 -1
- package/dist/assets/web/dist/assets/{javascript-32SiLGg0.js → javascript-nwBjnrhh.js} +1 -1
- package/dist/assets/web/dist/assets/{jinja--gJDUGYa.js → jinja-CMYO9byN.js} +1 -1
- package/dist/assets/web/dist/assets/{jison-BlKLGzZH.js → jison-Cko8HnE_.js} +1 -1
- package/dist/assets/web/dist/assets/{json-BICanD1N.js → json-Dzv__YsX.js} +1 -1
- package/dist/assets/web/dist/assets/{jsx-CgW3-uFT.js → jsx-CTFxagih.js} +1 -1
- package/dist/assets/web/dist/assets/{julia-BErRpwwL.js → julia-_Xvten1-.js} +1 -1
- package/dist/assets/web/dist/assets/{just-CordHv0h.js → just-bLps4qOu.js} +1 -1
- package/dist/assets/web/dist/assets/{latex-D7LVPLbi.js → latex-D6UaYsUP.js} +1 -1
- package/dist/assets/web/dist/assets/{liquid-3ehz2i4U.js → liquid-CoDIeULa.js} +1 -1
- package/dist/assets/web/dist/assets/{lua-C9ppSVrm.js → lua-D1KFRnVf.js} +1 -1
- package/dist/assets/web/dist/assets/main-C4QfwybO.css +1 -0
- package/dist/assets/web/dist/assets/main-Comxmwi4.js +28 -0
- package/dist/assets/web/dist/assets/{marko-xBZLUBEJ.js → marko-B5N5XcOw.js} +1 -1
- package/dist/assets/web/dist/assets/{mdc-BJxGoVgD.js → mdc-uxzjQO3q.js} +1 -1
- package/dist/assets/web/dist/assets/{nginx-B-sZmpsb.js → nginx-BMJxs2TK.js} +1 -1
- package/dist/assets/web/dist/assets/{nim--s9wRABK.js → nim-DqQ3-8p1.js} +1 -1
- package/dist/assets/web/dist/assets/{org-DoUvssoI.js → org-DsY6LK59.js} +1 -1
- package/dist/assets/web/dist/assets/{perl-B-vGHKOy.js → perl--pq9iJFp.js} +1 -1
- package/dist/assets/web/dist/assets/{php-CerWx_Xb.js → php-RHCMbsM2.js} +1 -1
- package/dist/assets/web/dist/assets/{pug-CzKrBMb1.js → pug-CFNq_FCp.js} +1 -1
- package/dist/assets/web/dist/assets/{qml-Bs8L8I6k.js → qml-C9Acx9E8.js} +1 -1
- package/dist/assets/web/dist/assets/{r-w6uG6mob.js → r-DxlU8FBd.js} +1 -1
- package/dist/assets/web/dist/assets/{razor-dz2oj2bd.js → razor-CLJ2kKih.js} +1 -1
- package/dist/assets/web/dist/assets/{regexp-CfQdprnY.js → regexp-CzmdGoEp.js} +1 -1
- package/dist/assets/web/dist/assets/{rst-DAOaX2tb.js → rst-D9yuxBET.js} +1 -1
- package/dist/assets/web/dist/assets/{ruby-QcCe4udB.js → ruby-CIRcEvnD.js} +1 -1
- package/dist/assets/web/dist/assets/{sas-BrM4lAAc.js → sas-LOl-uUPh.js} +1 -1
- package/dist/assets/web/dist/assets/{scss-DVbJXoUb.js → scss-BX2_sZHY.js} +1 -1
- package/dist/assets/web/dist/assets/{shellscript-09fP2hZf.js → shellscript-4d-QvKkY.js} +1 -1
- package/dist/assets/web/dist/assets/{shellsession-Qgr8xjZF.js → shellsession-DhB16UGy.js} +1 -1
- package/dist/assets/web/dist/assets/{soy-DIYeNTuz.js → soy-Cm1xTUBr.js} +1 -1
- package/dist/assets/web/dist/assets/{sql-CzLR9msV.js → sql-BsfcBJdu.js} +1 -1
- package/dist/assets/web/dist/assets/{sseReplay-BYo2EZKy.js → sseReplay-IzTdD4-3.js} +6 -6
- package/dist/assets/web/dist/assets/{stata-zCY1ZzH0.js → stata-Dwat3BSw.js} +1 -1
- package/dist/assets/web/dist/assets/{surrealql-CJIjTRV4.js → surrealql-P4rtxzAW.js} +1 -1
- package/dist/assets/web/dist/assets/{svelte-C5OTPFZg.js → svelte-DTcsi8S8.js} +1 -1
- package/dist/assets/web/dist/assets/{templ-DYj-UICF.js → templ-D-kP0Tg5.js} +1 -1
- package/dist/assets/web/dist/assets/{tex-xcie-3Gd.js → tex-D-4vuedK.js} +1 -1
- package/dist/assets/web/dist/assets/{ts-tags-CoN86VTt.js → ts-tags-jEEWYxsu.js} +1 -1
- package/dist/assets/web/dist/assets/{tsx-n3-No_Br.js → tsx-CFnlrVzT.js} +1 -1
- package/dist/assets/web/dist/assets/{twig-DPiJ7Vp1.js → twig-D5rGrPjs.js} +1 -1
- package/dist/assets/web/dist/assets/{typescript-BP6HBaOp.js → typescript-nYIQ35or.js} +1 -1
- package/dist/assets/web/dist/assets/{typst-Do9ZjsVP.js → typst-3-UX8dna.js} +1 -1
- package/dist/assets/web/dist/assets/{vue-CzAeXNRY.js → vue-CLIPIX03.js} +1 -1
- package/dist/assets/web/dist/assets/{vue-html-D7iYmYjd.js → vue-html-Ci9CpkHU.js} +1 -1
- package/dist/assets/web/dist/assets/{vue-vine-DtrMRsJx.js → vue-vine-DL5pEYYk.js} +1 -1
- package/dist/assets/web/dist/assets/{xml-DGJHo1c0.js → xml-bO80qikC.js} +1 -1
- package/dist/assets/web/dist/assets/{xsl-Q00kvNgW.js → xsl-BPze60Ej.js} +1 -1
- package/dist/assets/web/dist/assets/{yaml-F-UGKaiO.js → yaml-BQUSon_o.js} +1 -1
- package/dist/cli.js +36177 -34396
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/CostsPage-CxTKdpzU.js +0 -2
- package/dist/assets/web/dist/assets/PendingTurnRow-C-pUgyyX.js +0 -1
- package/dist/assets/web/dist/assets/RunFoldRow-sYFtuK2G.js +0 -1
- package/dist/assets/web/dist/assets/RunsIndexPage-DPDbcOA_.js +0 -1
- package/dist/assets/web/dist/assets/main-Cv0q9y56.js +0 -28
- package/dist/assets/web/dist/assets/main-DKFCM-sy.css +0 -1
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
type AnthropicTokens,
|
|
5
5
|
type ModelPriceTable,
|
|
6
6
|
} from "./modelPricing.js";
|
|
7
|
+
import { parseModelRef } from "./provider.js";
|
|
8
|
+
import type { RunUsage } from "./runUsage.js";
|
|
7
9
|
import { systemClock } from "./trace/clock.js";
|
|
8
10
|
// Spend report: what a group of deployed pieces ("switchboard" = the bot
|
|
9
11
|
// Worker + its containers, the resident/sandbox/memory Workers) costs per day,
|
|
@@ -511,6 +513,9 @@ export interface CostReport {
|
|
|
511
513
|
/** The snapshot the report was built from (src/core/costsSnapshot.ts): when it was
|
|
512
514
|
* taken, by whom, how long the read took. Absent on a report built straight from the sources. */
|
|
513
515
|
snapshot?: { takenAt: string; takenBy: string; durationMs: number };
|
|
516
|
+
/** Each biller's daily invoice tie-out (item 4d); absent when no provider block is known
|
|
517
|
+
* (a report built without the wiring) or run history is off. */
|
|
518
|
+
billers?: BillerTieOut[];
|
|
514
519
|
}
|
|
515
520
|
|
|
516
521
|
/** What the report carries beyond the priced rows: the account behind the
|
|
@@ -729,6 +734,208 @@ export interface LlmCostSource {
|
|
|
729
734
|
fetchDailyCost(range: DateRange): Promise<LlmCostRow[] | null>;
|
|
730
735
|
}
|
|
731
736
|
|
|
737
|
+
// ---- invoices per biller (costs.md item 4d) ---------------------------------------------
|
|
738
|
+
|
|
739
|
+
/** One day of a biller's invoice, as its own billing API reports it. */
|
|
740
|
+
export interface InvoiceDay {
|
|
741
|
+
date: string;
|
|
742
|
+
/** The biller's invoiced dollars for the day (an aggregator's fee + BYOK upstream). */
|
|
743
|
+
usd: number;
|
|
744
|
+
/** An aggregator's own charge (OpenRouter's `usage`) — what the summed `feeUsd` ties against. */
|
|
745
|
+
feeUsd?: number;
|
|
746
|
+
/** An aggregator's BYOK upstream half (`byok_usage_inference`) — what the remainder ties against. */
|
|
747
|
+
byokUsd?: number;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** One invoice source per provider block that names an invoice API and an
|
|
751
|
+
* `invoiceKeyEnv`: the biller's own billing figures, per UTC day. */
|
|
752
|
+
export interface LlmInvoiceSource {
|
|
753
|
+
/** The provider block this invoice bills — the `biller` its `model.turn` rows carry. */
|
|
754
|
+
readonly biller: string;
|
|
755
|
+
fetchInvoice(range: DateRange): Promise<InvoiceDay[]>;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** One day of a biller's tie-out: its invoice beside the summed `model.turn` rows. */
|
|
759
|
+
export interface BillerTieOutDay {
|
|
760
|
+
date: string;
|
|
761
|
+
/** The biller's invoiced dollars; absent when the block names no source. */
|
|
762
|
+
invoiceUsd?: number;
|
|
763
|
+
invoiceFeeUsd?: number;
|
|
764
|
+
invoiceByokUsd?: number;
|
|
765
|
+
/** The summed `model.turn` dollars whose ref names this block. */
|
|
766
|
+
attributedUsd: number;
|
|
767
|
+
/** The summed BYOK fees — the half an aggregator's `usage` ties against. */
|
|
768
|
+
attributedFeeUsd?: number;
|
|
769
|
+
/** `attributedUsd − attributedFeeUsd` — the half `byok_usage_inference` ties against. */
|
|
770
|
+
attributedUpstreamUsd?: number;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** One biller's daily tie-out over the range. */
|
|
774
|
+
export interface BillerTieOut {
|
|
775
|
+
biller: string;
|
|
776
|
+
/** false = the block names no invoice source: it ties out against nothing and the page says "no invoice". */
|
|
777
|
+
invoice: boolean;
|
|
778
|
+
days: BillerTieOutDay[];
|
|
779
|
+
totals: { invoiceUsd: number; attributedUsd: number };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** The block a ref bills to, or undefined for a ref that names none (`unknown`). */
|
|
783
|
+
const billerOfRef = (ref: string): string | undefined => (ref.includes("/") ? parseModelRef(ref).provider : undefined);
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Pure: each biller's invoice compared with the summed `model.turn` rows whose
|
|
787
|
+
* ref names that block, per UTC day of the range. An aggregator's invoice
|
|
788
|
+
* halves (`feeUsd`, `byokUsd`) sit beside the summed fees and the remainder. A
|
|
789
|
+
* model whose `usd` is null or absent (unpriced, or from before the meter row)
|
|
790
|
+
* contributes nothing to `attributedUsd` — an understated side is honest, an
|
|
791
|
+
* invented one is not, so a day the source returned no row for keeps its
|
|
792
|
+
* invoice side absent (the page renders —): the source holds closed days only,
|
|
793
|
+
* and a trailing uninvoiced day beside real spend is not a $0 invoice. A day
|
|
794
|
+
* with neither an invoice row nor a nonzero attributed figure is dropped; a
|
|
795
|
+
* biller without a source (`invoice: false`) ties out against nothing.
|
|
796
|
+
*/
|
|
797
|
+
export function buildBillerTieOuts(
|
|
798
|
+
billers: ReadonlyArray<{ name: string; invoice: boolean }>,
|
|
799
|
+
invoices: Readonly<Record<string, InvoiceDay[]>>,
|
|
800
|
+
cells: ReadonlyArray<{ day: string; usage: RunUsage }>,
|
|
801
|
+
range: DateRange,
|
|
802
|
+
): BillerTieOut[] {
|
|
803
|
+
return billers.map(({ name, invoice }) => {
|
|
804
|
+
const attributed = new Map<string, { usd: number; fee: number; hasFee: boolean }>();
|
|
805
|
+
for (const cell of cells) {
|
|
806
|
+
if (cell.day < range.from || cell.day > range.to) continue;
|
|
807
|
+
for (const [ref, m] of Object.entries(cell.usage.byModel)) {
|
|
808
|
+
if (billerOfRef(ref) !== name) continue;
|
|
809
|
+
const day = attributed.get(cell.day) ?? { usd: 0, fee: 0, hasFee: false };
|
|
810
|
+
if (typeof m.usd === "number") day.usd += m.usd;
|
|
811
|
+
if (m.feeUsd !== undefined) {
|
|
812
|
+
day.fee += m.feeUsd;
|
|
813
|
+
day.hasFee = true;
|
|
814
|
+
}
|
|
815
|
+
attributed.set(cell.day, day);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
const invoiced = new Map<string, InvoiceDay>();
|
|
819
|
+
if (invoice)
|
|
820
|
+
for (const d of invoices[name] ?? []) if (d.date >= range.from && d.date <= range.to) invoiced.set(d.date, d);
|
|
821
|
+
const dates = [...new Set([...attributed.keys(), ...invoiced.keys()])].sort().filter((date) => {
|
|
822
|
+
const a = attributed.get(date);
|
|
823
|
+
return invoiced.has(date) || (a !== undefined && (a.usd !== 0 || a.hasFee));
|
|
824
|
+
});
|
|
825
|
+
const days: BillerTieOutDay[] = dates.map((date) => {
|
|
826
|
+
const a = attributed.get(date);
|
|
827
|
+
const inv = invoiced.get(date);
|
|
828
|
+
return {
|
|
829
|
+
date,
|
|
830
|
+
...(inv ? { invoiceUsd: inv.usd } : {}),
|
|
831
|
+
...(inv?.feeUsd !== undefined ? { invoiceFeeUsd: inv.feeUsd } : {}),
|
|
832
|
+
...(inv?.byokUsd !== undefined ? { invoiceByokUsd: inv.byokUsd } : {}),
|
|
833
|
+
attributedUsd: a?.usd ?? 0,
|
|
834
|
+
...(a?.hasFee ? { attributedFeeUsd: a.fee, attributedUpstreamUsd: a.usd - a.fee } : {}),
|
|
835
|
+
};
|
|
836
|
+
});
|
|
837
|
+
return {
|
|
838
|
+
biller: name,
|
|
839
|
+
invoice,
|
|
840
|
+
days,
|
|
841
|
+
totals: {
|
|
842
|
+
invoiceUsd: days.reduce((s, d) => s + (d.invoiceUsd ?? 0), 0),
|
|
843
|
+
attributedUsd: days.reduce((s, d) => s + d.attributedUsd, 0),
|
|
844
|
+
},
|
|
845
|
+
};
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** OpenRouter's activity endpoint (`GET /api/v1/activity`, a management key —
|
|
850
|
+
* never the inference key): per-day, per-model rows whose `usage` is the
|
|
851
|
+
* aggregator's own charge and `byok_usage_inference` the BYOK upstream cost.
|
|
852
|
+
* Summed per day; the key travels only in the Authorization header and never
|
|
853
|
+
* appears in errors. The endpoint holds roughly the last 30 days, one short of
|
|
854
|
+
* the 31-day snapshot range: the oldest window day may come back with no row,
|
|
855
|
+
* and the tie-out then leaves that day's invoice side absent (rendered —),
|
|
856
|
+
* never an invented $0. */
|
|
857
|
+
export const OPENROUTER_ACTIVITY_URL = "https://openrouter.ai/api/v1/activity";
|
|
858
|
+
export class OpenRouterActivitySource implements LlmInvoiceSource {
|
|
859
|
+
readonly biller: string;
|
|
860
|
+
private readonly key: string;
|
|
861
|
+
private readonly fetchImpl: typeof fetch;
|
|
862
|
+
constructor(opts: { biller: string; managementKey: string; fetchImpl?: typeof fetch }) {
|
|
863
|
+
this.biller = opts.biller;
|
|
864
|
+
this.key = opts.managementKey;
|
|
865
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
async fetchInvoice(range: DateRange): Promise<InvoiceDay[]> {
|
|
869
|
+
const res = await this.fetchImpl(OPENROUTER_ACTIVITY_URL, {
|
|
870
|
+
headers: { authorization: `Bearer ${this.key}` },
|
|
871
|
+
});
|
|
872
|
+
if (res.status !== 200) throw new Error(`openrouter activity ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
873
|
+
const body = (await res.json()) as {
|
|
874
|
+
data?: { date?: string; usage?: number; byok_usage_inference?: number }[];
|
|
875
|
+
};
|
|
876
|
+
const byDay = new Map<string, { fee: number; byok: number }>();
|
|
877
|
+
for (const r of body.data ?? []) {
|
|
878
|
+
const date = str(r.date).slice(0, 10);
|
|
879
|
+
if (!date || date < range.from || date > range.to) continue;
|
|
880
|
+
const d = byDay.get(date) ?? { fee: 0, byok: 0 };
|
|
881
|
+
d.fee += num(r.usage);
|
|
882
|
+
d.byok += num(r.byok_usage_inference);
|
|
883
|
+
byDay.set(date, d);
|
|
884
|
+
}
|
|
885
|
+
return [...byDay]
|
|
886
|
+
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
887
|
+
.map(([date, d]) => ({ date, usd: d.fee + d.byok, feeUsd: d.fee, byokUsd: d.byok }));
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** OpenAI's organization costs (`GET /v1/organization/costs`, an admin key):
|
|
892
|
+
* daily buckets whose results carry `amount.value` dollars. Paginated like the
|
|
893
|
+
* Anthropic report, a non-USD row refused the same way, the key never in errors. */
|
|
894
|
+
export const OPENAI_COSTS_URL = "https://api.openai.com/v1/organization/costs";
|
|
895
|
+
export class OpenAICostsSource implements LlmInvoiceSource {
|
|
896
|
+
readonly biller: string;
|
|
897
|
+
private readonly key: string;
|
|
898
|
+
private readonly fetchImpl: typeof fetch;
|
|
899
|
+
constructor(opts: { biller: string; adminKey: string; fetchImpl?: typeof fetch }) {
|
|
900
|
+
this.biller = opts.biller;
|
|
901
|
+
this.key = opts.adminKey;
|
|
902
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async fetchInvoice(range: DateRange): Promise<InvoiceDay[]> {
|
|
906
|
+
const byDay = new Map<string, number>();
|
|
907
|
+
let page: string | null = null;
|
|
908
|
+
for (let i = 0; ; i++) {
|
|
909
|
+
if (i >= MAX_COST_PAGES)
|
|
910
|
+
throw new Error(`openai organization costs: more than ${MAX_COST_PAGES} pages; refusing a truncated total`);
|
|
911
|
+
const url = new URL(OPENAI_COSTS_URL);
|
|
912
|
+
url.searchParams.set("start_time", String(Date.parse(`${range.from}T00:00:00Z`) / 1000));
|
|
913
|
+
url.searchParams.set("end_time", String(Date.parse(`${addDays(range.to, 1)}T00:00:00Z`) / 1000));
|
|
914
|
+
url.searchParams.set("limit", String(MAX_DAYS));
|
|
915
|
+
if (page) url.searchParams.set("page", page);
|
|
916
|
+
const res = await this.fetchImpl(url.toString(), { headers: { authorization: `Bearer ${this.key}` } });
|
|
917
|
+
if (res.status !== 200)
|
|
918
|
+
throw new Error(`openai organization costs ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
919
|
+
const body = (await res.json()) as {
|
|
920
|
+
data?: { start_time?: number; results?: { amount?: { value?: number; currency?: string } }[] }[];
|
|
921
|
+
has_more?: boolean;
|
|
922
|
+
next_page?: string | null;
|
|
923
|
+
};
|
|
924
|
+
for (const bucket of body.data ?? []) {
|
|
925
|
+
const date = new Date(num(bucket.start_time) * 1000).toISOString().slice(0, 10);
|
|
926
|
+
for (const r of bucket.results ?? []) {
|
|
927
|
+
const currency = str(r.amount?.currency).toLowerCase();
|
|
928
|
+
if (currency !== "usd") throw new Error(`openai organization costs: unexpected currency ${currency || "?"}`);
|
|
929
|
+
byDay.set(date, (byDay.get(date) ?? 0) + num(r.amount?.value));
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
if (!body.has_more || !body.next_page) break;
|
|
933
|
+
page = body.next_page;
|
|
934
|
+
}
|
|
935
|
+
return [...byDay].sort(([a], [b]) => (a < b ? -1 : 1)).map(([date, usd]) => ({ date, usd }));
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
732
939
|
export class NullLlmCostSource implements LlmCostSource {
|
|
733
940
|
fetchDailyCost(_range: DateRange): Promise<LlmCostRow[] | null> {
|
|
734
941
|
return Promise.resolve(null);
|
|
@@ -917,14 +1124,26 @@ type UsageReportBody = {
|
|
|
917
1124
|
* `estimated` — the page can then show today, and a 1-day range means
|
|
918
1125
|
* something. Read live when this was written: fourteen hourly buckets for the
|
|
919
1126
|
* open day, none at `1d`; the cost report's last bucket was the day before. */
|
|
920
|
-
export class AnthropicCostReportSource implements LlmCostSource {
|
|
1127
|
+
export class AnthropicCostReportSource implements LlmCostSource, LlmInvoiceSource {
|
|
1128
|
+
/** The provider block this cost report bills; `anthropic` unless a block renames it. */
|
|
1129
|
+
readonly biller: string;
|
|
921
1130
|
private readonly adminKey: string;
|
|
922
1131
|
private readonly fetchImpl: typeof fetch;
|
|
923
|
-
constructor(opts: { adminKey: string; fetchImpl?: typeof fetch }) {
|
|
1132
|
+
constructor(opts: { adminKey: string; biller?: string; fetchImpl?: typeof fetch }) {
|
|
1133
|
+
this.biller = opts.biller ?? "anthropic";
|
|
924
1134
|
this.adminKey = opts.adminKey;
|
|
925
1135
|
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
926
1136
|
}
|
|
927
1137
|
|
|
1138
|
+
/** The biller's invoice per day: the cost report's closed days summed across
|
|
1139
|
+
* workspaces — the invoice, never the estimate (costs.md item 4d). */
|
|
1140
|
+
async fetchInvoice(range: DateRange): Promise<InvoiceDay[]> {
|
|
1141
|
+
const report = await this.fetchCostReport(range);
|
|
1142
|
+
const byDay = new Map<string, number>();
|
|
1143
|
+
for (const r of report.rows) byDay.set(r.date, (byDay.get(r.date) ?? 0) + r.amountUsd);
|
|
1144
|
+
return [...byDay].sort(([a], [b]) => (a < b ? -1 : 1)).map(([date, usd]) => ({ date, usd }));
|
|
1145
|
+
}
|
|
1146
|
+
|
|
928
1147
|
async fetchDailyCost(range: DateRange): Promise<LlmCostRow[]> {
|
|
929
1148
|
const report = await this.fetchCostReport(range);
|
|
930
1149
|
// A bucket with no results is still a closed day: track buckets, not rows.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CloudflareUsage, DateRange, LlmCostRow } from "./costs.js";
|
|
1
|
+
import type { CloudflareUsage, DateRange, InvoiceDay, LlmCostRow } from "./costs.js";
|
|
2
2
|
import { isRunUsageReport, type RunUsageReport } from "./runUsage.js";
|
|
3
3
|
import {
|
|
4
4
|
DEFAULT_STATE_WORKER_TOKEN_ENV,
|
|
@@ -46,6 +46,9 @@ export interface CostsSnapshot {
|
|
|
46
46
|
usage: CloudflareUsage;
|
|
47
47
|
/** Anthropic's daily cost rows (the open days estimated); null when no LLM source is configured. */
|
|
48
48
|
llm: LlmCostRow[] | null;
|
|
49
|
+
/** Each configured biller's invoice days (costs.md item 4d); a biller whose source failed
|
|
50
|
+
* is omitted (warned by name), and the field is absent on a snapshot from before it. */
|
|
51
|
+
invoices?: Record<string, InvoiceDay[]>;
|
|
49
52
|
/** The run history's per-user usage over the window; null with run history off. */
|
|
50
53
|
runUsage: RunUsageReport | null;
|
|
51
54
|
}
|
|
@@ -102,6 +105,20 @@ function isLlmRows(v: unknown): v is LlmCostRow[] {
|
|
|
102
105
|
);
|
|
103
106
|
}
|
|
104
107
|
|
|
108
|
+
function isInvoiceDays(v: unknown): v is InvoiceDay[] {
|
|
109
|
+
return (
|
|
110
|
+
Array.isArray(v) &&
|
|
111
|
+
v.every((row) => {
|
|
112
|
+
if (typeof row !== "object" || row === null) return false;
|
|
113
|
+
const r = row as Record<string, unknown>;
|
|
114
|
+
if (typeof r.date !== "string" || !ISO_DAY.test(r.date) || !isFiniteNumber(r.usd)) return false;
|
|
115
|
+
return (
|
|
116
|
+
(r.feeUsd === undefined || isFiniteNumber(r.feeUsd)) && (r.byokUsd === undefined || isFiniteNumber(r.byokUsd))
|
|
117
|
+
);
|
|
118
|
+
})
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
105
122
|
/** The fields the arithmetic reads, present and of the right kind; the rows are the sources'. */
|
|
106
123
|
export function isCostsSnapshot(v: unknown): v is CostsSnapshot {
|
|
107
124
|
if (typeof v !== "object" || v === null) return false;
|
|
@@ -113,6 +130,10 @@ export function isCostsSnapshot(v: unknown): v is CostsSnapshot {
|
|
|
113
130
|
if (typeof usage !== "object" || usage === null) return false;
|
|
114
131
|
if (!USAGE_DATASETS.every((name) => isDatedRows(usage[name]))) return false;
|
|
115
132
|
if (s.llm !== null && !isLlmRows(s.llm)) return false;
|
|
133
|
+
if (s.invoices !== undefined) {
|
|
134
|
+
if (typeof s.invoices !== "object" || s.invoices === null || Array.isArray(s.invoices)) return false;
|
|
135
|
+
if (!Object.values(s.invoices).every(isInvoiceDays)) return false;
|
|
136
|
+
}
|
|
116
137
|
return s.runUsage === null || isRunUsageReport(s.runUsage);
|
|
117
138
|
}
|
|
118
139
|
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import type { RunEvent, ShipRoundOutcome } from "./runEvents.js";
|
|
2
|
+
import type { UnitEnding } from "./ship/coordinator.js";
|
|
3
|
+
|
|
4
|
+
// The standing fold (record 0065): one pure, node-free reader of a hosted ship
|
|
5
|
+
// parent's `ship_round` and `ship_unit` events, yielding the pipeline's
|
|
6
|
+
// standing — per unit a stage from a closed vocabulary, its latest round, its
|
|
7
|
+
// pull request and the stamp that set the stage. The registry
|
|
8
|
+
// (`appendToBacklog`), the ledger view (`runsService.ts`) and the record
|
|
9
|
+
// assembly (`dispatch/record.ts`) all call it, so the three row sources can
|
|
10
|
+
// never disagree. Total over any event list; it never throws.
|
|
11
|
+
|
|
12
|
+
/** The stage vocabulary (record 0065): what every surface says a unit is in. */
|
|
13
|
+
export type Stage = "coding" | "review" | "fix" | "approved" | "merge-ready" | "merged" | "idle" | "ended";
|
|
14
|
+
|
|
15
|
+
export const STAGES = [
|
|
16
|
+
"coding",
|
|
17
|
+
"review",
|
|
18
|
+
"fix",
|
|
19
|
+
"approved",
|
|
20
|
+
"merge-ready",
|
|
21
|
+
"merged",
|
|
22
|
+
"idle",
|
|
23
|
+
"ended",
|
|
24
|
+
] as const satisfies readonly Stage[];
|
|
25
|
+
|
|
26
|
+
/** The stages a unit is still open in: what `PipelineSummary.current` lists. */
|
|
27
|
+
const OPEN_STAGES: ReadonlySet<Stage> = new Set(["coding", "review", "fix", "approved", "idle"]);
|
|
28
|
+
|
|
29
|
+
/** One unit's standing: the stage, the latest round index bound to it, the
|
|
30
|
+
* segment count (raised by a `continued` round and by a reopen from idle),
|
|
31
|
+
* when the stage was set, and the latest pull request and thread seen. A
|
|
32
|
+
* closed unit's `detail` names the ending kind the vocabulary folds to
|
|
33
|
+
* `ended`. */
|
|
34
|
+
export interface PipelineUnit {
|
|
35
|
+
unit: string;
|
|
36
|
+
stage: Stage;
|
|
37
|
+
round: number;
|
|
38
|
+
segment: number;
|
|
39
|
+
since: number;
|
|
40
|
+
pr?: number;
|
|
41
|
+
threadKey?: string;
|
|
42
|
+
detail?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A round of an unnamed unit: a `ship_round` with no companion `ship_unit`
|
|
46
|
+
* at its stamp — the shape of records written before `ship_unit` existed. */
|
|
47
|
+
export interface RoundRow {
|
|
48
|
+
index: number;
|
|
49
|
+
agent: string;
|
|
50
|
+
outcome: string;
|
|
51
|
+
at: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One stage change the fold made — the pipeline page's log row. */
|
|
55
|
+
export interface StandingChange {
|
|
56
|
+
at: number;
|
|
57
|
+
unit: string;
|
|
58
|
+
stage: Stage;
|
|
59
|
+
round?: number;
|
|
60
|
+
detail?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** What `pipelineStandingOf` returns: the units in order of first appearance,
|
|
64
|
+
* the rounds no unit names, and the changes in event order. The plan's unit
|
|
65
|
+
* total is not on the parent's stream and is not returned. */
|
|
66
|
+
export interface PipelineStanding {
|
|
67
|
+
units: PipelineUnit[];
|
|
68
|
+
unnamedRounds: RoundRow[];
|
|
69
|
+
changes: StandingChange[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The standing as the row sources carry it (`RunSummary.pipeline`,
|
|
73
|
+
* `RunView.pipeline`, `RunRecord.pipeline`): the open units whole, a count
|
|
74
|
+
* per stage, the units seen — and, for an older record whose rounds name no
|
|
75
|
+
* unit, the newest unnamed round, so its row can still say
|
|
76
|
+
* `round 2 · review` (record 0065, the pipeline row). */
|
|
77
|
+
export interface PipelineSummary {
|
|
78
|
+
current: PipelineUnit[];
|
|
79
|
+
counts: Record<Stage, number>;
|
|
80
|
+
total: number;
|
|
81
|
+
lastRound?: RoundRow;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** What a bound round does to its unit's stage, by outcome (record 0065's
|
|
85
|
+
* mapping). `start` reads the agent and the index (`coding` at round 0, `fix`
|
|
86
|
+
* at any higher index, `review` for the review agent); `continue` is `coding`
|
|
87
|
+
* with the segment count raised; `hold` leaves the stage alone — the ending
|
|
88
|
+
* will say. Pinned to the union: a new outcome fails the build here. */
|
|
89
|
+
export const ROUND_STAGE = {
|
|
90
|
+
started: "start",
|
|
91
|
+
pr_opened: "review",
|
|
92
|
+
completed: "merged",
|
|
93
|
+
approve: "approved",
|
|
94
|
+
request_changes: "fix",
|
|
95
|
+
no_verdict: "hold",
|
|
96
|
+
checks_failed: "fix",
|
|
97
|
+
aborted: "hold",
|
|
98
|
+
stopped: "hold",
|
|
99
|
+
continued: "continue",
|
|
100
|
+
idle: "idle",
|
|
101
|
+
} as const satisfies Record<ShipRoundOutcome, Stage | "start" | "continue" | "hold">;
|
|
102
|
+
|
|
103
|
+
/** What an unbound `ship_unit` ending word does to its unit (record 0065):
|
|
104
|
+
* `merged` and `already_landed` fold to `merged`, `merge_ready` to
|
|
105
|
+
* `merge-ready`, `idle` stays open as `idle`, `continued` is not an ending
|
|
106
|
+
* and holds the stage, and every other kind closes the unit as `ended` with
|
|
107
|
+
* the kind as detail. `blocked` — a unit status the driver posts to
|
|
108
|
+
* `unit-end` for a unit that never started — ends the same way. Pinned to
|
|
109
|
+
* the union: a new ending kind fails the build here. */
|
|
110
|
+
export const ENDING_STAGE = {
|
|
111
|
+
merged: "merged",
|
|
112
|
+
already_landed: "merged",
|
|
113
|
+
merge_ready: "merge-ready",
|
|
114
|
+
merge_refused: "ended",
|
|
115
|
+
round_cap: "ended",
|
|
116
|
+
wall_clock_cap: "ended",
|
|
117
|
+
review_pending: "ended",
|
|
118
|
+
stopped: "ended",
|
|
119
|
+
aborted: "ended",
|
|
120
|
+
continued: "hold",
|
|
121
|
+
no_verdict: "ended",
|
|
122
|
+
interrupted: "ended",
|
|
123
|
+
refused: "ended",
|
|
124
|
+
idle: "idle",
|
|
125
|
+
blocked: "ended",
|
|
126
|
+
} as const satisfies Record<UnitEnding["kind"] | "blocked", Stage | "hold">;
|
|
127
|
+
|
|
128
|
+
type ShipEvent = Extract<RunEvent, { type: "ship_round" } | { type: "ship_unit" }>;
|
|
129
|
+
|
|
130
|
+
function isShipEvent(e: RunEvent): e is ShipEvent {
|
|
131
|
+
return e.type === "ship_round" || e.type === "ship_unit";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Fold a run's events into the pipeline's standing. One pass, `seq` order
|
|
135
|
+
* assumed (every carrier's events are). A `ship_round` binds to the
|
|
136
|
+
* `ship_unit` published next at the same stamp — the round route's own batch
|
|
137
|
+
* contract — and that companion contributes only its pull request and thread
|
|
138
|
+
* key, never a stage; a round with no companion is a round of an unnamed
|
|
139
|
+
* unit. Total: an unknown word holds the stage, and the fold never throws. */
|
|
140
|
+
export function pipelineStandingOf(events: readonly RunEvent[]): PipelineStanding {
|
|
141
|
+
const ship = events.filter(isShipEvent);
|
|
142
|
+
const units = new Map<string, PipelineUnit>();
|
|
143
|
+
const unnamedRounds: RoundRow[] = [];
|
|
144
|
+
const changes: StandingChange[] = [];
|
|
145
|
+
|
|
146
|
+
const setStage = (u: PipelineUnit, stage: Stage, at: number, detail?: string): void => {
|
|
147
|
+
if (u.stage === stage && u.detail === detail) return;
|
|
148
|
+
u.stage = stage;
|
|
149
|
+
u.since = at;
|
|
150
|
+
if (detail !== undefined) u.detail = detail;
|
|
151
|
+
else delete u.detail;
|
|
152
|
+
changes.push({ at, unit: u.unit, stage, round: u.round, ...(detail !== undefined ? { detail } : {}) });
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** The unit's row, created at first sight: at `coding` for a plain entry, or
|
|
156
|
+
* already at a bound round's stage for a unit adopted mid-pipeline (a
|
|
157
|
+
* resume at review skips round 0 — its first bound round says so). */
|
|
158
|
+
const enter = (name: string, at: number, stage: Stage, round: number, detail?: string): PipelineUnit => {
|
|
159
|
+
const existing = units.get(name);
|
|
160
|
+
if (existing) return existing;
|
|
161
|
+
const u: PipelineUnit = { unit: name, stage, round, segment: 1, since: at };
|
|
162
|
+
if (detail !== undefined) u.detail = detail;
|
|
163
|
+
units.set(name, u);
|
|
164
|
+
changes.push({ at, unit: name, stage, round, ...(detail !== undefined ? { detail } : {}) });
|
|
165
|
+
return u;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const takeFacts = (u: PipelineUnit, e: Extract<ShipEvent, { type: "ship_unit" }>): void => {
|
|
169
|
+
if (e.pr !== undefined) u.pr = e.pr;
|
|
170
|
+
if (e.threadKey !== undefined) u.threadKey = e.threadKey;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const consumed = new Set<number>();
|
|
174
|
+
for (let i = 0; i < ship.length; i++) {
|
|
175
|
+
if (consumed.has(i)) continue;
|
|
176
|
+
const e = ship[i]!;
|
|
177
|
+
const at = e.at ?? 0;
|
|
178
|
+
if (e.type === "ship_round") {
|
|
179
|
+
const next = ship[i + 1];
|
|
180
|
+
const companion = next !== undefined && next.type === "ship_unit" && (next.at ?? 0) === at ? next : undefined;
|
|
181
|
+
if (companion === undefined) {
|
|
182
|
+
unnamedRounds.push({ index: e.index, agent: e.agent, outcome: e.outcome, at });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
consumed.add(i + 1);
|
|
186
|
+
const mapped = (ROUND_STAGE as Record<string, Stage | "start" | "continue" | "hold">)[e.outcome] ?? "hold";
|
|
187
|
+
const stage: Stage =
|
|
188
|
+
mapped === "start"
|
|
189
|
+
? e.agent === "review"
|
|
190
|
+
? "review"
|
|
191
|
+
: e.index >= 1
|
|
192
|
+
? "fix"
|
|
193
|
+
: "coding"
|
|
194
|
+
: mapped === "continue"
|
|
195
|
+
? "coding"
|
|
196
|
+
: mapped === "hold"
|
|
197
|
+
? "coding" // a new unit's first word the mapping holds on: it is in play, and coding is the entry stage
|
|
198
|
+
: mapped;
|
|
199
|
+
const known = units.has(companion.unit);
|
|
200
|
+
const u = enter(companion.unit, at, stage, e.index);
|
|
201
|
+
takeFacts(u, companion); // the companion's pull request and thread only — never its state (record 0065)
|
|
202
|
+
u.round = e.index;
|
|
203
|
+
if (mapped === "continue" && known) u.segment += 1;
|
|
204
|
+
if (mapped !== "hold") setStage(u, stage, at);
|
|
205
|
+
} else {
|
|
206
|
+
const state = e.state;
|
|
207
|
+
if (state === "started") {
|
|
208
|
+
const existing = units.get(e.unit);
|
|
209
|
+
if (existing === undefined) {
|
|
210
|
+
const u = enter(e.unit, at, "coding", 0);
|
|
211
|
+
takeFacts(u, e);
|
|
212
|
+
} else {
|
|
213
|
+
// An idle or closed unit reopens at its next `started` — the next
|
|
214
|
+
// segment; a started while the unit is open holds where it stands.
|
|
215
|
+
if (existing.stage === "idle" || existing.stage === "ended") {
|
|
216
|
+
existing.segment += 1;
|
|
217
|
+
setStage(existing, "coding", at);
|
|
218
|
+
}
|
|
219
|
+
takeFacts(existing, e);
|
|
220
|
+
}
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const mapped = (ENDING_STAGE as Record<string, Stage | "hold">)[state];
|
|
224
|
+
const u =
|
|
225
|
+
units.get(e.unit) ??
|
|
226
|
+
// An ending for a unit that never started (`blocked` with no prior,
|
|
227
|
+
// or a route that skipped `unit-start`): the row still enters.
|
|
228
|
+
enter(e.unit, at, mapped !== undefined && mapped !== "hold" ? mapped : "coding", 0, endedDetail(state, mapped));
|
|
229
|
+
takeFacts(u, e);
|
|
230
|
+
// A round-outcome word on an unbound `ship_unit` (a route that skipped
|
|
231
|
+
// its round) and any word the fold does not know hold the stage.
|
|
232
|
+
if (mapped !== undefined && mapped !== "hold") setStage(u, mapped, at, endedDetail(state, mapped));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { units: [...units.values()], unnamedRounds, changes };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function endedDetail(state: string, mapped: Stage | "hold" | undefined): string | undefined {
|
|
239
|
+
return mapped === "ended" ? state : undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The standing as the three row sources carry it: the open units whole, the
|
|
243
|
+
* counts per stage (zero-filled — the shape is stable), the units seen, and
|
|
244
|
+
* an older record's newest unnamed round. */
|
|
245
|
+
export function summaryOfStanding(standing: PipelineStanding): PipelineSummary {
|
|
246
|
+
const counts = Object.fromEntries(STAGES.map((s) => [s, 0])) as Record<Stage, number>;
|
|
247
|
+
for (const u of standing.units) counts[u.stage]++;
|
|
248
|
+
const lastRound = standing.unnamedRounds.at(-1);
|
|
249
|
+
return {
|
|
250
|
+
current: standing.units.filter((u) => OPEN_STAGES.has(u.stage)),
|
|
251
|
+
counts,
|
|
252
|
+
total: standing.units.length,
|
|
253
|
+
...(lastRound !== undefined ? { lastRound } : {}),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The one carrier helper: the fold's summary for a run's events, or nothing
|
|
258
|
+
* for a run with no ship facts — so a model run's row never grows the field. */
|
|
259
|
+
export function pipelineOfEvents(events: readonly RunEvent[]): PipelineSummary | undefined {
|
|
260
|
+
const standing = pipelineStandingOf(events);
|
|
261
|
+
if (standing.units.length === 0 && standing.unnamedRounds.length === 0) return undefined;
|
|
262
|
+
return summaryOfStanding(standing);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const isFinite_ = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
|
266
|
+
|
|
267
|
+
function isPipelineUnitShape(v: unknown): v is PipelineUnit {
|
|
268
|
+
if (typeof v !== "object" || v === null) return false;
|
|
269
|
+
const u = v as Record<string, unknown>;
|
|
270
|
+
if (typeof u.unit !== "string" || !STAGES.includes(u.stage as Stage)) return false;
|
|
271
|
+
if (!isFinite_(u.round) || !isFinite_(u.segment) || !isFinite_(u.since)) return false;
|
|
272
|
+
if (u.pr !== undefined && !isFinite_(u.pr)) return false;
|
|
273
|
+
if (u.threadKey !== undefined && typeof u.threadKey !== "string") return false;
|
|
274
|
+
if (u.detail !== undefined && typeof u.detail !== "string") return false;
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Structural check on a stored summary (`RunRecord.pipeline`): the shape
|
|
279
|
+
* above, nothing else — an older reader accepts a newer record's field. */
|
|
280
|
+
export function isPipelineSummaryShape(v: unknown): v is PipelineSummary {
|
|
281
|
+
if (typeof v !== "object" || v === null) return false;
|
|
282
|
+
const s = v as Record<string, unknown>;
|
|
283
|
+
if (!Array.isArray(s.current) || !s.current.every(isPipelineUnitShape)) return false;
|
|
284
|
+
if (typeof s.counts !== "object" || s.counts === null) return false;
|
|
285
|
+
if (!Object.values(s.counts).every(isFinite_)) return false;
|
|
286
|
+
if (!isFinite_(s.total)) return false;
|
|
287
|
+
if (s.lastRound !== undefined) {
|
|
288
|
+
if (typeof s.lastRound !== "object" || s.lastRound === null) return false;
|
|
289
|
+
const r = s.lastRound as Record<string, unknown>;
|
|
290
|
+
if (!isFinite_(r.index) || typeof r.agent !== "string" || typeof r.outcome !== "string" || !isFinite_(r.at))
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
return true;
|
|
294
|
+
}
|