feat: Replace duration/elapsed fields with wall_time_ms and StageTiming (#343)

## Summary

Replaces the ambiguous `runtime_secs`, `elapsed_secs`, and `duration_ms`
timing fields on run/stage public API surfaces with explicit
`wall_time_ms` (elapsed clock time) and a `StageTiming` value object
that also carries `inference_time_ms`, `tool_time_ms`, and
`active_time_ms`.

This is a greenfield breaking change — no compatibility shims are
preserved.

### What changed

**API shape**
- `RunBillingStage.runtime_secs` → `RunBillingStage.timing: StageTiming`
- `RunBillingTotals.runtime_secs` → `RunBillingTotals.timing:
StageTiming`
- `RunSummary.timestamps.duration_ms` / `elapsed_secs` removed; a
top-level `timing: StageTiming | null` field added
- Stage list item `duration_secs` → `wall_time_ms`

**Web app (`apps/fabro-web`)**
- `run-billing.tsx`: `liveRuntimeSecs` → `liveWallTimeMs`; live ticking
now returns milliseconds and the footer total sums `wallTimeMs` across
rows
- `stage-sidebar.ts`: `duration_secs` → `wall_time_ms` for the per-stage
duration display
- `runs.ts`: `elapsed_secs` lookup replaced with `timing.wall_time_ms`
- `formatElapsedSecs` / `formatDurationSecs` call sites replaced with
`formatDurationMs`

**Lockfile / tooling**
- `@openapitools/openapi-generator-cli@2.20.2` added as a dev dependency
to `@qltysh/fabro-api-client` to support regenerating the TypeScript
client after schema edits; several transitive deps pulled in alongside
it.

### Design notes

- **Units are now consistent**: every timing value on run/stage surfaces
is in milliseconds; the old API mixed seconds (`runtime_secs`,
`elapsed_secs`) with milliseconds (`duration_ms`).
- **Live ticking** still works correctly: the in-flight billing row
computes `now - startedAt` in ms and sums across rows for the footer,
avoiding a server round-trip during a running stage.
- **`StageTiming.active_time_ms = inference_time_ms + tool_time_ms`** —
parallel work is summed, so run active time can exceed wall time.
- Subsystem-internal `duration_ms` fields (sandbox setup, devcontainer
lifecycle, hooks) are intentionally left unchanged; only public
run/stage timing surfaces are affected.


### Fabro Details

<details>
<summary>Ran 9 stages in 115m 53s for $108.50</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 6s | – | 0 |
| preflight_lint | 2m 18s | – | 0 |
| implement | 80m 53s | $101.97 | 0 |
| simplify_opus | 21m 35s | $4.09 | 0 |
| simplify_gpt | 5m 1s | $2.44 | 0 |
| verify | 3m 11s | – | 0 |
| fmt | 3s | – | 0 |
| **Total** | **115m 53s** | **$108.50** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (12 nodes and 15
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-7; }
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD."]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gpt      [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1 && cargo dev docs refresh 2>&1 && cargo dev docs check 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings, test failures, and generated docs errors.", max_visits=3]
    fmt               [label="Format", shape=parallelogram, script="cargo +nightly-2026-04-14 fmt --all 2>&1", max_retries=0]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> fmt   [condition="outcome=succeeded"]
    verify -> fixup
    fixup -> verify
    fmt -> exit
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
This commit is contained in:
fabro-sh-0530[bot] 2026-05-21 21:06:40 -04:00 • committed by GitHub
parent 178adf15ea
commit 54bc67017e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 1788 additions and 716 deletions

View file

@ -36,8 +36,12 @@ function makeRun(overrides: Partial<Run> = {}): Run {
started_at: "2026-04-08T12:00:00Z",
last_event_at: null,
completed_at: null,
duration_ms: 65000,
elapsed_secs: 65,
},
timing: {
wall_time_ms: 65000,
inference_time_ms: 0,
tool_time_ms: 0,
active_time_ms: 0,
},
billing: { total_usd_micros: 500000 },
diff: null,
@ -130,9 +134,8 @@ describe("mapRunToRunItem", () => {
started_at: null,
last_event_at: null,
completed_at: null,
duration_ms: null,
elapsed_secs: null,
},
timing: null,
billing: null,
});
const item = mapRunToRunItem(summary);
@ -177,4 +180,4 @@ describe("columnForStatus", () => {
test("returns null for lifecycle states that do not map to a board column", () => {
expect(columnForStatus("removing")).toBeNull();
});
});
});

View file

@ -1,4 +1,4 @@
import { formatElapsedSecs, formatDurationSecs } from "../lib/format";
import { formatDurationMs } from "../lib/format";
import {
BoardColumn,
type Run,
@ -91,7 +91,7 @@ export function mapRunListItem(item: Run): RunItem {
lifecycleStatusLabel: lifecycleStatusLabel(item.lifecycle.status, item.lifecycle.archived),
number: item.pull_request?.number,
pullRequestUrl: item.pull_request?.html_url,
elapsed: item.timestamps.elapsed_secs != null ? formatElapsedSecs(item.timestamps.elapsed_secs) : undefined,
elapsed: item.timing != null ? formatDurationMs(item.timing.wall_time_ms) : undefined,
resources: undefined,
question: item.current_question?.text,
sandboxId: runtime?.id ?? undefined,
@ -202,4 +202,4 @@ export const ciConfig: Record<CiStatus, { label: string; dot: string; text: stri
passing: { label: "Passing", dot: "bg-mint", text: "text-mint" },
failing: { label: "Changes needed", dot: "bg-coral", text: "text-coral" },
pending: { label: "Pending", dot: "bg-amber", text: "text-amber" },
};
};

View file

@ -26,7 +26,7 @@ describe("mapRunStagesToSidebarStages", () => {
name: "Apply Changes",
handler: "command",
status: "succeeded",
duration_secs: 12.5,
wall_time_ms: 12500,
node_id: "apply",
visit: 1,
},
@ -197,4 +197,4 @@ describe("aggregateGraphNodeStatus", () => {
latestStageId: "apply@1",
});
});
});
});

View file

@ -3,7 +3,7 @@ import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
import type { Stage } from "../components/stage-sidebar";
import { isVisibleStage } from "../data/runs";
import { formatDurationSecs } from "./format";
import { formatDurationMs } from "./format";
export const ACTIVE_STAGE_STATES: ReadonlySet<StageState> = new Set([
StageState.RUNNING,
@ -70,8 +70,8 @@ export function mapRunStagesToSidebarStages(
nodeId: stage.node_id,
visit: stage.visit,
status: stage.status,
duration: stage.duration_secs != null
? formatDurationSecs(stage.duration_secs)
duration: stage.wall_time_ms != null
? formatDurationMs(stage.wall_time_ms)
: "--",
startedAt: stage.started_at ?? null,
}));
@ -112,4 +112,4 @@ export function aggregateGraphNodeStatus(stages: readonly Stage[]): Map<
result.set(nodeId, { displayStatus: display.status, latestStageId: latestStage.id });
}
return result;
}
}

View file

@ -1,7 +1,20 @@
import { afterEach, describe, expect, mock, test } from "bun:test";
import TestRenderer from "react-test-renderer";
import type { BilledTokenCounts, RunBilling } from "@qltysh/fabro-api-client";
import type {
BilledTokenCounts,
RunBilling,
StageTiming,
} from "@qltysh/fabro-api-client";
function stageTiming(wall_time_ms = 0, inference_time_ms = 0, tool_time_ms = 0): StageTiming {
return {
wall_time_ms,
inference_time_ms,
tool_time_ms,
active_time_ms: inference_time_ms + tool_time_ms,
};
}
let currentBilling: RunBilling | undefined;
@ -28,7 +41,7 @@ function billing(overrides: Partial<RunBilling> = {}): RunBilling {
return {
stages: [],
totals: {
runtime_secs: 0,
timing: stageTiming(),
...zeroBilling(),
},
by_model: [],
@ -74,19 +87,19 @@ describe("RunBilling", () => {
stage: { id: "start", name: "start" },
model: null,
billing: zeroBilling(),
runtime_secs: 0,
timing: stageTiming(),
state: "succeeded",
},
{
stage: { id: "command", name: "command" },
model: null,
billing: zeroBilling(),
runtime_secs: 61,
timing: stageTiming(61000),
state: "succeeded",
},
],
totals: {
runtime_secs: 61,
timing: stageTiming(61000),
...zeroBilling(),
},
}),
@ -109,7 +122,7 @@ describe("RunBilling", () => {
stage: { id: "start", name: "start" },
model: null,
billing: zeroBilling(),
runtime_secs: 0,
timing: stageTiming(),
state: "succeeded",
},
{
@ -124,12 +137,12 @@ describe("RunBilling", () => {
total_tokens: 1500,
total_usd_micros: 240000,
}),
runtime_secs: 42,
timing: stageTiming(42000),
state: "succeeded",
},
],
totals: {
runtime_secs: 42,
timing: stageTiming(42000),
...zeroBilling({
input_tokens: 1200,
output_tokens: 300,
@ -197,13 +210,13 @@ describe("RunBilling", () => {
total_tokens: 1500,
total_usd_micros: 240000,
}),
runtime_secs: 0,
timing: stageTiming(),
started_at: startedAt,
state: "running",
},
],
totals: {
runtime_secs: 0,
timing: stageTiming(),
...zeroBilling({
input_tokens: 1200,
output_tokens: 300,
@ -255,4 +268,4 @@ describe("RunBilling", () => {
Date.now = originalNow;
}
});
});
});

View file

@ -3,7 +3,7 @@ import { Fragment, useMemo } from "react";
import { EmptyState } from "../components/state";
import { Tooltip } from "../components/ui";
import {
formatDurationSecs,
formatDurationMs,
formatTokenCount,
formatUsdMicros,
} from "../lib/format";
@ -53,24 +53,24 @@ interface MappedStageRow {
outputTokens: number | null;
cacheReadTokens: number | null;
cacheWriteTokens: number | null;
runtimeSecs: number;
wallTimeMs: number;
totalUsdMicros: number | null | undefined;
inFlight: boolean;
}
function liveRuntimeSecs(stage: RunBillingStage, now: number): number {
function liveWallTimeMs(stage: RunBillingStage, now: number): number {
if (stage.started_at) {
const startedMs = new Date(stage.started_at).getTime();
if (Number.isFinite(startedMs)) {
return Math.max(0, (now - startedMs) / 1000);
return Math.max(0, now - startedMs);
}
}
return stage.runtime_secs;
return stage.timing.wall_time_ms;
}
export const handle = { wide: true };
function mapStageRow(stage: RunBillingStage, runtimeSecs: number): MappedStageRow {
function mapStageRow(stage: RunBillingStage, wallTimeMs: number): MappedStageRow {
const hasModel = stage.model != null;
return {
stage: stage.stage.name,
@ -81,7 +81,7 @@ function mapStageRow(stage: RunBillingStage, runtimeSecs: number): MappedStageRo
: null,
cacheReadTokens: hasModel ? stage.billing.cache_read_tokens : null,
cacheWriteTokens: hasModel ? stage.billing.cache_write_tokens : null,
runtimeSecs,
wallTimeMs,
totalUsdMicros: stage.billing.total_usd_micros,
inFlight: isInFlight(stage),
};
@ -181,7 +181,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
// don't reallocate them every tick.
const completedRows = useMemo<MappedStageRow[]>(() => {
if (!billing) return [];
return billing.stages.map((stage) => mapStageRow(stage, stage.runtime_secs));
return billing.stages.map((stage) => mapStageRow(stage, stage.timing.wall_time_ms));
}, [billing]);
// The model breakdown is server-derived and stable across ticks too.
@ -206,16 +206,16 @@ export default function RunBilling({ params }: { params: { id: string } }) {
if (!hasInFlight) return completedRows;
return billing.stages.map((stage, idx) =>
isInFlight(stage)
? mapStageRow(stage, liveRuntimeSecs(stage, now))
? mapStageRow(stage, liveWallTimeMs(stage, now))
: completedRows[idx],
);
}, [billing, completedRows, hasInFlight, now]);
// While ticking, sum the displayed row runtimes so the footer updates in
// lock-step. Otherwise trust the server's authoritative total.
const totalRuntimeSecs = hasInFlight
? rows.reduce((sum, row) => sum + row.runtimeSecs, 0)
: (billing?.totals.runtime_secs ?? 0);
const totalWallTimeMs = hasInFlight
? rows.reduce((sum, row) => sum + row.wallTimeMs, 0)
: (billing?.totals.timing.wall_time_ms ?? 0);
const hasLlmStages = (billing?.by_model.length ?? 0) > 0;
const totalInput = hasLlmStages ? (billing?.totals.input_tokens ?? null) : null;
@ -276,7 +276,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
/>
</td>
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
{formatDurationSecs(row.runtimeSecs)}
{formatDurationMs(row.wallTimeMs)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
{formatUsdMicrosOrDash(row.totalUsdMicros)}
@ -297,7 +297,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
/>
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
{formatDurationSecs(totalRuntimeSecs)}
{formatDurationMs(totalWallTimeMs)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
{formatUsdMicrosOrDash(totalUsdMicros)}

View file

@ -104,9 +104,8 @@ function makeRunSummary(
started_at: null,
last_event_at: null,
completed_at: null,
duration_ms: null,
elapsed_secs: null,
},
timing: null,
billing: null,
diff: diffSummary,
pull_request: pullRequest,
@ -548,4 +547,4 @@ describe("RunDetail full-height child routes", () => {
);
expect(outletWrappers).toHaveLength(1);
});
});
});

267
bun.lock
View file

@ -77,6 +77,7 @@
"axios": "^1.7.0",
},
"devDependencies": {
"@openapitools/openapi-generator-cli": "2.20.2",
"typescript": "^5.9.2",
},
},
@ -146,6 +147,8 @@
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
"@capsizecss/unpack": ["@capsizecss/unpack@4.0.0", "", { "dependencies": { "fontkitten": "^1.0.0" } }, "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA=="],
"@clack/core": ["@clack/core@1.2.0", "", { "dependencies": { "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg=="],
@ -294,6 +297,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@lukeed/csprng": ["@lukeed/csprng@1.1.0", "", {}, "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA=="],
"@mediabunny/aac-encoder": ["@mediabunny/aac-encoder@1.39.2", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-KD6KADVzAnW7tqhRFGBOX4uaiHbd0Yxvg0lfthj3wJLAEEgEBAvi43w+ZXWeEn54X/jpabrLe4bW/eYFFvlbUA=="],
"@mediabunny/flac-encoder": ["@mediabunny/flac-encoder@1.39.2", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-VwBr3AzZTPEEPvt4aladZiXwOf3W293eq213zDupGQi/taS8WWNqDd3eBdf8FfvlbXATfbRiycXDKyQ0HlOZaQ=="],
@ -314,6 +319,18 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],
"@nestjs/axios": ["@nestjs/axios@4.0.0", "", { "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "axios": "^1.3.1", "rxjs": "^7.0.0" } }, "sha512-1cB+Jyltu/uUPNQrpUimRHEQHrnQrpLzVj6dU3dgn6iDDDdahr10TgHFGTmw5VuJ9GzKZsCLDL78VSwJAs/9JQ=="],
"@nestjs/common": ["@nestjs/common@11.1.1", "", { "dependencies": { "file-type": "20.5.0", "iterare": "1.2.1", "load-esm": "1.0.2", "tslib": "2.8.1", "uid": "2.0.2" }, "peerDependencies": { "class-transformer": ">=0.4.1", "class-validator": ">=0.13.2", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, "optionalPeers": ["class-transformer", "class-validator"] }, "sha512-crzp+1qeZ5EGL0nFTPy9NrVMAaUWewV5AwtQyv6SQ9yQPXwRl9W9hm1pt0nAtUu5QbYMbSuo7lYcF81EjM+nCA=="],
"@nestjs/core": ["@nestjs/core@11.1.1", "", { "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", "path-to-regexp": "8.2.0", "tslib": "2.8.1", "uid": "2.0.2" }, "peerDependencies": { "@nestjs/common": "^11.0.0", "@nestjs/microservices": "^11.0.0", "@nestjs/platform-express": "^11.0.0", "@nestjs/websockets": "^11.0.0", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, "optionalPeers": ["@nestjs/microservices", "@nestjs/platform-express", "@nestjs/websockets"] }, "sha512-UFoUAgLKFT+RwHTANJdr0dF7p0qS9QjkaUPjg8aafnjM/qxxxrUVDB49nVvyMlk+Hr1+vvcNaOHbWWQBxoZcHA=="],
"@nuxt/opencollective": ["@nuxt/opencollective@0.4.1", "", { "dependencies": { "consola": "^3.2.3" }, "bin": { "opencollective": "bin/opencollective.js" } }, "sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ=="],
"@nuxtjs/opencollective": ["@nuxtjs/opencollective@0.3.2", "", { "dependencies": { "chalk": "^4.1.0", "consola": "^2.15.0", "node-fetch": "^2.6.1" }, "bin": { "opencollective": "bin/opencollective.js" } }, "sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA=="],
"@openapitools/openapi-generator-cli": ["@openapitools/openapi-generator-cli@2.20.2", "", { "dependencies": { "@nestjs/axios": "4.0.0", "@nestjs/common": "11.1.1", "@nestjs/core": "11.1.1", "@nuxtjs/opencollective": "0.3.2", "axios": "1.9.0", "chalk": "4.1.2", "commander": "8.3.0", "compare-versions": "4.1.4", "concurrently": "6.5.1", "console.table": "0.10.0", "fs-extra": "11.3.0", "glob": "9.3.5", "inquirer": "8.2.6", "lodash": "4.17.21", "proxy-agent": "6.5.0", "reflect-metadata": "0.2.2", "rxjs": "7.8.2", "tslib": "2.8.1" }, "bin": { "openapi-generator-cli": "main.js" } }, "sha512-dNFwQcQu6+rmEWSJj4KUx468+p6Co7nfpVgi5QEfVhzKj7wBytz9GEhCN2qmVgtg3ZX8H6nxbXI8cjh7hAxAqg=="],
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
"@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
@ -668,6 +685,12 @@
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.19", "", {}, "sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
@ -760,12 +783,20 @@
"acorn-import-phases": ["acorn-import-phases@1.0.4", "", { "peerDependencies": { "acorn": "^8.14.0" } }, "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="],
"ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="],
"ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
@ -792,14 +823,26 @@
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
"basic-ftp": ["basic-ftp@5.3.1", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="],
"big.js": ["big.js@5.2.2", "", {}, "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
@ -810,6 +853,8 @@
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
@ -818,6 +863,8 @@
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
"chardet": ["chardet@0.7.0", "", {}, "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"chrome-trace-event": ["chrome-trace-event@1.0.4", "", {}, "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ=="],
@ -828,16 +875,38 @@
"classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
"cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="],
"cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
"cli-width": ["cli-width@3.0.0", "", {}, "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw=="],
"cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="],
"clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="],
"compare-versions": ["compare-versions@4.1.4", "", {}, "sha512-FemMreK9xNyL8gQevsdRMrvO4lFCkQP7qbuktn1q8ndcNk1+0mz7lgE7b/sNvbhVgY4w6tMN1FDp6aADjqw2rw=="],
"concurrently": ["concurrently@6.5.1", "", { "dependencies": { "chalk": "^4.1.0", "date-fns": "^2.16.1", "lodash": "^4.17.21", "rxjs": "^6.6.3", "spawn-command": "^0.0.2-1", "supports-color": "^8.1.0", "tree-kill": "^1.2.2", "yargs": "^16.2.0" }, "bin": { "concurrently": "bin/concurrently.js" } }, "sha512-FlSwNpGjWQfRwPLXvJ/OgysbBxPkWpiVjy1042b0U7on7S7qwwMIILRj7WTN1mTgqa582bG6NFuScOoh6Zgdag=="],
"consola": ["consola@2.15.3", "", {}, "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw=="],
"console.table": ["console.table@0.10.0", "", { "dependencies": { "easy-table": "1.1.0" } }, "sha512-dPyZofqggxuvSf7WXvNjuRfnsOk1YazkVP8FdxH4tcH2c37wc79/Yl6Bhr7Lsu00KMgy2ql/qCMuNu8xctZM8g=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
@ -862,14 +931,22 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
"date-fns": ["date-fns@2.30.0", "", { "dependencies": { "@babel/runtime": "^7.21.0" } }, "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="],
"define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
"defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
"degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
@ -902,8 +979,12 @@
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"easy-table": ["easy-table@1.1.0", "", { "optionalDependencies": { "wcwidth": ">=1.0.1" } }, "sha512-oq33hWOSSnl2Hoh00tZWaIPi1ievrD9aFG82/IgjlycAnW9hHx5PkJiXpxPsgEE+H7BsbVQXFVFST8TEXS6/pA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.302", "", {}, "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"emojis-list": ["emojis-list@3.0.0", "", {}, "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
@ -928,7 +1009,9 @@
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
"escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="],
"eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="],
@ -942,6 +1025,8 @@
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
@ -950,6 +1035,8 @@
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"external-editor": ["external-editor@3.1.0", "", { "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", "tmp": "^0.0.33" } }, "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew=="],
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
"fabro-remotion": ["fabro-remotion@workspace:apps/remotion"],
@ -960,6 +1047,8 @@
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
"fast-string-truncated-width": ["fast-string-truncated-width@1.2.1", "", {}, "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow=="],
"fast-string-width": ["fast-string-width@1.1.0", "", { "dependencies": { "fast-string-truncated-width": "^1.2.0" } }, "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ=="],
@ -972,6 +1061,12 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"figures": ["figures@3.2.0", "", { "dependencies": { "escape-string-regexp": "^1.0.5" } }, "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg=="],
"file-type": ["file-type@20.5.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.6", "strtok3": "^10.2.0", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-BfHZtG/l9iMm4Ecianu7P8HRD2tBHLtjXinm4X62XBOYzi7CYA7jyqfJzOvXHqzVrVPYqBo2/GvbARMaaJkKVg=="],
"flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
@ -982,14 +1077,20 @@
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"fs-extra": ["fs-extra@11.3.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew=="],
"fs-monkey": ["fs-monkey@1.0.3", "", {}, "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q=="],
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
@ -998,8 +1099,12 @@
"get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
"get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="],
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
@ -1048,12 +1153,26 @@
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"icss-utils": ["icss-utils@5.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"inquirer": ["inquirer@8.2.6", "", { "dependencies": { "ansi-escapes": "^4.2.1", "chalk": "^4.1.1", "cli-cursor": "^3.1.0", "cli-width": "^3.0.0", "external-editor": "^3.0.3", "figures": "^3.0.0", "lodash": "^4.17.21", "mute-stream": "0.0.8", "ora": "^5.4.1", "run-async": "^2.4.0", "rxjs": "^7.5.5", "string-width": "^4.1.0", "strip-ansi": "^6.0.0", "through": "^2.3.6", "wrap-ansi": "^6.0.1" } }, "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg=="],
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
"iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
@ -1066,20 +1185,28 @@
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
"is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="],
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"iterare": ["iterare@1.2.1", "", {}, "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q=="],
"jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
@ -1096,6 +1223,8 @@
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
@ -1122,15 +1251,21 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"load-esm": ["load-esm@1.0.2", "", {}, "sha512-nVAvWk/jeyrWyXEAs84mpQCYccxRqgKY4OznLuJhJCa0XsPSfdOIr2zvBZEj3IHEHbX97jjscKRRV539bW0Gpw=="],
"loader-runner": ["loader-runner@4.3.1", "", {}, "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q=="],
"loader-utils": ["loader-utils@2.0.4", "", { "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", "json5": "^2.1.2" } }, "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="],
"log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="],
"lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
"lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="],
@ -1250,24 +1385,34 @@
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="],
"minimist": ["minimist@1.2.6", "", {}, "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="],
"minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mute-stream": ["mute-stream@0.0.8", "", {}, "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="],
"nanoid": ["nanoid@5.1.11", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="],
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
"neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="],
"netmask": ["netmask@2.1.1", "", {}, "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA=="],
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="],
@ -1296,12 +1441,20 @@
"open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
"ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="],
"os-tmpdir": ["os-tmpdir@1.0.2", "", {}, "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="],
"p-limit": ["p-limit@7.3.0", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw=="],
"p-queue": ["p-queue@9.1.2", "", { "dependencies": { "eventemitter3": "^5.0.1", "p-timeout": "^7.0.0" } }, "sha512-ktsDOALzTYTWWF1PbkNVg2rOt+HaOaMWJMUnt7T3qf5tvZ1L8dBW3tObzprBcXNMKkwj+yFSLqHso0x+UFcJXw=="],
"p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="],
"pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="],
"pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="],
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
@ -1312,6 +1465,10 @@
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"path-to-regexp": ["path-to-regexp@8.2.0", "", {}, "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ=="],
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
"piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="],
@ -1346,6 +1503,8 @@
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
@ -1378,10 +1537,14 @@
"react-textarea-autosize": ["react-textarea-autosize@8.5.9", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
@ -1408,8 +1571,12 @@
"remotion": ["remotion@4.0.437", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-mQHiYZwt3HoMngJeTGyytVFdobf/mgsPTiQSUfPP43kA7bEpn4OdaF4hWoMfJhjfTC1ZdkTIRX/s3OKto0aWzg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="],
"retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="],
"retext-latin": ["retext-latin@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "parse-latin": "^7.0.0", "unified": "^11.0.0" } }, "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA=="],
@ -1420,8 +1587,16 @@
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
"run-async": ["run-async@2.4.1", "", {}, "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ=="],
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-content-frame": ["safe-content-frame@0.0.19", "", {}, "sha512-+R0IHHjvghT5O8bc8itf9AoS9MvzhUcD0p+hNINLgyEuFQJug3wt3ZuhLFZFG3bUzHi8UfQED4p6J3/Ft9oCtg=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
@ -1446,8 +1621,14 @@
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
"smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="],
"socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="],
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
"source-map": ["source-map@0.7.3", "", {}, "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@ -1456,19 +1637,29 @@
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"spawn-command": ["spawn-command@0.0.2", "", {}, "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ=="],
"stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
"style-loader": ["style-loader@4.0.0", "", { "peerDependencies": { "webpack": "^5.27.0" } }, "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA=="],
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="],
@ -1484,6 +1675,8 @@
"terser-webpack-plugin": ["terser-webpack-plugin@5.4.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g=="],
"through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="],
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
@ -1494,7 +1687,13 @@
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="],
"tmp": ["tmp@0.0.33", "", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="],
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
@ -1504,10 +1703,16 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
"uid": ["uid@2.0.2", "", { "dependencies": { "@lukeed/csprng": "^1.0.0" } }, "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g=="],
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"ultrahtml": ["ultrahtml@1.6.0", "", {}, "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw=="],
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
@ -1536,6 +1741,8 @@
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
@ -1570,28 +1777,36 @@
"watchpack": ["watchpack@2.5.1", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg=="],
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"webpack": ["webpack@5.105.0", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.19.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.5.1", "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw=="],
"webpack-sources": ["webpack-sources@3.3.4", "", {}, "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q=="],
"whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="],
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="],
"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="],
"yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
"yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
@ -1620,6 +1835,10 @@
"@babel/traverse/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
"@nuxt/opencollective/consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"@openapitools/openapi-generator-cli/axios": ["axios@1.9.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } }, "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg=="],
"@parcel/watcher/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"@radix-ui/react-accordion/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
@ -1816,24 +2035,44 @@
"astro/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"concurrently/rxjs": ["rxjs@6.6.7", "", { "dependencies": { "tslib": "^1.9.0" } }, "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ=="],
"concurrently/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="],
"degenerator/ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"escodegen/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"esrecurse/estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"extract-zip/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"magicast/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"postcss-modules-local-by-default/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
@ -1852,14 +2091,20 @@
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"terser-webpack-plugin/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="],
"tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"unstorage/lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="],
"webpack/schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="],
"yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
"@astrojs/markdown-remark/shiki/@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="],
"@astrojs/markdown-remark/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="],
@ -1986,6 +2231,8 @@
"@remotion/bundler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ=="],
"@remotion/renderer/source-map/whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="],
"@remotion/studio-server/semver/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"@remotion/studio/semver/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
@ -2030,6 +2277,8 @@
"astro/shiki/@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
"concurrently/rxjs/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
"terser-webpack-plugin/schema-utils/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
@ -2040,6 +2289,10 @@
"webpack/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" }, "peerDependencies": { "ajv": "^8.8.2" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="],
"@remotion/renderer/source-map/whatwg-url/tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="],
"@remotion/renderer/source-map/whatwg-url/webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
"@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
"@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],

View file

@ -7583,11 +7583,13 @@ components:
type: ["string", "null"]
format: date-time
description: Wall-clock time the latest attempt of this stage started, if known.
duration_ms:
type: ["integer", "null"]
format: uint64
minimum: 0
description: Wall-clock duration of the stage's latest terminal attempt, if known.
timing:
oneOf:
- $ref: "#/components/schemas/StageTiming"
- type: "null"
description: |
Per-attempt timing breakdown for the latest terminal attempt:
wall time plus the active inference/tool breakdown.
usage:
$ref: "#/components/schemas/BilledTokenCounts"
model:
@ -7743,17 +7745,15 @@ components:
required:
- stage_id
- stage_label
- duration_ms
- timing
- retries
properties:
stage_id:
type: string
stage_label:
type: string
duration_ms:
type: integer
format: uint64
minimum: 0
timing:
$ref: "#/components/schemas/StageTiming"
billing_usd_micros:
type: ["integer", "null"]
format: int64
@ -7768,7 +7768,7 @@ components:
required:
- timestamp
- status
- duration_ms
- timing
- stages
- total_retries
- diff
@ -7778,10 +7778,8 @@ components:
format: date-time
status:
$ref: "#/components/schemas/StageOutcome"
duration_ms:
type: integer
format: uint64
minimum: 0
timing:
$ref: "#/components/schemas/RunTiming"
failure:
oneOf:
- $ref: "#/components/schemas/RunFailure"
@ -7908,6 +7906,7 @@ components:
- models
- source_directory
- timestamps
- timing
- billing
- diff
- pull_request
@ -7964,6 +7963,13 @@ components:
type: ["string", "null"]
timestamps:
$ref: "#/components/schemas/RunTimestamps"
timing:
oneOf:
- $ref: "#/components/schemas/RunTiming"
- type: "null"
description: |
Run-level timing rollup. Wall time is the run's clock duration;
active timing sums work across stage visits.
billing:
oneOf:
- $ref: "#/components/schemas/RunBillingSummary"
@ -8068,11 +8074,6 @@ components:
completed_at:
type: ["string", "null"]
format: date-time
duration_ms:
type: ["integer", "null"]
format: int64
elapsed_secs:
type: ["number", "null"]
RunBillingSummary:
type: object
@ -8212,10 +8213,12 @@ components:
example: unit-tests
status:
$ref: "#/components/schemas/CheckRunStatus"
duration_secs:
type: number
description: Duration of the check run in seconds.
example: 154.0
wall_time_ms:
type: integer
format: uint64
minimum: 0
description: Wall-clock duration of the check run in milliseconds.
example: 154000
# ── Reusable Sub-Schemas ───────────────────────────────────────────
@ -8611,20 +8614,75 @@ components:
format: uri
example: https://github.com/fabro-sh/fabro/pull/123
RunTimings:
description: Timing information for a run.
StageTiming:
description: |
Timing breakdown for one stage visit. Fields are all milliseconds.
`wall_time_ms` is elapsed clock time; `inference_time_ms` is Fabro-
observed LLM request/stream elapsed time; `tool_time_ms` is tool or
command execution elapsed time; `active_time_ms` equals
`inference_time_ms + tool_time_ms`.
type: object
required:
- elapsed_secs
- wall_time_ms
- active_time_ms
properties:
elapsed_secs:
type: number
description: Wall-clock time elapsed in seconds.
example: 420.0
elapsed_warning:
type: boolean
description: Whether the elapsed time exceeds the expected threshold.
example: false
wall_time_ms:
type: integer
format: uint64
minimum: 0
example: 1500
inference_time_ms:
type: integer
format: uint64
minimum: 0
default: 0
example: 900
tool_time_ms:
type: integer
format: uint64
minimum: 0
default: 0
example: 200
active_time_ms:
type: integer
format: uint64
minimum: 0
description: Equals `inference_time_ms + tool_time_ms`.
example: 1100
RunTiming:
description: |
Timing rollup for an entire run. Active fields sum work across stage
visits, so `active_time_ms` can exceed `wall_time_ms` when parallel
branches run concurrently.
type: object
required:
- wall_time_ms
- active_time_ms
properties:
wall_time_ms:
type: integer
format: uint64
minimum: 0
example: 420000
inference_time_ms:
type: integer
format: uint64
minimum: 0
default: 0
example: 120000
tool_time_ms:
type: integer
format: uint64
minimum: 0
default: 0
example: 60000
active_time_ms:
type: integer
format: uint64
minimum: 0
description: Equals `inference_time_ms + tool_time_ms`.
example: 180000
SandboxProvider:
description: Sandbox execution provider.
@ -8704,7 +8762,7 @@ components:
- reasoning_tokens
- cache_read_tokens
- cache_write_tokens
- runtime_secs
- timing
properties:
runs:
type: integer
@ -8739,10 +8797,12 @@ components:
format: int64
description: Total billed USD amount in micros.
example: 20340000
runtime_secs:
type: number
description: Total runtime in seconds.
example: 3501.0
timing:
$ref: "#/components/schemas/RunTiming"
description: |
Aggregate timing rollup across every completed run. Active timing
sums work across stage visits, so `active_time_ms` can exceed
`wall_time_ms`.
BillingStageRef:
description: Reference to a workflow node in a billing stage row.
@ -8864,10 +8924,12 @@ components:
$ref: "#/components/schemas/StageHandler"
status:
$ref: "#/components/schemas/StageState"
duration_secs:
type: number
description: Time spent in this stage, in seconds.
example: 154.0
wall_time_ms:
type: integer
format: uint64
minimum: 0
description: Wall-clock time the latest attempt spent in this stage, in milliseconds.
example: 154000
node_id:
type: string
description: Node id in the workflow graph; multiple stages with different visits share the same node_id.
@ -9220,13 +9282,13 @@ components:
# ── Billing Schemas ──────────────────────────────────────────────────
RunBillingStage:
description: Token counts and billed totals for one workflow node within a run. Rows are grouped by node; billing and runtime sum every visit of that node.
description: Token counts and billed totals for one workflow node within a run. Rows are grouped by node; billing and timing sum every visit of that node.
type: object
required:
- stage
- model
- billing
- runtime_secs
- timing
properties:
stage:
$ref: "#/components/schemas/BillingStageRef"
@ -9237,10 +9299,11 @@ components:
- type: "null"
billing:
$ref: "#/components/schemas/BilledTokenCounts"
runtime_secs:
type: number
description: Wall-clock runtime in seconds, summed across every visit of this node.
example: 154.0
timing:
$ref: "#/components/schemas/StageTiming"
description: |
Per-node timing summed across every visit. `wall_time_ms` is the
sum of visit wall times; the active breakdown sums work timing.
started_at:
type: ["string", "null"]
format: date-time
@ -9256,7 +9319,7 @@ components:
description: Aggregate billing totals across all stages of a run.
type: object
required:
- runtime_secs
- timing
- input_tokens
- output_tokens
- total_tokens
@ -9264,10 +9327,11 @@ components:
- cache_read_tokens
- cache_write_tokens
properties:
runtime_secs:
type: number
description: Total wall-clock runtime in seconds.
example: 389.0
timing:
$ref: "#/components/schemas/RunTiming"
description: |
Run-level timing rollup. `wall_time_ms` is summed across stage
visits; active timing sums work across visits.
input_tokens:
type: integer
description: Total input tokens consumed.
@ -11350,4 +11414,4 @@ components:
login:
type: string
description: User's login identifier (e.g. GitHub username).
example: octocat
example: octocat

View file

@ -364,6 +364,8 @@ fn main() {
("BillingModelRef", "fabro_model::ModelRef", &[]),
("BillingSpeed", "fabro_model::Speed", &[]),
("ExecOutputTail", "fabro_types::ExecOutputTail", &[]),
("StageTiming", "fabro_types::StageTiming", &[]),
("RunTiming", "fabro_types::RunTiming", &[]),
("ProviderId", "fabro_model::ProviderId", &[]),
("Model", "fabro_model::Model", &[]),
("Provider", "fabro_model::Provider", &[]),

View file

@ -37,7 +37,7 @@ fn run_billing_stage_model_accepts_required_null() {
"cache_read_tokens": 0,
"cache_write_tokens": 0
},
"runtime_secs": 0.0
"timing": {"wall_time_ms": 0, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0}
});
let stage: RunBillingStage =
@ -69,7 +69,7 @@ fn run_billing_stage_round_trips_terminal_row_with_started_at_and_state() {
"cache_read_tokens": 0,
"cache_write_tokens": 0
},
"runtime_secs": 5.5,
"timing": {"wall_time_ms": 5500, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0},
"started_at": "2026-04-29T12:34:56Z",
"state": "succeeded"
});
@ -122,7 +122,7 @@ fn run_billing_stage_round_trips_in_flight_row() {
"cache_read_tokens": 0,
"cache_write_tokens": 0
},
"runtime_secs": 1.25,
"timing": {"wall_time_ms": 1250, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0},
"started_at": "2026-04-29T12:34:56Z",
"state": "running"
});

View file

@ -70,7 +70,7 @@ fn conclusion_json_uses_failure_object() {
status: StageOutcome::Failed {
retry_requested: false,
},
duration_ms: 42,
timing: fabro_types::RunTiming::wall_only(42),
failure: Some(RunFailure {
reason: FailureReason::WorkflowError,
detail: FailureDetail::new("boom", FailureCategory::Deterministic),
@ -84,7 +84,12 @@ fn conclusion_json_uses_failure_object() {
json!({
"timestamp": "2026-05-13T12:00:00Z",
"status": "failed",
"duration_ms": 42,
"timing": {
"wall_time_ms": 42,
"inference_time_ms": 0,
"tool_time_ms": 0,
"active_time_ms": 0
},
"failure": {
"reason": "workflow_error",
"detail": {

View file

@ -6,7 +6,7 @@ use fabro_api::types::{RepositoryRef as ApiRepositoryRef, Run as ApiRun};
use fabro_types::status::{RunStatus, SuccessReason};
use fabro_types::{
DiffSummary, PullRequestLink, RepositoryProvider, RepositoryRef, Run, RunBillingSummary, RunId,
RunLifecycle, RunLinks, RunOrigin, RunTimestamps, WorkflowRef,
RunLifecycle, RunLinks, RunOrigin, RunTimestamps, RunTiming, WorkflowRef,
};
use serde_json::json;
@ -62,9 +62,8 @@ fn run_summary_json_matches_openapi_shape() {
started_at: Some(created_at),
last_event_at: Some(last_event_at),
completed_at: None,
duration_ms: Some(42_000),
elapsed_secs: Some(42.0),
},
timing: Some(RunTiming::new(42_000, 12_000, 30_000)),
billing: Some(RunBillingSummary {
total_usd_micros: Some(123),
}),
@ -128,9 +127,13 @@ fn run_summary_json_matches_openapi_shape() {
"created_at": "2026-04-20T12:00:00Z",
"started_at": "2026-04-20T12:00:00Z",
"last_event_at": "2026-04-20T12:00:42Z",
"completed_at": null,
"duration_ms": 42000,
"elapsed_secs": 42.0
"completed_at": null
},
"timing": {
"wall_time_ms": 42000,
"inference_time_ms": 12000,
"tool_time_ms": 30000,
"active_time_ms": 42000
},
"billing": {
"total_usd_micros": 123
@ -220,8 +223,7 @@ fn run_summary_deserializes_when_optional_fields_are_absent() {
assert_eq!(summary.timestamps.last_event_at, None);
assert_eq!(summary.lifecycle.status, RunStatus::Running);
assert_eq!(summary.lifecycle.pending_control, None);
assert_eq!(summary.timestamps.duration_ms, None);
assert_eq!(summary.timestamps.elapsed_secs, None);
assert_eq!(summary.timing.map(|t| t.wall_time_ms), None);
assert_eq!(summary.billing, None);
assert_eq!(summary.superseded_by, None);
assert_eq!(summary.diff, None);

View file

@ -29,7 +29,12 @@ fn stage_projection_round_trips_representative_json() {
"output": "ok",
"termination": "exited",
"started_at": "2026-04-29T12:34:00Z",
"duration_ms": 56000,
"timing": {
"wall_time_ms": 56000,
"inference_time_ms": 0,
"tool_time_ms": 0,
"active_time_ms": 0
},
"usage": {
"input_tokens": 0,
"output_tokens": 0,

View file

@ -372,7 +372,7 @@ fn format_event_pretty_value(envelope: &serde_json::Value, styles: &Styles) -> O
}
}
"run.completed" => {
let duration = format_duration_ms(prop_field(envelope, "duration_ms"));
let duration = format_duration_ms(timing_wall_field(envelope));
let status_str = match prop_str_field(envelope, "status") {
Some(status) if !status.is_empty() => status,
_ => "succeeded",
@ -526,7 +526,7 @@ fn format_event_pretty_value(envelope: &serde_json::Value, styles: &Styles) -> O
}
"stage.completed" => {
let label = str_field(envelope, "node_label").unwrap_or("?");
let duration = format_duration_ms(prop_field(envelope, "duration_ms"));
let duration = format_duration_ms(timing_wall_field(envelope));
let billing = prop_field(envelope, "billing").or_else(|| prop_field(envelope, "usage"));
let cost = format_cost(
billing
@ -809,6 +809,13 @@ fn prop_str_field<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str
prop_field(value, key)?.as_str()
}
/// Read `properties.timing.wall_time_ms` from a stage/run terminal event
/// envelope. Returns `None` when timing is absent, which falls back to a
/// blank display via `format_duration_ms`.
fn timing_wall_field(envelope: &serde_json::Value) -> Option<&serde_json::Value> {
prop_field(envelope, "timing")?.get("wall_time_ms")
}
fn failure_message(failure: &serde_json::Value) -> Option<&serde_json::Value> {
failure
.get("detail")
@ -1027,7 +1034,7 @@ mod tests {
#[test]
fn pretty_stage_completed() {
let styles = no_color_styles();
let line = r#"{"ts":"2026-01-01T14:23:15Z","event":"stage.completed","node_label":"plan","properties":{"duration_ms":8000,"status":"succeeded","usage":{"cost":0.12,"input_tokens":10000,"output_tokens":5200}}}"#;
let line = r#"{"ts":"2026-01-01T14:23:15Z","event":"stage.completed","node_label":"plan","properties":{"timing":{"wall_time_ms":8000,"inference_time_ms":0,"tool_time_ms":0,"active_time_ms":0},"status":"succeeded","usage":{"cost":0.12,"input_tokens":10000,"output_tokens":5200}}}"#;
let result = format_event_pretty(line, &styles).unwrap();
assert!(result.contains("plan"), "got: {result}");
assert!(result.contains("$0.12"), "got: {result}");
@ -1106,7 +1113,7 @@ mod tests {
#[test]
fn pretty_workflow_run_completed() {
let styles = no_color_styles();
let line = r#"{"ts":"2026-01-01T14:23:32Z","run_id":"abc123","event":"run.completed","properties":{"duration_ms":25000,"status":"succeeded","total_usd_micros":570000,"billing":{"input_tokens":5000,"output_tokens":2000,"total_tokens":7000,"cache_read_tokens":3000,"cache_write_tokens":500,"reasoning_tokens":800}}}"#;
let line = r#"{"ts":"2026-01-01T14:23:32Z","run_id":"abc123","event":"run.completed","properties":{"timing":{"wall_time_ms":25000,"inference_time_ms":0,"tool_time_ms":0,"active_time_ms":0},"status":"succeeded","total_usd_micros":570000,"billing":{"input_tokens":5000,"output_tokens":2000,"total_tokens":7000,"cache_read_tokens":3000,"cache_write_tokens":500,"reasoning_tokens":800}}}"#;
let result = format_event_pretty(line, &styles).unwrap();
assert!(result.contains("SUCCEEDED"), "got: {result}");
assert!(result.contains("25s"), "got: {result}");
@ -1120,7 +1127,7 @@ mod tests {
#[test]
fn pretty_workflow_run_completed_backward_compat() {
let styles = no_color_styles();
let line = r#"{"ts":"2026-01-01T14:23:32Z","run_id":"abc123","event":"run.completed","properties":{"duration_ms":25000,"total_cost":0.57}}"#;
let line = r#"{"ts":"2026-01-01T14:23:32Z","run_id":"abc123","event":"run.completed","properties":{"timing":{"wall_time_ms":25000,"inference_time_ms":0,"tool_time_ms":0,"active_time_ms":0},"total_cost":0.57}}"#;
let result = format_event_pretty(line, &styles).unwrap();
assert!(result.contains("SUCCEEDED"), "got: {result}");
assert!(result.contains("25s"), "got: {result}");
@ -1131,7 +1138,7 @@ mod tests {
#[test]
fn pretty_workflow_run_completed_fail_status() {
let styles = no_color_styles();
let line = r#"{"ts":"2026-01-01T14:23:32Z","event":"run.completed","properties":{"duration_ms":25000,"status":"failed"}}"#;
let line = r#"{"ts":"2026-01-01T14:23:32Z","event":"run.completed","properties":{"timing":{"wall_time_ms":25000,"inference_time_ms":0,"tool_time_ms":0,"active_time_ms":0},"status":"failed"}}"#;
let result = format_event_pretty(line, &styles).unwrap();
assert!(result.contains("FAIL"), "got: {result}");
}

View file

@ -205,7 +205,7 @@ pub(crate) fn print_run_conclusion(
fabro_util::printerr!(
printer,
"Duration: {}",
HumanDuration(Duration::from_millis(conclusion.duration_ms))
HumanDuration(Duration::from_millis(conclusion.timing.wall_time_ms))
);
if let Some(billing) = conclusion.billing.as_ref() {

View file

@ -132,11 +132,11 @@ pub(super) enum ProgressEvent {
script: Option<String>,
},
StageCompleted {
node_id: String,
name: String,
duration_ms: u64,
status: String,
usage: Option<ProgressUsage>,
node_id: String,
name: String,
timing: fabro_types::StageTiming,
status: String,
usage: Option<ProgressUsage>,
},
StageFailed {
node_id: String,
@ -350,7 +350,7 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option<ProgressEvent> {
EventBody::StageCompleted(props) => Some(ProgressEvent::StageCompleted {
node_id,
name: node_label,
duration_ms: props.duration_ms,
timing: props.timing,
status: props.status.to_string(),
usage: props
.billing
@ -550,7 +550,7 @@ mod tests {
node_id: "plan".into(),
name: "Plan".into(),
index: 0,
duration_ms: 5000,
timing: fabro_types::StageTiming::wall_only(5000),
status: "succeeded".into(),
preferred_label: None,
suggested_next_ids: Vec::new(),
@ -576,9 +576,9 @@ mod tests {
ProgressEvent::StageCompleted {
node_id,
name,
duration_ms,
timing,
..
} if node_id == "plan" && name == "Plan" && duration_ms == 5000
} if node_id == "plan" && name == "Plan" && timing.wall_time_ms == 5000
));
}

View file

@ -255,7 +255,7 @@ impl ProgressUI {
ProgressEvent::StageCompleted {
node_id,
name,
duration_ms,
timing,
status,
usage,
} => {
@ -263,7 +263,7 @@ impl ProgressUI {
renderer,
&node_id,
&name,
duration_ms,
timing.wall_time_ms,
&status,
usage.as_ref(),
);
@ -585,7 +585,7 @@ mod tests {
node_id: node_id.into(),
name: name.into(),
index: 0,
duration_ms: 5000,
timing: fabro_types::StageTiming::wall_only(5000),
status: "succeeded".into(),
preferred_label: None,
suggested_next_ids: Vec::new(),

View file

@ -850,7 +850,7 @@ mod tests {
);
assert_eq!(
worker_title_phase_for_event(&EventBody::RunCompleted(RunCompletedProps {
duration_ms: 10,
timing: fabro_types::RunTiming::wall_only(10),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
@ -868,7 +868,7 @@ mod tests {
reason: FailureReason::Cancelled,
detail: FailureDetail::new("cancelled", FailureCategory::Canceled),
},
duration_ms: 10,
timing: fabro_types::RunTiming::wall_only(10),
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
@ -882,7 +882,7 @@ mod tests {
reason: FailureReason::Terminated,
detail: FailureDetail::new("boom", FailureCategory::Deterministic),
},
duration_ms: 10,
timing: fabro_types::RunTiming::wall_only(10),
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,

View file

@ -82,7 +82,8 @@ fn build_json_output(
"status": run_status_kind(status),
});
if let Some(c) = conclusion {
value["duration_ms"] = c.duration_ms.into();
value["timing"] =
serde_json::to_value(c.timing).unwrap_or_else(|_| serde_json::Value::Null);
if let Some(total_usd_micros) = c
.billing
.as_ref()
@ -112,7 +113,7 @@ fn print_human_output(
let details = match conclusion {
Some(c) => {
let duration = format_duration_ms(c.duration_ms);
let duration = format_duration_ms(c.timing.wall_time_ms);
let cost = c
.billing
.as_ref()
@ -152,7 +153,7 @@ mod tests {
let conclusion = Conclusion {
timestamp: chrono::Utc::now(),
status: StageOutcome::Succeeded,
duration_ms: 12345,
timing: fabro_types::RunTiming::wall_only(12345),
failure: None,
final_git_commit_sha: None,
stages: vec![],
@ -177,7 +178,7 @@ mod tests {
);
assert_eq!(json["run_id"], run_id.to_string());
assert_eq!(json["status"], "succeeded");
assert_eq!(json["duration_ms"], 12345);
assert_eq!(json["timing"]["wall_time_ms"], 12345);
assert_eq!(json["total_usd_micros"], 420_000);
}
@ -193,7 +194,7 @@ mod tests {
);
assert_eq!(json["run_id"], run_id.to_string());
assert_eq!(json["status"], "failed");
assert!(json.get("duration_ms").is_none());
assert!(json.get("timing").is_none());
assert!(json.get("total_usd_micros").is_none());
}
@ -211,7 +212,7 @@ mod tests {
status: StageOutcome::Failed {
retry_requested: false,
},
duration_ms: 500,
timing: fabro_types::RunTiming::wall_only(500),
failure: Some(RunFailure {
reason: FailureReason::WorkflowError,
detail: FailureDetail::new("error", FailureCategory::Deterministic),
@ -230,7 +231,7 @@ mod tests {
Some(&conclusion),
);
assert!(json.get("total_usd_micros").is_none());
assert_eq!(json["duration_ms"], 500);
assert_eq!(json["timing"]["wall_time_ms"], 500);
}
#[test]
@ -240,7 +241,7 @@ mod tests {
let conclusion = Conclusion {
timestamp: chrono::Utc::now(),
status: StageOutcome::Succeeded,
duration_ms: 8000,
timing: fabro_types::RunTiming::wall_only(8000),
failure: None,
final_git_commit_sha: None,
stages: vec![],

View file

@ -54,7 +54,7 @@ pub(crate) async fn list_command(
"status": run.status(),
"start_time": run.start_time(),
"labels": run.labels(),
"duration_ms": run.duration_ms(),
"wall_time_ms": run.wall_time_ms(),
"total_usd_micros": run.total_usd_micros(),
"source_directory": run.source_directory(),
"repo_origin_url": run.repo_origin_url(),
@ -107,7 +107,7 @@ pub(crate) async fn list_command(
let rows: Vec<Vec<CellStruct>> = display_runs
.iter()
.map(|run| {
let duration_display = match run.duration_ms() {
let duration_display = match run.wall_time_ms() {
Some(ms) => format_duration_ms(ms),
None => match run.start_time_dt() {
Some(start) => {

View file

@ -77,8 +77,8 @@ impl ServerRunInfo {
&self.run.labels
}
pub(crate) fn duration_ms(&self) -> Option<u64> {
self.run.timestamps.duration_ms
pub(crate) fn wall_time_ms(&self) -> Option<u64> {
self.run.timing.as_ref().map(|t| t.wall_time_ms)
}
pub(crate) fn total_usd_micros(&self) -> Option<i64> {

View file

@ -1147,13 +1147,18 @@ fn attach_json_errors_without_prompting_for_human_input() {
"internal.run_id": "[ULID]",
"internal.thread_id": null
},
"duration_ms": "[DURATION_MS]",
"index": 0,
"max_attempts": 1,
"node_visits": {
"start": 1
},
"status": "succeeded"
"status": "succeeded",
"timing": {
"active_time_ms": 0,
"inference_time_ms": 0,
"tool_time_ms": 0,
"wall_time_ms": 0
}
},
"run_id": "[ULID]",
"stage_id": "start@1",

View file

@ -356,7 +356,7 @@ fn inspect_completed_run_shows_run_start_conclusion_checkpoint() {
},
"conclusion": {
"status": "succeeded",
"duration_ms": "[DURATION_MS]",
"timing": "[TIMING]",
"stage_count": null
},
"checkpoint": {
@ -428,7 +428,7 @@ fn inspect_completed_run_reads_store_without_disk_metadata_files() {
},
"conclusion": {
"status": "succeeded",
"duration_ms": "[DURATION_MS]",
"timing": "[TIMING]",
"stage_count": null
},
"checkpoint": {
@ -487,7 +487,7 @@ fn inspect_git_backed_run_exposes_checkpoint_and_sandbox_state() {
},
"conclusion": {
"status": "succeeded",
"duration_ms": "[DURATION_MS]",
"timing": "[TIMING]",
"final_git_commit_sha": "[SHA]",
"stage_count": null
},

View file

@ -57,7 +57,7 @@ fn remote_run_state_response(run_id: &str) -> serde_json::Value {
state["conclusion"] = serde_json::json!({
"timestamp": "2026-04-05T12:00:01Z",
"status": "succeeded",
"duration_ms": 12,
"timing": {"wall_time_ms": 12, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0},
"stages": [],
"billing": null,
"total_retries": 0,
@ -74,7 +74,7 @@ fn run_completed_event(run_id: &str) -> serde_json::Value {
"run_id": run_id,
"ts": "2026-04-05T12:00:01Z",
"properties": {
"duration_ms": 12,
"timing": {"wall_time_ms": 12, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0},
"artifact_count": 0,
"status": "succeeded",
"reason": "completed"

View file

@ -174,10 +174,9 @@ pub(crate) fn remote_run_summary_json(
"created_at": timestamp,
"started_at": timestamp,
"last_event_at": null,
"completed_at": null,
"duration_ms": null,
"elapsed_secs": null
"completed_at": null
},
"timing": null,
"billing": null,
"diff": null,
"pull_request": null,
@ -1108,7 +1107,7 @@ async fn append_seeded_simple_completion_events(
None,
"run.completed",
serde_json::json!({
"duration_ms": 123,
"timing": {"wall_time_ms": 123, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0},
"artifact_count": 0,
"status": "succeeded",
"reason": "completed",
@ -1274,7 +1273,7 @@ async fn append_seeded_git_completion_events(
None,
"run.completed",
serde_json::json!({
"duration_ms": 456,
"timing": {"wall_time_ms": 456, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0},
"artifact_count": 0,
"status": "succeeded",
"reason": "completed",
@ -1335,7 +1334,7 @@ async fn append_seeded_git_noop_events(
None,
"run.completed",
serde_json::json!({
"duration_ms": 123,
"timing": {"wall_time_ms": 123, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0},
"artifact_count": 0,
"status": "succeeded",
"reason": "completed",
@ -1395,7 +1394,7 @@ async fn append_seeded_artifact_run_events(
None,
"run.completed",
serde_json::json!({
"duration_ms": 123,
"timing": {"wall_time_ms": 123, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0},
"artifact_count": 6,
"status": "succeeded",
"reason": "completed",
@ -1544,7 +1543,7 @@ fn test_labels(context: &TestContext) -> Vec<String> {
fn stage_completed_properties(index: usize, response: Option<&str>) -> serde_json::Value {
serde_json::json!({
"index": index,
"duration_ms": 1,
"timing": {"wall_time_ms": 1, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0},
"status": "succeeded",
"preferred_label": null,
"suggested_next_ids": [],
@ -1741,7 +1740,7 @@ pub(crate) fn compact_inspect(output: &Output) -> Value {
"conclusion": conclusion.as_object().map(|_| {
serde_json::json!({
"status": conclusion["status"],
"duration_ms": "[DURATION_MS]",
"timing": "[TIMING]",
"stage_count": conclusion["stages"].as_array().map(|stages| stages.len()),
})
}),
@ -1802,7 +1801,7 @@ pub(crate) fn compact_git_inspect(output: &Output) -> Value {
"conclusion": conclusion.as_object().map(|_| {
serde_json::json!({
"status": conclusion["status"],
"duration_ms": "[DURATION_MS]",
"timing": "[TIMING]",
"final_git_commit_sha": "[SHA]",
"stage_count": conclusion["stages"].as_array().map(|stages| stages.len()),
})

View file

@ -96,8 +96,20 @@ fn wait_completed_run_json_outputs_status_and_duration() {
let run = setup_seeded_completed_dry_run(&context);
let mut filters = context.filters();
filters.push((
r#""duration_ms":\s*\d+"#.to_string(),
r#""duration_ms": [DURATION_MS]"#.to_string(),
r#""wall_time_ms":\s*\d+"#.to_string(),
r#""wall_time_ms": [WALL_TIME_MS]"#.to_string(),
));
filters.push((
r#""inference_time_ms":\s*\d+"#.to_string(),
r#""inference_time_ms": [INFERENCE_TIME_MS]"#.to_string(),
));
filters.push((
r#""tool_time_ms":\s*\d+"#.to_string(),
r#""tool_time_ms": [TOOL_TIME_MS]"#.to_string(),
));
filters.push((
r#""active_time_ms":\s*\d+"#.to_string(),
r#""active_time_ms": [ACTIVE_TIME_MS]"#.to_string(),
));
let mut cmd = context.command();
cmd.args(["wait", "--json", &run.run_id]);
@ -109,7 +121,12 @@ fn wait_completed_run_json_outputs_status_and_duration() {
{
"run_id": "[ULID]",
"status": "succeeded",
"duration_ms": [DURATION_MS]
"timing": {
"wall_time_ms": [WALL_TIME_MS],
"inference_time_ms": [INFERENCE_TIME_MS],
"tool_time_ms": [TOOL_TIME_MS],
"active_time_ms": [ACTIVE_TIME_MS]
}
}
----- stderr -----
"###);

View file

@ -21,7 +21,7 @@ fn run_sse_body(run_id: &str) -> String {
"run_id": run_id,
"ts": "2026-04-05T12:00:01Z",
"properties": {
"duration_ms": 12,
"timing": {"wall_time_ms": 12, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0},
"artifact_count": 0,
"status": "succeeded",
"reason": "completed"

View file

@ -37,8 +37,8 @@ fn scenario_full_stack(sandbox: &str) {
"conclusion: {conclusion}"
);
assert!(
conclusion["duration_ms"].as_u64().unwrap_or(0) > 0,
"duration_ms should be > 0"
conclusion["timing"]["wall_time_ms"].as_u64().unwrap_or(0) > 0,
"timing.wall_time_ms should be > 0"
);
// RunSpec should have key fields

View file

@ -14,9 +14,37 @@ use crate::lifecycle::{
AttemptContext, AttemptResultContext, EdgeContext, EdgeDecision, NodeDecision, NoopLifecycle,
RunLifecycle,
};
use crate::outcome::{NodeResult, NodeResultExt, Outcome};
use crate::outcome::{NodeResult, NodeResultExt, Outcome, OutcomeMeta};
use crate::state::ExecutionState;
/// Build a [`NodeResult`] from an attempt outcome, pulling the inference and
/// tool breakdown from `outcome.timing` when handlers populated it. The wall
/// time comes from the executor's stopwatch since that is the source of
/// authoritative per-attempt clock time.
fn node_result_from_outcome<M: OutcomeMeta>(
outcome: Outcome<M>,
wall_time: std::time::Duration,
attempts: u32,
max_attempts: u32,
) -> NodeResult<M> {
let inference_time = outcome
.timing
.map(|t| std::time::Duration::from_millis(t.inference_time_ms))
.unwrap_or_default();
let tool_time = outcome
.timing
.map(|t| std::time::Duration::from_millis(t.tool_time_ms))
.unwrap_or_default();
NodeResult::new(
outcome,
wall_time,
inference_time,
tool_time,
attempts,
max_attempts,
)
}
#[derive(Default)]
pub struct ExecutorOptions {
pub cancel_token: Option<CancellationToken>,
@ -269,9 +297,9 @@ impl<G: Graph + 'static> Executor<G> {
graph: &G,
) -> Result<NodeResult<G::Meta>> {
let policy = self.handler.retry_policy(node, graph);
let start = Instant::now();
for attempt in 1..=policy.max_attempts {
let attempt_start = Instant::now();
let attempt_ctx = AttemptContext {
node,
attempt,
@ -288,8 +316,12 @@ impl<G: Graph + 'static> Executor<G> {
match self.handler.execute(node, &state.context, graph).await {
Ok(outcome) if outcome.status.retry_requested() && can_retry => {
let delay = policy.backoff.delay_for_attempt(attempt);
let result =
NodeResult::new(outcome, start.elapsed(), attempt, policy.max_attempts);
let result = node_result_from_outcome(
outcome,
attempt_start.elapsed(),
attempt,
policy.max_attempts,
);
let ctx = AttemptResultContext {
node,
result: &result,
@ -302,9 +334,9 @@ impl<G: Graph + 'static> Executor<G> {
}
Ok(outcome) if outcome.status.retry_requested() => {
let final_outcome = self.handler.on_retries_exhausted(node, outcome);
let result = NodeResult::new(
let result = node_result_from_outcome(
final_outcome,
start.elapsed(),
attempt_start.elapsed(),
attempt,
policy.max_attempts,
);
@ -319,8 +351,12 @@ impl<G: Graph + 'static> Executor<G> {
return Ok(result);
}
Ok(outcome) => {
let result =
NodeResult::new(outcome, start.elapsed(), attempt, policy.max_attempts);
let result = node_result_from_outcome(
outcome,
attempt_start.elapsed(),
attempt,
policy.max_attempts,
);
let ctx = AttemptResultContext {
node,
result: &result,
@ -333,8 +369,12 @@ impl<G: Graph + 'static> Executor<G> {
}
Err(e) if can_retry && e.is_retryable() => {
let delay = policy.backoff.delay_for_attempt(attempt);
let fail_result =
NodeResult::from_error(&e, start.elapsed(), attempt, policy.max_attempts);
let fail_result = NodeResult::from_error(
&e,
attempt_start.elapsed(),
attempt,
policy.max_attempts,
);
let ctx = AttemptResultContext {
node,
result: &fail_result,
@ -348,8 +388,12 @@ impl<G: Graph + 'static> Executor<G> {
Err(e @ Error::Handler { .. }) => {
// Convert handler failures to fail outcomes so routing continues.
let outcome = e.to_fail_outcome();
let result =
NodeResult::new(outcome, start.elapsed(), attempt, policy.max_attempts);
let result = node_result_from_outcome(
outcome,
attempt_start.elapsed(),
attempt,
policy.max_attempts,
);
let ctx = AttemptResultContext {
node,
result: &result,
@ -1289,6 +1333,80 @@ mod tests {
assert_eq!(log, vec![(1, true), (2, false)]);
}
#[tokio::test]
async fn executor_retry_attempt_wall_time_excludes_prior_attempts_and_backoff() {
let wall_times = Arc::new(Mutex::new(Vec::<Duration>::new()));
struct WallTimeTracker(Arc<Mutex<Vec<Duration>>>);
#[async_trait]
impl RunLifecycle<TestGraph> for WallTimeTracker {
async fn after_attempt(
&self,
ctx: &AttemptResultContext<'_, TestGraph>,
_s: &ExecutionState,
) -> Result<()> {
self.0.lock().unwrap().push(ctx.result.wall_time);
Ok(())
}
}
struct SlowRetryThenSuccess(AtomicU32);
#[async_trait]
impl NodeHandler<TestGraph> for SlowRetryThenSuccess {
async fn execute(
&self,
_n: &TestNode,
_c: &Context,
_g: &TestGraph,
) -> Result<Outcome> {
sleep(Duration::from_millis(5)).await;
let call = self.0.fetch_add(1, Ordering::Relaxed);
if call == 0 {
Ok(Outcome {
status: StageOutcome::Failed {
retry_requested: true,
},
..Outcome::default()
})
} else {
Ok(Outcome::success())
}
}
fn retry_policy(&self, _n: &TestNode, _g: &TestGraph) -> RetryPolicy {
RetryPolicy {
max_attempts: 2,
backoff: BackoffPolicy {
initial_delay: Duration::from_millis(500),
factor: 1.0,
max_delay: Duration::from_millis(500),
jitter: false,
},
}
}
}
let g = linear_graph(&["start", "end"]);
let state = ExecutionState::new(&g).unwrap();
let executor =
ExecutorBuilder::new(Arc::new(SlowRetryThenSuccess(AtomicU32::new(0)))
as Arc<dyn NodeHandler<TestGraph>>)
.lifecycle(Box::new(WallTimeTracker(Arc::clone(&wall_times))))
.build();
executor.run(&g, state).await.unwrap();
let wall_times = wall_times.lock().unwrap().clone();
assert_eq!(wall_times.len(), 2);
for wall_time in wall_times {
assert!(wall_time >= Duration::from_millis(5));
assert!(
wall_time < Duration::from_millis(300),
"attempt wall time should not include retry backoff or prior attempts: {wall_time:?}"
);
}
}
#[tokio::test]
async fn executor_retry_lifecycle_before_attempt_skip_stops_retry() {
let call_count = Arc::new(std::sync::atomic::AtomicU32::new(0));

View file

@ -570,7 +570,14 @@ mod tests {
let g = linear_graph(&["start", "end"]);
let state = ExecutionState::new(&g).unwrap();
let node = g.get_node("start").unwrap();
let result = NodeResult::new(Outcome::success(), Duration::ZERO, 1, 1);
let result = NodeResult::new(
Outcome::success(),
Duration::ZERO,
Duration::ZERO,
Duration::ZERO,
1,
1,
);
let ctx = AttemptResultContext {
node: &node,
result: &result,
@ -667,7 +674,14 @@ mod tests {
let g = linear_graph(&["start", "end"]);
let state = ExecutionState::new(&g).unwrap();
let node = g.get_node("start").unwrap();
let mut result = NodeResult::new(Outcome::success(), Duration::ZERO, 1, 1);
let mut result = NodeResult::new(
Outcome::success(),
Duration::ZERO,
Duration::ZERO,
Duration::ZERO,
1,
1,
);
lc.after_node(&node, &mut result, &state).await.unwrap();
let calls = log.lock().unwrap().clone();
assert_eq!(calls, vec!["a:after_node", "b:after_node"]);
@ -683,7 +697,14 @@ mod tests {
let g = linear_graph(&["start", "end"]);
let state = ExecutionState::new(&g).unwrap();
let node = g.get_node("start").unwrap();
let result = NodeResult::new(Outcome::success(), Duration::ZERO, 1, 1);
let result = NodeResult::new(
Outcome::success(),
Duration::ZERO,
Duration::ZERO,
Duration::ZERO,
1,
1,
);
lc.after_record(&node, &result, &state).await.unwrap();
let calls = log.lock().unwrap().clone();
assert_eq!(calls, vec!["a:after_record", "b:after_record"]);

View file

@ -7,14 +7,16 @@ pub use fabro_types::outcome::{
use crate::error::Error;
pub trait NodeResultExt<M: OutcomeMeta = ()> {
fn from_error(error: &Error, duration: Duration, attempts: u32, max_attempts: u32) -> Self;
fn from_error(error: &Error, wall_time: Duration, attempts: u32, max_attempts: u32) -> Self;
}
impl<M: OutcomeMeta> NodeResultExt<M> for NodeResult<M> {
fn from_error(error: &Error, duration: Duration, attempts: u32, max_attempts: u32) -> Self {
fn from_error(error: &Error, wall_time: Duration, attempts: u32, max_attempts: u32) -> Self {
Self {
outcome: error.to_fail_outcome(),
duration,
wall_time,
inference_time: Duration::ZERO,
tool_time: Duration::ZERO,
attempts,
max_attempts,
}

View file

@ -111,7 +111,14 @@ mod tests {
fn run_state_record_updates_all_fields() {
let g = linear_graph(&["start", "end"]);
let mut state = ExecutionState::<()>::new(&g).unwrap();
let result = NodeResult::new(Outcome::success(), Duration::from_millis(50), 2, 3);
let result = NodeResult::new(
Outcome::success(),
Duration::from_millis(50),
Duration::ZERO,
Duration::ZERO,
2,
3,
);
state.record("start", &result);
assert_eq!(state.completed_nodes, vec!["start"]);
@ -126,7 +133,14 @@ mod tests {
let mut state = ExecutionState::<()>::new(&g).unwrap();
let mut outcome = Outcome::success();
outcome.context_updates.insert("key".into(), json!("value"));
let result = NodeResult::new(outcome, Duration::ZERO, 1, 1);
let result = NodeResult::new(
outcome,
Duration::ZERO,
Duration::ZERO,
Duration::ZERO,
1,
1,
);
state.record("start", &result);
assert_eq!(state.context.get("key"), Some(json!("value")));
}
@ -155,7 +169,14 @@ mod tests {
state.increment_visits("work");
state.record(
"start",
&NodeResult::new(Outcome::success(), Duration::ZERO, 1, 1),
&NodeResult::new(
Outcome::success(),
Duration::ZERO,
Duration::ZERO,
Duration::ZERO,
1,
1,
),
);
state.advance("work");

View file

@ -548,7 +548,7 @@ mod tests {
.single()
.unwrap(),
status: StageOutcome::Succeeded,
duration_ms: 5,
timing: fabro_types::RunTiming::wall_only(5),
failure: None,
final_git_commit_sha: Some("abc123".to_string()),
stages: Vec::new(),

View file

@ -1106,7 +1106,8 @@ mod runs {
let run_id = RunId::with_timestamp(created_at, sequence);
let source_directory = Some(format!("/demo/{repo_name}"));
let repo_origin_url = Some(format!("https://github.com/demo/{repo_name}.git"));
let duration_ms = elapsed_secs.and_then(duration_ms_from_secs);
let wall_time_ms = elapsed_secs.and_then(duration_ms_from_secs);
let timing = wall_time_ms.map(fabro_types::RunTiming::wall_only);
Run {
id: run_id,
parent_id: None,
@ -1145,9 +1146,8 @@ mod runs {
started_at: Some(created_at),
last_event_at: Some(created_at),
completed_at: Some(created_at),
duration_ms,
elapsed_secs,
},
timing,
billing: total_usd_micros.map(|total_usd_micros| RunBillingSummary {
total_usd_micros: Some(total_usd_micros),
}),
@ -1350,7 +1350,7 @@ mod runs {
&StageId::new("detect-drift", 1),
"Detect Drift",
StageState::Succeeded,
Some(72.0),
Some(72_000),
None,
StageHandler::Command,
),
@ -1358,7 +1358,7 @@ mod runs {
&StageId::new("propose-changes", 1),
"Propose Changes",
StageState::Succeeded,
Some(154.0),
Some(154_000),
None,
StageHandler::Agent,
),
@ -1366,7 +1366,7 @@ mod runs {
&StageId::new("review-changes", 1),
"Review Changes",
StageState::Succeeded,
Some(45.0),
Some(45_000),
None,
StageHandler::Agent,
),
@ -1374,7 +1374,7 @@ mod runs {
&StageId::new("apply-changes", 1),
"Apply Changes",
StageState::Succeeded,
Some(118.0),
Some(118_000),
None,
StageHandler::Command,
),
@ -1512,15 +1512,15 @@ mod runs {
RunBilling {
stages: vec![
RunBillingStage {
stage: BillingStageRef {
stage: BillingStageRef {
id: "detect-drift".into(),
name: "Detect Drift".into(),
},
model: Some(billing_model(
model: Some(billing_model(
fabro_model::ProviderId::anthropic(),
"claude-opus-4-6",
)),
billing: BilledTokenCounts {
billing: BilledTokenCounts {
cache_read_tokens: 0,
cache_write_tokens: 0,
input_tokens: 12480,
@ -1529,20 +1529,20 @@ mod runs {
total_tokens: 15690,
total_usd_micros: Some(480_000),
},
runtime_secs: 72.0,
started_at: None,
state: Some(StageState::Succeeded),
timing: fabro_types::StageTiming::wall_only(72_000),
started_at: None,
state: Some(StageState::Succeeded),
},
RunBillingStage {
stage: BillingStageRef {
stage: BillingStageRef {
id: "propose-changes".into(),
name: "Propose Changes".into(),
},
model: Some(billing_model(
model: Some(billing_model(
fabro_model::ProviderId::gemini(),
"gemini-3.1-pro-preview",
)),
billing: BilledTokenCounts {
billing: BilledTokenCounts {
cache_read_tokens: 0,
cache_write_tokens: 0,
input_tokens: 28640,
@ -1551,20 +1551,20 @@ mod runs {
total_tokens: 37390,
total_usd_micros: Some(720_000),
},
runtime_secs: 154.0,
started_at: None,
state: Some(StageState::Succeeded),
timing: fabro_types::StageTiming::wall_only(154_000),
started_at: None,
state: Some(StageState::Succeeded),
},
RunBillingStage {
stage: BillingStageRef {
stage: BillingStageRef {
id: "review-changes".into(),
name: "Review Changes".into(),
},
model: Some(billing_model(
model: Some(billing_model(
fabro_model::ProviderId::openai(),
"gpt-5.3-codex",
)),
billing: BilledTokenCounts {
billing: BilledTokenCounts {
cache_read_tokens: 0,
cache_write_tokens: 0,
input_tokens: 9120,
@ -1573,20 +1573,20 @@ mod runs {
total_tokens: 11760,
total_usd_micros: Some(190_000),
},
runtime_secs: 45.0,
started_at: None,
state: Some(StageState::Succeeded),
timing: fabro_types::StageTiming::wall_only(45_000),
started_at: None,
state: Some(StageState::Succeeded),
},
RunBillingStage {
stage: BillingStageRef {
stage: BillingStageRef {
id: "apply-changes".into(),
name: "Apply Changes".into(),
},
model: Some(billing_model(
model: Some(billing_model(
fabro_model::ProviderId::anthropic(),
"claude-opus-4-6",
)),
billing: BilledTokenCounts {
billing: BilledTokenCounts {
cache_read_tokens: 0,
cache_write_tokens: 0,
input_tokens: 21300,
@ -1595,15 +1595,15 @@ mod runs {
total_tokens: 27780,
total_usd_micros: Some(870_000),
},
runtime_secs: 118.0,
started_at: None,
state: Some(StageState::Running),
timing: fabro_types::StageTiming::wall_only(118_000),
started_at: None,
state: Some(StageState::Running),
},
],
totals: RunBillingTotals {
cache_read_tokens: 0,
cache_write_tokens: 0,
runtime_secs: 389.0,
timing: fabro_types::RunTiming::wall_only(389_000),
input_tokens: 71540,
output_tokens: 21080,
reasoning_tokens: 0,
@ -1987,7 +1987,7 @@ mod billing {
input_tokens: 643_860,
output_tokens: 189_720,
reasoning_tokens: 0,
runtime_secs: 3_501.0,
timing: fabro_types::RunTiming::wall_only(3_501_000),
total_tokens: 833_580,
total_usd_micros: Some(20_340_000),
},

View file

@ -2390,7 +2390,7 @@ index 1111111..2222222 160000
projection.conclusion = Some(fabro_types::Conclusion {
timestamp: chrono::Utc::now(),
status: fabro_types::StageOutcome::Succeeded,
duration_ms: 1,
timing: fabro_types::RunTiming::wall_only(1),
failure: None,
final_git_commit_sha: None,
stages: Vec::new(),

View file

@ -252,9 +252,9 @@ struct ModelBillingTotals {
/// In-memory aggregate billing counters, reset on server restart.
#[derive(Default)]
struct BillingAccumulator {
total_runs: i64,
total_runtime_secs: f64,
by_model: HashMap<ModelRef, ModelBillingTotals>,
total_runs: i64,
total_timing: fabro_types::RunTiming,
by_model: HashMap<ModelRef, ModelBillingTotals>,
}
pub(crate) type RegistryFactoryOverride =
@ -703,7 +703,7 @@ fn accumulate_billing_rollup(
rollup: &fabro_workflow::ProjectionBillingRollup,
) {
accumulator.total_runs += 1;
accumulator.total_runtime_secs += rollup.runtime_ms as f64 / 1000.0;
accumulator.total_timing = accumulator.total_timing.saturating_add(&rollup.timing);
for model in &rollup.by_model {
let entry = accumulator.by_model.entry(model.model.clone()).or_default();
entry.stages += model.stages;
@ -715,7 +715,7 @@ pub(crate) fn run_stage_from_stage_id(
stage_id: &StageId,
name: impl Into<String>,
status: StageState,
duration_secs: Option<f64>,
wall_time_ms: Option<u64>,
started_at: Option<chrono::DateTime<chrono::Utc>>,
handler: StageHandler,
) -> RunStage {
@ -724,7 +724,7 @@ pub(crate) fn run_stage_from_stage_id(
name: name.into(),
handler,
status,
duration_secs,
wall_time_ms,
node_id: stage_id.node_id().to_string(),
visit: std::num::NonZeroU32::new(stage_id.visit())
.expect("StageId stores a non-zero visit"),
@ -2237,7 +2237,13 @@ pub(crate) async fn reconcile_incomplete_runs_on_startup(
"Fabro server restarted before the run reached a terminal state.".to_string(),
);
let failure_event = workflow_event::Event::workflow_run_failed_from_error(
&error, 0, reason, None, None, None, None,
&error,
fabro_types::RunTiming::default(),
reason,
None,
None,
None,
None,
);
workflow_event::append_event(&run_store, &summary.id, &failure_event).await?;
reconciled += 1;
@ -2282,7 +2288,13 @@ async fn persist_shutdown_run_failures(
"Fabro server shut down before the run reached a terminal state.".to_string(),
);
let failure_event = workflow_event::Event::workflow_run_failed_from_error(
&error, 0, reason, None, None, None, None,
&error,
fabro_types::RunTiming::default(),
reason,
None,
None,
None,
None,
);
workflow_event::append_event(&run_store, &run_id, &failure_event).await?;
}
@ -2354,7 +2366,7 @@ async fn persist_cancelled_run_status(state: &AppState, run_id: RunId) -> anyhow
let failure_event = workflow_event::Event::workflow_run_failed_from_error(
&WorkflowError::Cancelled,
0,
fabro_types::RunTiming::default(),
FailureReason::Cancelled,
None,
None,
@ -2390,7 +2402,7 @@ async fn fail_run_before_execution(
Ok(run_store) => {
let failure_event = workflow_event::Event::workflow_run_failed_from_error(
&WorkflowError::engine(message.clone()),
0,
fabro_types::RunTiming::default(),
reason,
None,
None,
@ -2704,7 +2716,13 @@ async fn append_worker_exit_failure(
format!("Worker exited before emitting a terminal run event: {wait_status}"),
);
let failure_event = workflow_event::Event::workflow_run_failed_from_error(
&error, 0, reason, None, None, None, None,
&error,
fabro_types::RunTiming::default(),
reason,
None,
None,
None,
None,
);
if let Err(err) = workflow_event::append_event(run_store, &run_id, &failure_event).await {
@ -3365,7 +3383,7 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
let message = format!("Failed to spawn worker: {err}");
let failure_event = workflow_event::Event::workflow_run_failed_from_error(
&WorkflowError::engine_with_anyhow("Failed to spawn worker", err),
0,
fabro_types::RunTiming::default(),
FailureReason::LaunchFailed,
None,
None,
@ -3385,7 +3403,7 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
let _ = child.start_kill();
let failure_event = workflow_event::Event::workflow_run_failed_from_error(
&WorkflowError::engine(message.clone()),
0,
fabro_types::RunTiming::default(),
FailureReason::LaunchFailed,
None,
None,
@ -3413,7 +3431,7 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
let _ = child.start_kill();
let failure_event = workflow_event::Event::workflow_run_failed_from_error(
&WorkflowError::engine(message.clone()),
0,
fabro_types::RunTiming::default(),
FailureReason::LaunchFailed,
None,
None,
@ -3432,7 +3450,7 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
let _ = child.start_kill();
let failure_event = workflow_event::Event::workflow_run_failed_from_error(
&WorkflowError::engine(message.clone()),
0,
fabro_types::RunTiming::default(),
FailureReason::LaunchFailed,
None,
None,
@ -3464,7 +3482,7 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
let _ = child.start_kill();
let failure_event = workflow_event::Event::workflow_run_failed_from_error(
&WorkflowError::engine_with_source("Worker wait failed", err),
0,
fabro_types::RunTiming::default(),
FailureReason::Terminated,
None,
None,

View file

@ -2,7 +2,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use chrono::{DateTime, Utc};
use fabro_types::{RunProjection, StageHandler, StageProjection, StageState};
use fabro_types::{RunProjection, StageHandler, StageProjection, StageState, StageTiming};
use super::super::{
ApiError, AppState, BillingByModel, BillingStageRef, IntoResponse, Json, ListResponse,
@ -54,7 +54,7 @@ async fn list_run_stages(
stage_id,
stage_id.node_id().to_string(),
stage.effective_state(),
stage.runtime_secs(now),
stage.live_wall_time_ms(now),
stage.started_at,
handler,
)
@ -97,23 +97,25 @@ async fn get_run_billing(
.map(|stage| (stage.node_id.as_str(), stage))
.collect::<HashMap<_, _>>();
let live_rows = live_billing_rows(&projection, Utc::now());
let runtime_secs = live_rows.iter().map(|row| row.runtime_secs).sum::<f64>();
let totals_timing = live_rows.iter().fold(StageTiming::default(), |acc, row| {
acc.saturating_add(&row.timing)
});
let stages = live_rows
.into_iter()
.map(|row| {
let rollup_stage = rollup_by_node.get(row.node_id.as_str());
RunBillingStage {
billing: rollup_stage
billing: rollup_stage
.map(|stage| stage.billing.clone())
.unwrap_or_default(),
model: rollup_stage.and_then(|stage| stage.model.as_ref()).cloned(),
runtime_secs: row.runtime_secs,
stage: BillingStageRef {
model: rollup_stage.and_then(|stage| stage.model.as_ref()).cloned(),
timing: row.timing,
stage: BillingStageRef {
id: row.node_id.clone(),
name: row.node_id,
},
started_at: row.started_at,
state: row.state,
started_at: row.started_at,
state: row.state,
}
})
.collect::<Vec<_>>();
@ -122,14 +124,14 @@ async fn get_run_billing(
by_model,
stages,
totals: RunBillingTotals {
cache_read_tokens: rollup.totals.cache_read_tokens,
cache_read_tokens: rollup.totals.cache_read_tokens,
cache_write_tokens: rollup.totals.cache_write_tokens,
input_tokens: rollup.totals.input_tokens,
output_tokens: rollup.totals.output_tokens,
reasoning_tokens: rollup.totals.reasoning_tokens,
runtime_secs,
total_tokens: rollup.totals.total_tokens,
total_usd_micros: rollup.totals.total_usd_micros,
input_tokens: rollup.totals.input_tokens,
output_tokens: rollup.totals.output_tokens,
reasoning_tokens: rollup.totals.reasoning_tokens,
timing: totals_timing.into(),
total_tokens: rollup.totals.total_tokens,
total_usd_micros: rollup.totals.total_usd_micros,
},
};
@ -138,7 +140,7 @@ async fn get_run_billing(
struct LiveBillingRow {
node_id: String,
runtime_secs: f64,
timing: StageTiming,
started_at: Option<DateTime<Utc>>,
state: Option<StageState>,
latest_visit: u32,
@ -158,7 +160,7 @@ fn live_billing_rows(projection: &RunProjection, now: DateTime<Utc>) -> Vec<Live
let index = rows.len();
rows.push(LiveBillingRow {
node_id: node_id.to_string(),
runtime_secs: 0.0,
timing: StageTiming::default(),
started_at: None,
state: None,
latest_visit: 0,
@ -166,7 +168,8 @@ fn live_billing_rows(projection: &RunProjection, now: DateTime<Utc>) -> Vec<Live
index
});
let row = &mut rows[index];
row.runtime_secs += billing_runtime_secs(stage, now).unwrap_or(0.0);
let stage_timing = billing_stage_timing(stage, now);
row.timing = row.timing.saturating_add(&stage_timing);
if stage_id.visit() >= row.latest_visit {
row.latest_visit = stage_id.visit();
@ -178,16 +181,23 @@ fn live_billing_rows(projection: &RunProjection, now: DateTime<Utc>) -> Vec<Live
rows
}
fn billing_runtime_secs(stage: &StageProjection, now: DateTime<Utc>) -> Option<f64> {
stage
.duration_ms
.map(|ms| ms as f64 / 1000.0)
.or_else(|| stage.runtime_secs(now))
/// Per-visit timing for a stage. For terminal visits, the stored breakdown is
/// used directly. For in-flight visits, fall back to the live wall-clock since
/// `started_at` (no active breakdown yet — that is only finalized at terminal
/// event time in v1).
fn billing_stage_timing(stage: &StageProjection, now: DateTime<Utc>) -> StageTiming {
if let Some(timing) = stage.timing {
return timing;
}
if let Some(live_wall) = stage.live_wall_time_ms(now) {
return StageTiming::wall_only(live_wall);
}
StageTiming::default()
}
fn stage_has_billing_row(stage: &StageProjection) -> bool {
stage.completion.is_some()
|| stage.duration_ms.is_some()
|| stage.timing.is_some()
|| !stage.usage.is_zero()
|| stage.started_at.is_some()
}

View file

@ -541,7 +541,7 @@ async fn get_aggregate_billing(
output_tokens: total_billing.output_tokens,
reasoning_tokens: total_billing.reasoning_tokens,
runs: agg.total_runs,
runtime_secs: agg.total_runtime_secs,
timing: agg.total_timing,
total_tokens: total_billing.total_tokens,
total_usd_micros: total_billing.total_usd_micros,
},

View file

@ -2743,7 +2743,7 @@ async fn persist_cancelled_run_status_ignores_already_terminal_runs() {
let run_id = fixtures::RUN_1;
create_durable_run_with_events(&state, run_id, &[
workflow_event::Event::WorkflowRunCompleted {
duration_ms: 1000,
timing: fabro_types::RunTiming::wall_only(1000),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
@ -2779,7 +2779,7 @@ async fn delete_terminal_managed_run_does_not_send_cancel_signal() {
let run_id = fixtures::RUN_1;
create_durable_run_with_events(&state, run_id, &[
workflow_event::Event::WorkflowRunCompleted {
duration_ms: 1000,
timing: fabro_types::RunTiming::wall_only(1000),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
@ -2889,7 +2889,7 @@ async fn list_run_stages_projects_retrying_until_completion() {
node_id: "setup".to_string(),
name: "Setup".to_string(),
index: 0,
duration_ms: 5,
timing: fabro_types::StageTiming::wall_only(5),
status: "succeeded".to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),
@ -2930,14 +2930,14 @@ async fn list_run_stages_projects_retrying_until_completion() {
"work",
1,
&workflow_event::Event::StageFailed {
node_id: "work".to_string(),
name: "Work".to_string(),
index: 1,
failure: FailureDetail::new("try again", FailureCategory::TransientInfra),
will_retry: true,
duration_ms: 10,
billing: None,
actor: None,
node_id: "work".to_string(),
name: "Work".to_string(),
index: 1,
failure: FailureDetail::new("try again", FailureCategory::TransientInfra),
will_retry: true,
timing: fabro_types::StageTiming::wall_only(10),
billing: None,
actor: None,
},
)
.await;
@ -2981,7 +2981,7 @@ async fn list_run_stages_projects_retrying_until_completion() {
node_id: "work".to_string(),
name: "Work".to_string(),
index: 1,
duration_ms: 25,
timing: fabro_types::StageTiming::wall_only(25),
status: "partially_succeeded".to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),
@ -3111,7 +3111,7 @@ async fn list_run_stages_distinguishes_visits() {
node_id: "verify".to_string(),
name: "Verify".to_string(),
index: 1,
duration_ms: 1500,
timing: fabro_types::StageTiming::wall_only(1500),
status: "failed".to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),
@ -3171,7 +3171,7 @@ async fn list_run_stages_distinguishes_visits() {
assert_eq!(first["visit"], 1);
assert_eq!(first["handler"], "command");
assert_eq!(first["status"], "failed");
assert_eq!(first["duration_secs"], 1.5);
assert_eq!(first["wall_time_ms"], 1500);
let second = stage_entry(&body, "verify@2");
assert_eq!(second["node_id"], "verify");
@ -3211,7 +3211,7 @@ async fn run_billing_dedups_retried_nodes_and_sums_their_durations() {
node_id: "verify".to_string(),
name: "Verify".to_string(),
index: 1,
duration_ms: 1500,
timing: fabro_types::StageTiming::wall_only(1500),
status: "failed".to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),
@ -3242,7 +3242,7 @@ async fn run_billing_dedups_retried_nodes_and_sums_their_durations() {
node_id: "verify".to_string(),
name: "Verify".to_string(),
index: 1,
duration_ms: 800,
timing: fabro_types::StageTiming::wall_only(800),
status: "succeeded".to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),
@ -3314,16 +3314,16 @@ async fn run_billing_dedups_retried_nodes_and_sums_their_durations() {
assert_eq!(stages[0]["stage"]["id"], "verify");
// Duration on the row is the sum across visits (1.5s + 0.8s = 2.3s).
assert!(
(stages[0]["runtime_secs"].as_f64().unwrap() - 2.3).abs() < f64::EPSILON,
stages[0]["timing"]["wall_time_ms"].as_u64().unwrap() == 2300,
"row runtime_secs should sum visits, got {}",
stages[0]["runtime_secs"]
stages[0]["timing"]["wall_time_ms"]
);
// Totals must not double-count: a single 2.3s, not 4.6s.
assert!(
(body["totals"]["runtime_secs"].as_f64().unwrap() - 2.3).abs() < f64::EPSILON,
body["totals"]["timing"]["wall_time_ms"].as_u64().unwrap() == 2300,
"totals.runtime_secs should sum visits exactly once, got {}",
body["totals"]["runtime_secs"]
body["totals"]["timing"]["wall_time_ms"]
);
}
@ -3350,14 +3350,14 @@ async fn run_billing_sums_usage_across_retry_visits_and_uses_latest_model() {
"verify",
1,
&workflow_event::Event::StageFailed {
node_id: "verify".to_string(),
name: "Verify".to_string(),
index: 1,
failure: FailureDetail::new("try again", FailureCategory::TransientInfra),
will_retry: true,
duration_ms: 1200,
billing: Some(failed_usage),
actor: None,
node_id: "verify".to_string(),
name: "Verify".to_string(),
index: 1,
failure: FailureDetail::new("try again", FailureCategory::TransientInfra),
will_retry: true,
timing: fabro_types::StageTiming::wall_only(1200),
billing: Some(failed_usage),
actor: None,
},
)
.await;
@ -3370,7 +3370,7 @@ async fn run_billing_sums_usage_across_retry_visits_and_uses_latest_model() {
node_id: "verify".to_string(),
name: "Verify".to_string(),
index: 1,
duration_ms: 800,
timing: fabro_types::StageTiming::wall_only(800),
status: "succeeded".to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),
@ -3393,7 +3393,7 @@ async fn run_billing_sums_usage_across_retry_visits_and_uses_latest_model() {
let mut latest_outcome: Outcome<Option<fabro_model::BilledModelUsage>> = Outcome::success();
latest_outcome.usage = Some(success_usage);
latest_outcome.duration_ms = Some(800);
latest_outcome.timing = Some(fabro_types::StageTiming::wall_only(800));
let run_store = state.store.open_run(&run_id).await.unwrap();
workflow_event::append_event(
&run_store,
@ -3442,12 +3442,12 @@ async fn run_billing_sums_usage_across_retry_visits_and_uses_latest_model() {
assert_eq!(stages[0]["billing"]["input_tokens"], 300);
assert_eq!(stages[0]["billing"]["output_tokens"], 30);
assert_eq!(stages[0]["billing"]["total_usd_micros"], 330);
assert!((stages[0]["runtime_secs"].as_f64().unwrap() - 2.0).abs() < f64::EPSILON);
assert!(stages[0]["timing"]["wall_time_ms"].as_u64().unwrap() == 2000);
assert_eq!(body["totals"]["input_tokens"], 300);
assert_eq!(body["totals"]["output_tokens"], 30);
assert_eq!(body["totals"]["total_usd_micros"], 330);
assert!((body["totals"]["runtime_secs"].as_f64().unwrap() - 2.0).abs() < f64::EPSILON);
assert!(body["totals"]["timing"]["wall_time_ms"].as_u64().unwrap() == 2000);
let by_model = body["by_model"].as_array().unwrap();
assert_eq!(by_model.len(), 2);
@ -3503,14 +3503,14 @@ async fn list_run_stages_shows_retrying_after_failed_event() {
"work",
1,
&workflow_event::Event::StageFailed {
node_id: "work".to_string(),
name: "Work".to_string(),
index: 0,
failure: FailureDetail::new("flake", FailureCategory::TransientInfra),
will_retry: true,
duration_ms: 5,
billing: None,
actor: None,
node_id: "work".to_string(),
name: "Work".to_string(),
index: 0,
failure: FailureDetail::new("flake", FailureCategory::TransientInfra),
will_retry: true,
timing: fabro_types::StageTiming::wall_only(5),
billing: None,
actor: None,
},
)
.await;
@ -3583,14 +3583,14 @@ async fn list_run_stages_shows_retrying_when_failed_will_retry() {
"work",
1,
&workflow_event::Event::StageFailed {
node_id: "work".to_string(),
name: "Work".to_string(),
index: 0,
failure: FailureDetail::new("flake", FailureCategory::TransientInfra),
will_retry: true,
duration_ms: 5,
billing: None,
actor: None,
node_id: "work".to_string(),
name: "Work".to_string(),
index: 0,
failure: FailureDetail::new("flake", FailureCategory::TransientInfra),
will_retry: true,
timing: fabro_types::StageTiming::wall_only(5),
billing: None,
actor: None,
},
)
.await;
@ -3631,14 +3631,14 @@ async fn run_billing_retried_node_then_succeeded_emits_one_row_with_final_attemp
max_attempts: 3,
},
workflow_event::Event::StageFailed {
node_id: "work".to_string(),
name: "Work".to_string(),
index: 0,
failure: FailureDetail::new("transient", FailureCategory::TransientInfra),
will_retry: true,
duration_ms: 10,
billing: None,
actor: None,
node_id: "work".to_string(),
name: "Work".to_string(),
index: 0,
failure: FailureDetail::new("transient", FailureCategory::TransientInfra),
will_retry: true,
timing: fabro_types::StageTiming::wall_only(10),
billing: None,
actor: None,
},
workflow_event::Event::StageRetrying {
node_id: "work".to_string(),
@ -3660,7 +3660,7 @@ async fn run_billing_retried_node_then_succeeded_emits_one_row_with_final_attemp
node_id: "work".to_string(),
name: "Work".to_string(),
index: 0,
duration_ms: 25,
timing: fabro_types::StageTiming::wall_only(25),
status: "succeeded".to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),
@ -3700,9 +3700,9 @@ async fn run_billing_retried_node_then_succeeded_emits_one_row_with_final_attemp
row["state"], "succeeded",
"final state mirrors the latest StageCompleted"
);
let runtime = row["runtime_secs"].as_f64().unwrap();
assert!(
(runtime - 0.025).abs() < f64::EPSILON,
let runtime = row["timing"]["wall_time_ms"].as_u64().unwrap();
assert_eq!(
runtime, 25,
"runtime should equal final attempt's 25ms, got {runtime}"
);
}
@ -3729,7 +3729,7 @@ fn revisit_test_completed_with_visit(
node_id: node_id.to_string(),
name: node_id.to_string(),
index: 0,
duration_ms,
timing: fabro_types::StageTiming::wall_only(duration_ms),
status: "succeeded".to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),
@ -3790,14 +3790,14 @@ async fn run_billing_revisited_node_collapses_to_two_rows_with_summed_visit_dura
"A appeared first → A's row first"
);
assert_eq!(stages[1]["stage"]["id"], "b");
let a_runtime = stages[0]["runtime_secs"].as_f64().unwrap();
assert!(
(a_runtime - 0.1).abs() < f64::EPSILON,
let a_runtime = stages[0]["timing"]["wall_time_ms"].as_u64().unwrap();
assert_eq!(
a_runtime, 100,
"A should sum both visit durations (1ms + 99ms), got {a_runtime}"
);
let b_runtime = stages[1]["runtime_secs"].as_f64().unwrap();
assert!(
(b_runtime - 0.002).abs() < f64::EPSILON,
let b_runtime = stages[1]["timing"]["wall_time_ms"].as_u64().unwrap();
assert_eq!(
b_runtime, 2,
"B should carry its single visit's duration (2ms), got {b_runtime}"
);
}
@ -3843,7 +3843,12 @@ async fn create_unreadable_durable_run(state: &Arc<AppState>, run_id: RunId) {
"run_id": run_id,
"event": "run.completed",
"properties": {
"duration_ms": 1,
"timing": {
"wall_time_ms": 1,
"inference_time_ms": 0,
"tool_time_ms": 0,
"active_time_ms": 0
},
"artifact_count": 0,
"status": "legacy-status",
"reason": "completed",
@ -4086,7 +4091,7 @@ async fn create_completed_run_ready_for_pull_request(
goal: Some("Ship the server-side PR".to_string()),
},
workflow_event::Event::WorkflowRunCompleted {
duration_ms: 1,
timing: fabro_types::RunTiming::wall_only(1),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
@ -7785,7 +7790,7 @@ async fn patch_run_title_updates_active_and_archived_runs() {
&run_store,
&run_id,
&workflow_event::Event::WorkflowRunCompleted {
duration_ms: 1,
timing: fabro_types::RunTiming::wall_only(1),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
@ -7933,7 +7938,7 @@ async fn cancel_terminal_durable_run_returns_conflict() {
let run_id = fixtures::RUN_1;
create_durable_run_with_events(&state, run_id, &[
workflow_event::Event::WorkflowRunCompleted {
duration_ms: 1000,
timing: fabro_types::RunTiming::wall_only(1000),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
@ -7983,7 +7988,7 @@ async fn steer_terminal_durable_run_returns_run_not_steerable() {
let run_id = fixtures::RUN_1;
create_durable_run_with_events(&state, run_id, &[
workflow_event::Event::WorkflowRunCompleted {
duration_ms: 1000,
timing: fabro_types::RunTiming::wall_only(1000),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
@ -8402,7 +8407,7 @@ async fn active_acp_steerable_marker_clears_on_terminal_paths() {
node_id: "agent".to_string(),
name: "agent".to_string(),
index: 0,
duration_ms: 1,
timing: fabro_types::StageTiming::wall_only(1),
status: "success".to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),
@ -8421,14 +8426,14 @@ async fn active_acp_steerable_marker_clears_on_terminal_paths() {
max_attempts: 1,
},
workflow_event::Event::StageFailed {
node_id: "agent".to_string(),
name: "agent".to_string(),
index: 0,
failure: FailureDetail::new("failed", FailureCategory::Deterministic),
will_retry: false,
duration_ms: 1,
billing: None,
actor: None,
node_id: "agent".to_string(),
name: "agent".to_string(),
index: 0,
failure: FailureDetail::new("failed", FailureCategory::Deterministic),
will_retry: false,
timing: fabro_types::StageTiming::wall_only(1),
billing: None,
actor: None,
},
];
@ -8837,7 +8842,7 @@ async fn archive_and_unarchive_updates_listing_visibility() {
workflow_event::Event::RunStarting,
workflow_event::Event::RunRunning,
workflow_event::Event::WorkflowRunCompleted {
duration_ms: 1000,
timing: fabro_types::RunTiming::wall_only(1000),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
@ -9171,7 +9176,7 @@ async fn delete_run_retry_after_missing_provider_resource_removes_metadata() {
primary_repo_link: None,
},
workflow_event::Event::WorkflowRunCompleted {
duration_ms: 1,
timing: fabro_types::RunTiming::wall_only(1),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
@ -9299,7 +9304,10 @@ async fn get_aggregate_billing_returns_zeros_initially() {
assert_eq!(body["totals"]["runs"].as_i64().unwrap(), 0);
assert_eq!(body["totals"]["input_tokens"].as_i64().unwrap(), 0);
assert_eq!(body["totals"]["output_tokens"].as_i64().unwrap(), 0);
assert_eq!(body["totals"]["runtime_secs"].as_f64().unwrap(), 0.0);
assert_eq!(
body["totals"]["timing"]["wall_time_ms"].as_u64().unwrap(),
0
);
assert!(body["totals"]["total_usd_micros"].is_null());
assert!(body["by_model"].as_array().unwrap().is_empty());
}
@ -9434,14 +9442,14 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() {
},
},
],
runtime_ms: 2000,
timing: fabro_types::RunTiming::wall_only(2000),
billed_visit_count: 2,
};
accumulate_billing_rollup(&mut accumulator, &rollup);
assert_eq!(accumulator.total_runs, 1);
assert_eq!(accumulator.total_runtime_secs, 2.0);
assert_eq!(accumulator.total_timing.wall_time_ms, 2000);
assert_eq!(accumulator.by_model.len(), 2);
assert_eq!(
accumulator.by_model[&ModelRef {
@ -10671,7 +10679,7 @@ async fn boards_runs_excludes_archived_by_default() {
workflow_event::Event::RunStarting,
workflow_event::Event::RunRunning,
workflow_event::Event::WorkflowRunCompleted {
duration_ms: 1000,
timing: fabro_types::RunTiming::wall_only(1000),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
@ -10720,7 +10728,7 @@ async fn boards_runs_includes_archived_when_flag_set() {
workflow_event::Event::RunStarting,
workflow_event::Event::RunRunning,
workflow_event::Event::WorkflowRunCompleted {
duration_ms: 1000,
timing: fabro_types::RunTiming::wall_only(1000),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
@ -10740,7 +10748,7 @@ async fn boards_runs_includes_archived_when_flag_set() {
workflow_event::Event::RunStarting,
workflow_event::Event::RunRunning,
workflow_event::Event::WorkflowRunCompleted {
duration_ms: 1000,
timing: fabro_types::RunTiming::wall_only(1000),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
@ -10813,7 +10821,7 @@ async fn get_run_exposes_canonical_operator_statuses() {
workflow_event::Event::RunStarting,
workflow_event::Event::RunRunning,
workflow_event::Event::WorkflowRunCompleted {
duration_ms: 1000,
timing: fabro_types::RunTiming::wall_only(1000),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
@ -10898,7 +10906,7 @@ async fn boards_runs_maps_statuses_to_columns() {
workflow_event::Event::RunStarting,
workflow_event::Event::RunRunning,
workflow_event::Event::WorkflowRunCompleted {
duration_ms: 1000,
timing: fabro_types::RunTiming::wall_only(1000),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,

View file

@ -102,7 +102,7 @@ async fn append_completed_run_with_final_patch(
&run_store,
run_id,
&workflow_event::Event::WorkflowRunCompleted {
duration_ms: 1,
timing: fabro_types::RunTiming::wall_only(1),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,

View file

@ -133,12 +133,12 @@ fn assert_non_llm_billing(billing: &serde_json::Value, expected_stage_ids: &[&st
"every non-LLM stage should have null model and zero token counts: {stages:?}"
);
let runtime_secs: f64 = stages
let stage_wall_sum: u64 = stages
.iter()
.map(|stage| {
stage["runtime_secs"]
.as_f64()
.expect("stage should include runtime_secs")
stage["timing"]["wall_time_ms"]
.as_u64()
.expect("stage should include timing.wall_time_ms")
})
.sum();
@ -153,11 +153,11 @@ fn assert_non_llm_billing(billing: &serde_json::Value, expected_stage_ids: &[&st
assert_eq!(billing["totals"]["output_tokens"], 0);
assert!(billing["totals"]["total_usd_micros"].is_null());
let total_runtime_secs = billing["totals"]["runtime_secs"]
.as_f64()
.expect("totals should include runtime_secs");
assert!(
(total_runtime_secs - runtime_secs).abs() < f64::EPSILON,
"total runtime {total_runtime_secs} should equal summed stage runtime {runtime_secs}"
let total_wall_time_ms = billing["totals"]["timing"]["wall_time_ms"]
.as_u64()
.expect("totals should include timing.wall_time_ms");
assert_eq!(
total_wall_time_ms, stage_wall_sum,
"total wall_time_ms {total_wall_time_ms} should equal summed stage wall_time_ms {stage_wall_sum}"
);
}

View file

@ -333,7 +333,7 @@ impl RunProjectionReducer for RunProjection {
};
stage.response = response;
stage.completion = Some(completion);
stage.duration_ms = Some(props.duration_ms);
stage.timing = Some(props.timing);
if let Some(billing) = &props.billing {
stage.usage.replace_with_billed_usage(billing);
stage.model = Some(billing.model().clone());
@ -354,7 +354,7 @@ impl RunProjectionReducer for RunProjection {
failure_reason,
timestamp: ts,
});
stage.duration_ms = Some(props.duration_ms);
stage.timing = Some(props.timing);
if let Some(billing) = &props.billing {
stage.usage.replace_with_billed_usage(billing);
stage.model = Some(billing.model().clone());
@ -620,10 +620,10 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {
.conclusion
.as_ref()
.map(|conclusion| conclusion.timestamp);
let duration_ms = state
let run_timing = state
.conclusion
.as_ref()
.map(|conclusion| conclusion.duration_ms);
.map(|conclusion| conclusion.timing);
let total_usd_micros = state
.conclusion
.as_ref()
@ -669,9 +669,8 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {
started_at: start_time,
last_event_at: Some(state.last_event_at),
completed_at,
duration_ms,
elapsed_secs: elapsed_secs(duration_ms),
},
timing: run_timing,
billing: total_usd_micros.map(|total_usd_micros| RunBillingSummary {
total_usd_micros: Some(total_usd_micros),
}),
@ -703,10 +702,6 @@ fn run_models(state: &RunProjection) -> Vec<RunModel> {
models
}
fn elapsed_secs(duration_ms: Option<u64>) -> Option<f64> {
duration_ms.map(|ms| ms as f64 / 1000.0)
}
fn checkpoint_from_props(props: &CheckpointCompletedProps, timestamp: DateTime<Utc>) -> Checkpoint {
let loop_failure_signatures = props
.loop_failure_signatures
@ -751,7 +746,7 @@ fn conclusion_from_completed(
timestamp,
status: StageOutcome::from_str(&props.status)
.map_err(|err| Error::InvalidEvent(format!("invalid completed stage status: {err}")))?,
duration_ms: props.duration_ms,
timing: props.timing,
failure: None,
final_git_commit_sha: props.final_git_commit_sha.clone(),
stages: Vec::new(),
@ -770,7 +765,7 @@ fn conclusion_from_failed(props: &RunFailedProps, timestamp: DateTime<Utc>) -> C
status: StageOutcome::Failed {
retry_requested: false,
},
duration_ms: props.duration_ms,
timing: props.timing,
failure: Some(props.failure.clone()),
final_git_commit_sha: props.final_git_commit_sha.clone(),
stages: Vec::new(),
@ -811,7 +806,7 @@ fn stage_outcome_from_props(props: &StageCompletedProps) -> Outcome<Option<Bille
failure: props.failure.clone(),
usage: props.billing.clone(),
files_touched: props.files_touched.clone(),
duration_ms: Some(props.duration_ms),
timing: Some(props.timing),
}
}
@ -1467,7 +1462,7 @@ mod tests {
3,
EventBody::StageCompleted(StageCompletedProps {
index: 0,
duration_ms: 789,
timing: fabro_types::StageTiming::wall_only(789),
status: StageOutcome::Succeeded,
preferred_label: None,
suggested_next_ids: Vec::new(),
@ -1490,7 +1485,7 @@ mod tests {
.unwrap();
let stage = state.stage(&StageId::new("build", 1)).unwrap();
assert_eq!(stage.duration_ms, Some(789));
assert_eq!(stage.timing.map(|t| t.wall_time_ms), Some(789));
assert_eq!(stage.usage, usage_counts(&usage));
assert_eq!(stage.model.as_ref(), Some(usage.model()));
}
@ -1524,7 +1519,7 @@ mod tests {
"category": "transient_infra"
},
"will_retry": false,
"duration_ms": 654,
"timing": {"wall_time_ms": 654, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0},
"billing": usage_json(&usage)
}),
Some("build"),
@ -1532,7 +1527,7 @@ mod tests {
.unwrap();
let stage = state.stage(&stage_id).unwrap();
assert_eq!(stage.duration_ms, Some(654));
assert_eq!(stage.timing.map(|t| t.wall_time_ms), Some(654));
assert_eq!(stage.usage, usage_counts(&usage));
assert_eq!(stage.model.as_ref(), Some(usage.model()));
}
@ -1552,7 +1547,7 @@ mod tests {
seq,
EventBody::StageCompleted(StageCompletedProps {
index: 0,
duration_ms,
timing: fabro_types::StageTiming::wall_only(duration_ms),
status: StageOutcome::Succeeded,
preferred_label: None,
suggested_next_ids: Vec::new(),
@ -1577,10 +1572,10 @@ mod tests {
let first_stage = state.stage(&StageId::new("build", 1)).unwrap();
let second_stage = state.stage(&StageId::new("build", 2)).unwrap();
assert_eq!(first_stage.duration_ms, Some(111));
assert_eq!(first_stage.timing.map(|t| t.wall_time_ms), Some(111));
assert_eq!(first_stage.usage, usage_counts(&first_usage));
assert_eq!(first_stage.model.as_ref(), Some(first_usage.model()));
assert_eq!(second_stage.duration_ms, Some(222));
assert_eq!(second_stage.timing.map(|t| t.wall_time_ms), Some(222));
assert_eq!(second_stage.usage, usage_counts(&second_usage));
assert_eq!(second_stage.model.as_ref(), Some(second_usage.model()));
}
@ -1596,7 +1591,7 @@ mod tests {
3,
EventBody::StageCompleted(StageCompletedProps {
index: 0,
duration_ms: 333,
timing: fabro_types::StageTiming::wall_only(333),
status: StageOutcome::Succeeded,
preferred_label: None,
suggested_next_ids: Vec::new(),
@ -1623,7 +1618,7 @@ mod tests {
"legacy node_visits must not override stored stage_id"
);
let stage = state.stage(&scoped_stage_id).unwrap();
assert_eq!(stage.duration_ms, Some(333));
assert_eq!(stage.timing.map(|t| t.wall_time_ms), Some(333));
assert_eq!(stage.usage, usage_counts(&usage));
assert_eq!(stage.model.as_ref(), Some(usage.model()));
assert_eq!(stage.response.as_deref(), Some("done"));
@ -1639,14 +1634,14 @@ mod tests {
.apply_event(&test_stage_event(
3,
EventBody::StageFailed(StageFailedProps {
index: 0,
failure: Some(fabro_types::FailureDetail::new(
index: 0,
failure: Some(fabro_types::FailureDetail::new(
"try again",
fabro_types::FailureCategory::TransientInfra,
)),
will_retry: true,
duration_ms: 444,
billing: Some(usage.clone()),
will_retry: true,
timing: fabro_types::StageTiming::wall_only(444),
billing: Some(usage.clone()),
}),
scoped_stage_id.clone(),
))
@ -1657,7 +1652,7 @@ mod tests {
"current-visit fallback must not override stored stage_id"
);
let stage = state.stage(&scoped_stage_id).unwrap();
assert_eq!(stage.duration_ms, Some(444));
assert_eq!(stage.timing.map(|t| t.wall_time_ms), Some(444));
assert_eq!(stage.usage, usage_counts(&usage));
assert_eq!(stage.model.as_ref(), Some(usage.model()));
let completion = stage.completion.as_ref().unwrap();
@ -2145,7 +2140,7 @@ mod tests {
reason: FailureReason::WorkflowError,
detail: FailureDetail::new("boom", FailureCategory::Deterministic),
},
duration_ms: 42,
timing: fabro_types::RunTiming::wall_only(42),
final_git_commit_sha: Some("abc123".to_string()),
final_patch: Some(patch.to_string()),
diff_summary: None,
@ -2222,7 +2217,7 @@ mod tests {
5,
"run.completed",
&json!({
"duration_ms": 42,
"timing": {"wall_time_ms": 42, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0},
"artifact_count": 0,
"status": "succeeded",
"reason": "completed",
@ -2263,7 +2258,7 @@ mod tests {
"category": "deterministic"
}
},
"duration_ms": 42,
"timing": {"wall_time_ms": 42, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0},
"diff_summary": {
"files_changed": 5,
"additions": 20,
@ -2304,7 +2299,7 @@ mod tests {
detail
},
},
duration_ms: 42,
timing: fabro_types::RunTiming::wall_only(42),
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
@ -2345,7 +2340,7 @@ mod tests {
1,
EventBody::RunFailed(RunFailedProps {
failure: failure.clone(),
duration_ms: 42,
timing: fabro_types::RunTiming::wall_only(42),
final_git_commit_sha: Some("abc123".to_string()),
final_patch: None,
diff_summary: None,
@ -2372,7 +2367,7 @@ mod tests {
.apply_event(&test_event(
1,
EventBody::RunCompleted(RunCompletedProps {
duration_ms: 10,
timing: fabro_types::RunTiming::wall_only(10),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
@ -2538,7 +2533,7 @@ mod tests {
.apply_event(&test_event(
1,
EventBody::RunCompleted(RunCompletedProps {
duration_ms: 10,
timing: fabro_types::RunTiming::wall_only(10),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::PartialSuccess,
@ -2678,7 +2673,7 @@ mod tests {
.apply_event(&test_event(
1,
EventBody::RunCompleted(RunCompletedProps {
duration_ms: 10,
timing: fabro_types::RunTiming::wall_only(10),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
@ -2721,7 +2716,7 @@ mod tests {
index: 0,
failure: Some(FailureDetail::new("boom", FailureCategory::TransientInfra)),
will_retry,
duration_ms,
timing: fabro_types::StageTiming::wall_only(duration_ms),
billing: None,
}
}
@ -2738,7 +2733,7 @@ mod tests {
fn completed_props(duration_ms: u64, status: StageOutcome) -> StageCompletedProps {
StageCompletedProps {
index: 0,
duration_ms,
timing: fabro_types::StageTiming::wall_only(duration_ms),
status,
preferred_label: None,
suggested_next_ids: Vec::new(),
@ -3015,7 +3010,7 @@ mod tests {
.unwrap();
let stage = state.stage(&stage_id).unwrap();
assert_eq!(stage.duration_ms, Some(42));
assert_eq!(stage.timing.map(|t| t.wall_time_ms), Some(42));
assert_eq!(stage.usage, usage_counts(&usage));
assert_eq!(stage.model.as_ref(), Some(usage.model()));
assert_eq!(stage.state, StageState::Succeeded);
@ -3043,7 +3038,7 @@ mod tests {
.unwrap();
let stage = state.stage(&stage_id).unwrap();
assert_eq!(stage.duration_ms, Some(10));
assert_eq!(stage.timing.map(|t| t.wall_time_ms), Some(10));
assert_eq!(stage.state, StageState::Failed);
}
@ -3116,6 +3111,6 @@ mod tests {
assert_eq!(stage.state, StageState::Running);
// Prior attempt's terminal data must not leak into the new attempt.
assert!(stage.completion.is_none());
assert_eq!(stage.duration_ms, None);
assert_eq!(stage.timing.map(|t| t.wall_time_ms), None);
}
}

View file

@ -619,7 +619,7 @@ mod tests {
"2026-03-27T12:00:03Z",
"run.completed",
&serde_json::json!({
"duration_ms": 3210,
"timing": {"wall_time_ms": 3210, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0},
"artifact_count": 1,
"status": "succeeded",
"reason": "completed",
@ -993,7 +993,7 @@ mod tests {
"category": "canceled"
}
},
"duration_ms": 1,
"timing": {"wall_time_ms": 1, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0},
}),
))
.await
@ -1038,7 +1038,7 @@ mod tests {
"2026-03-27T12:00:04Z",
"run.completed",
&serde_json::json!({
"duration_ms": 3210,
"timing": {"wall_time_ms": 3210, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0},
"artifact_count": 1,
"status": "succeeded",
"reason": "completed",
@ -1397,7 +1397,7 @@ mod tests {
"category": "deterministic"
}
},
"duration_ms": 1,
"timing": {"wall_time_ms": 1, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0},
}),
))
.await

View file

@ -131,7 +131,7 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() {
stage.script_invocation = Some(json!({ "command": "cargo test" }));
stage.script_timing = Some(json!({ "duration_ms": 10 }));
stage.parallel_results = Some(json!([{ "stage": "fanout@1" }]));
stage.duration_ms = Some(1234);
stage.timing = Some(fabro_types::StageTiming::wall_only(1234));
let usage = sample_usage();
let usage_counts = BilledTokenCounts::from_billed_usage(std::slice::from_ref(&usage));
stage.usage = usage_counts.clone();
@ -186,7 +186,7 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() {
node.parallel_results,
Some(json!([{ "stage": "fanout@1" }]))
);
assert_eq!(node.duration_ms, Some(1234));
assert_eq!(node.timing.map(|t| t.wall_time_ms), Some(1234));
assert_eq!(node.usage, usage_counts);
assert_eq!(node.model.as_ref(), Some(usage.model()));
}

View file

@ -294,9 +294,8 @@ mod tests {
started_at: None,
last_event_at: None,
completed_at: None,
duration_ms: None,
elapsed_secs: None,
},
timing: None,
billing: None,
diff: None,
pull_request: None,

View file

@ -510,9 +510,8 @@ mod tests {
started_at: None,
last_event_at: None,
completed_at: None,
duration_ms: None,
elapsed_secs: None,
},
timing: None,
billing: None,
diff: None,
pull_request: None,

View file

@ -463,9 +463,8 @@ mod tests {
started_at: None,
last_event_at: None,
completed_at: None,
duration_ms: None,
elapsed_secs: None,
},
timing: None,
billing: None,
diff: None,
pull_request: None,

View file

@ -2,13 +2,15 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::outcome::StageOutcome;
use crate::{BilledTokenCounts, RunDiff, RunFailure};
use crate::{BilledTokenCounts, RunDiff, RunFailure, RunTiming, StageTiming};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StageSummary {
pub stage_id: String,
pub stage_label: String,
pub duration_ms: u64,
/// Per-node timing summed across every visit of the node within this
/// conclusion. `wall_time_ms` is the sum of visit wall times.
pub timing: StageTiming,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub billing_usd_micros: Option<i64>,
pub retries: u32,
@ -18,7 +20,9 @@ pub struct StageSummary {
pub struct Conclusion {
pub timestamp: DateTime<Utc>,
pub status: StageOutcome,
pub duration_ms: u64,
/// Run-level timing. `wall_time_ms` is the run's clock duration; active
/// fields sum work across stage visits and can exceed wall time.
pub timing: RunTiming,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure: Option<RunFailure>,
#[serde(default, skip_serializing_if = "Option::is_none")]

View file

@ -16,7 +16,8 @@ mod tests {
use super::EventEnvelope;
use crate::run_event::RunCompletedProps;
use crate::{
EventBody, ParallelBranchId, Principal, RunEvent, StageId, SuccessReason, fixtures,
EventBody, ParallelBranchId, Principal, RunEvent, RunTiming, StageId, SuccessReason,
fixtures,
};
#[test]
@ -35,7 +36,7 @@ mod tests {
tool_call_id: None,
actor: None,
body: EventBody::RunCompleted(RunCompletedProps {
duration_ms: 42,
timing: RunTiming::wall_only(42),
artifact_count: 0,
status: "success".to_string(),
reason: SuccessReason::Completed,
@ -79,7 +80,7 @@ mod tests {
model: Some("claude-sonnet".to_string()),
}),
body: EventBody::RunCompleted(RunCompletedProps {
duration_ms: 100,
timing: RunTiming::wall_only(100),
artifact_count: 1,
status: "success".to_string(),
reason: SuccessReason::Completed,

View file

@ -42,6 +42,7 @@ pub mod stage_id;
pub mod start;
pub mod status;
pub mod steering;
pub mod timing;
pub use artifact::ArtifactUpload;
pub use auth::{IdpIdentity, IdpIdentityError};
@ -131,3 +132,4 @@ pub use status::{
TerminalStatus,
};
pub use steering::SteeringMessage;
pub use timing::{RunTiming, StageTiming};

View file

@ -8,7 +8,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use strum::{Display, EnumString, IntoStaticStr};
use crate::{ExecOutputTail, FailureSignature, SystemActorKind};
use crate::{ExecOutputTail, FailureSignature, StageTiming, SystemActorKind};
pub trait OutcomeMeta:
Default + Clone + Send + Sync + fmt::Debug + Serialize + DeserializeOwned + 'static
@ -274,8 +274,13 @@ pub struct Outcome<M: OutcomeMeta = ()> {
pub usage: M,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files_touched: Vec<String>,
/// Stage timing breakdown captured by the workflow engine.
///
/// `None` until the stage produces a terminal outcome with a measured
/// wall time. Stage handlers that perform no inference or tool work
/// populate this with [`StageTiming::wall_only`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
pub timing: Option<StageTiming>,
}
impl<M: OutcomeMeta> Default for Outcome<M> {
@ -290,7 +295,7 @@ impl<M: OutcomeMeta> Default for Outcome<M> {
failure: None,
usage: M::default(),
files_touched: Vec::new(),
duration_ms: None,
timing: None,
}
}
}
@ -402,17 +407,35 @@ mod tests {
#[derive(Debug, Clone)]
pub struct NodeResult<M: OutcomeMeta = ()> {
pub outcome: Outcome<M>,
pub duration: Duration,
pub attempts: u32,
pub max_attempts: u32,
pub outcome: Outcome<M>,
/// Wall-clock time spent executing this node attempt (including handler
/// internal waits). Independent of the `inference_time` / `tool_time`
/// breakdown — those are work-only measurements.
pub wall_time: Duration,
/// Sum of LLM request/stream elapsed time across this node attempt. Zero
/// for non-LLM handlers.
pub inference_time: Duration,
/// Sum of tool/command execution elapsed time across this node attempt.
/// Zero for handlers that do not invoke tools or commands.
pub tool_time: Duration,
pub attempts: u32,
pub max_attempts: u32,
}
impl<M: OutcomeMeta> NodeResult<M> {
pub fn new(outcome: Outcome<M>, duration: Duration, attempts: u32, max_attempts: u32) -> Self {
pub fn new(
outcome: Outcome<M>,
wall_time: Duration,
inference_time: Duration,
tool_time: Duration,
attempts: u32,
max_attempts: u32,
) -> Self {
Self {
outcome,
duration,
wall_time,
inference_time,
tool_time,
attempts,
max_attempts,
}
@ -421,7 +444,9 @@ impl<M: OutcomeMeta> NodeResult<M> {
pub fn from_skip(outcome: Outcome<M>) -> Self {
Self {
outcome,
duration: Duration::ZERO,
wall_time: Duration::ZERO,
inference_time: Duration::ZERO,
tool_time: Duration::ZERO,
attempts: 0,
max_attempts: 0,
}

View file

@ -940,7 +940,7 @@ mod tests {
actor: None,
body: EventBody::StageCompleted(StageCompletedProps {
index: 1,
duration_ms: 1234,
timing: crate::StageTiming::wall_only(1234),
status: crate::StageOutcome::Succeeded,
preferred_label: None,
suggested_next_ids: vec!["next".to_string()],
@ -1135,7 +1135,12 @@ mod tests {
(
"run.completed",
json!({
"duration_ms": 42,
"timing": {
"wall_time_ms": 42,
"inference_time_ms": 0,
"tool_time_ms": 0,
"active_time_ms": 0
},
"artifact_count": 0,
"status": "succeeded",
"reason": "completed",
@ -1156,7 +1161,12 @@ mod tests {
"category": "deterministic"
}
},
"duration_ms": 42,
"timing": {
"wall_time_ms": 42,
"inference_time_ms": 0,
"tool_time_ms": 0,
"active_time_ms": 0
},
"diff_summary": {
"files_changed": 2,
"additions": 10,
@ -1214,7 +1224,7 @@ mod tests {
fn event_body_event_name_matches_wire_name() {
let body = EventBody::StageCompleted(StageCompletedProps {
index: 1,
duration_ms: 1234,
timing: crate::StageTiming::wall_only(1234),
status: crate::StageOutcome::Succeeded,
preferred_label: None,
suggested_next_ids: vec!["next".to_string()],

View file

@ -6,7 +6,7 @@ use super::{BilledTokenCounts, ExecOutputTail, RunNoticeLevel};
use crate::status::{BlockedReason, SuccessReason};
use crate::{
DiffSummary, ForkSourceRef, GitContext, Graph, PairId, PairTarget, RunBlobId, RunControlAction,
RunFailure, RunId, RunProvenance, WorkflowSettings,
RunFailure, RunId, RunProvenance, RunTiming, WorkflowSettings,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -183,7 +183,8 @@ pub struct RunUnarchivedProps {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunCompletedProps {
pub duration_ms: u64,
/// Run wall-clock time, with active timing breakdown for the run rollup.
pub timing: RunTiming,
pub artifact_count: usize,
pub status: String,
pub reason: SuccessReason,
@ -202,7 +203,8 @@ pub struct RunCompletedProps {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunFailedProps {
pub failure: RunFailure,
pub duration_ms: u64,
/// Run wall-clock time at failure, with active timing breakdown.
pub timing: RunTiming,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_git_commit_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]

View file

@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::ExecOutputTail;
use crate::{BilledModelUsage, DiffSummary, FailureDetail, Outcome, StageOutcome};
use crate::{BilledModelUsage, DiffSummary, FailureDetail, Outcome, StageOutcome, StageTiming};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StageStartedProps {
@ -17,7 +17,8 @@ pub struct StageStartedProps {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StageCompletedProps {
pub index: usize,
pub duration_ms: u64,
/// Per-attempt timing breakdown for this stage visit.
pub timing: StageTiming,
pub status: StageOutcome,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preferred_label: Option<String>,
@ -51,14 +52,15 @@ pub struct StageCompletedProps {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StageFailedProps {
pub index: usize,
pub index: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure: Option<FailureDetail>,
pub will_retry: bool,
pub failure: Option<FailureDetail>,
pub will_retry: bool,
/// Per-attempt timing breakdown for this stage visit.
#[serde(default)]
pub duration_ms: u64,
pub timing: StageTiming,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub billing: Option<BilledModelUsage>,
pub billing: Option<BilledModelUsage>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

View file

@ -7,7 +7,7 @@ use chrono::{DateTime, Utc};
use crate::{
BilledTokenCounts, Checkpoint, Conclusion, InterviewQuestionRecord, InvalidTransition,
ModelRef, PullRequestLink, RunControlAction, RunDiff, RunId, RunSandbox, RunSpec, RunStatus,
StageCompletion, StageHandler, StageId, StageState, StartRecord,
StageCompletion, StageHandler, StageId, StageState, StageTiming, StartRecord,
};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@ -71,8 +71,13 @@ pub struct StageProjection {
pub started_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handler: Option<StageHandler>,
/// Per-attempt timing breakdown for the latest terminal attempt.
///
/// `None` for stages still in flight (`started_at` is set but no terminal
/// event has been observed yet). For live wall-time ticking, the UI uses
/// `started_at`; once terminal this carries the finalized breakdown.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
pub timing: Option<StageTiming>,
#[serde(default)]
pub usage: BilledTokenCounts,
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -95,7 +100,7 @@ impl StageProjection {
prompt: None,
response: None,
completion: None,
duration_ms: None,
timing: None,
usage: BilledTokenCounts::default(),
model: None,
provider_used: None,
@ -119,26 +124,27 @@ impl StageProjection {
self.state
}
/// Live wall-clock runtime in seconds.
/// Live wall-clock time in milliseconds.
///
/// While the stage is non-terminal (`Pending`, `Running`, or `Retrying`),
/// this returns the elapsed time since `started_at` so the UI can tick
/// client-side. Once terminal, the stored `duration_ms` is returned. This
/// also handles retries safely: a new `StageStarted` resets the state
/// back to `Running` and keeps the live computation correct even if a
/// previous attempt left a stale `duration_ms`.
/// client-side. Once terminal, the stored `timing.wall_time_ms` is
/// returned. This also handles retries safely: a new `StageStarted` resets
/// the state back to `Running` and keeps the live computation correct
/// even if a previous attempt left stale timing.
#[must_use]
pub fn runtime_secs(&self, now: DateTime<Utc>) -> Option<f64> {
pub fn live_wall_time_ms(&self, now: DateTime<Utc>) -> Option<u64> {
let state = self.effective_state();
if matches!(
state,
StageState::Running | StageState::Retrying | StageState::Pending
) {
return self.started_at.map(|started| {
now.signed_duration_since(started).num_milliseconds().max(0) as f64 / 1000.0
u64::try_from(now.signed_duration_since(started).num_milliseconds().max(0))
.unwrap_or(0)
});
}
self.duration_ms.map(|ms| ms as f64 / 1000.0)
self.timing.map(|timing| timing.wall_time_ms)
}
/// Begin a new attempt (or visit) for this stage: clear every

View file

@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
use crate::{
DiffSummary, InterviewQuestionRecord, Principal, PullRequestLink, RepositoryRef,
RunControlAction, RunId, RunSandbox, RunStatus,
RunControlAction, RunId, RunSandbox, RunStatus, RunTiming,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -33,6 +33,10 @@ pub struct Run {
#[serde(default)]
pub source_directory: Option<String>,
pub timestamps: RunTimestamps,
/// Run-level timing rollup. `None` until the run has measurable timing
/// data; populated once a terminal event or partial rollup is available.
#[serde(default)]
pub timing: Option<RunTiming>,
#[serde(default)]
pub billing: Option<RunBillingSummary>,
#[serde(default)]
@ -123,10 +127,6 @@ pub struct RunTimestamps {
pub last_event_at: Option<DateTime<Utc>>,
#[serde(default)]
pub completed_at: Option<DateTime<Utc>>,
#[serde(default)]
pub duration_ms: Option<u64>,
#[serde(default)]
pub elapsed_secs: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

View file

@ -0,0 +1,252 @@
//! Wall and active timing primitives shared across stages and runs.
//!
//! Two value objects: [`StageTiming`] for one stage visit, [`RunTiming`] for a
//! run-level rollup. Both expose the same four fields:
//!
//! - `wall_time_ms`: elapsed clock time from start to finish.
//! - `inference_time_ms`: Fabro-observed LLM request/stream elapsed time.
//! - `tool_time_ms`: tool or command execution elapsed time.
//! - `active_time_ms`: `inference_time_ms + tool_time_ms`.
//!
//! `active_time_ms` is precomputed and serialized so API consumers do not need
//! to redo the addition. Use the `new` constructors to enforce the invariant.
//!
//! For parallel container stages the container reports `active = 0` and the
//! child branches carry their own work timing; run-level active time sums work
//! across stage visits and can exceed run wall time when work runs in parallel.
use serde::{Deserialize, Serialize};
/// Timing breakdown for one stage visit.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct StageTiming {
/// Wall-clock time from stage start to terminal event.
pub wall_time_ms: u64,
/// Fabro-observed LLM request/stream elapsed time.
#[serde(default)]
pub inference_time_ms: u64,
/// Tool or command execution elapsed time.
#[serde(default)]
pub tool_time_ms: u64,
/// `inference_time_ms + tool_time_ms`.
pub active_time_ms: u64,
}
impl StageTiming {
/// Construct a [`StageTiming`] with `active_time_ms` derived from the
/// breakdown.
#[must_use]
pub fn new(wall_time_ms: u64, inference_time_ms: u64, tool_time_ms: u64) -> Self {
let active_time_ms = inference_time_ms.saturating_add(tool_time_ms);
Self {
wall_time_ms,
inference_time_ms,
tool_time_ms,
active_time_ms,
}
}
/// Stages with no inference/tool work (human, wait, conditional, fan-in,
/// start, exit, parallel container) report wall time only.
#[must_use]
pub fn wall_only(wall_time_ms: u64) -> Self {
Self::new(wall_time_ms, 0, 0)
}
/// Sum two timings field-by-field. Used to aggregate visits of one node
/// and to accumulate run-level rollups.
#[must_use]
pub fn saturating_add(&self, other: &Self) -> Self {
Self::new(
self.wall_time_ms.saturating_add(other.wall_time_ms),
self.inference_time_ms
.saturating_add(other.inference_time_ms),
self.tool_time_ms.saturating_add(other.tool_time_ms),
)
}
}
/// Timing rollup for an entire run.
///
/// `wall_time_ms` is the run's clock duration from start to terminal event.
/// The other three fields sum work across stage visits, so `active_time_ms`
/// can exceed `wall_time_ms` when parallel branches run concurrently.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunTiming {
/// Wall-clock time from run start to terminal event.
pub wall_time_ms: u64,
/// Sum of inference time across every stage visit.
#[serde(default)]
pub inference_time_ms: u64,
/// Sum of tool time across every stage visit.
#[serde(default)]
pub tool_time_ms: u64,
/// `inference_time_ms + tool_time_ms`.
pub active_time_ms: u64,
}
impl RunTiming {
/// Construct a [`RunTiming`] with `active_time_ms` derived from the
/// breakdown.
#[must_use]
pub fn new(wall_time_ms: u64, inference_time_ms: u64, tool_time_ms: u64) -> Self {
let active_time_ms = inference_time_ms.saturating_add(tool_time_ms);
Self {
wall_time_ms,
inference_time_ms,
tool_time_ms,
active_time_ms,
}
}
/// Run-level rollups for runs with no inference/tool work yet recorded.
#[must_use]
pub fn wall_only(wall_time_ms: u64) -> Self {
Self::new(wall_time_ms, 0, 0)
}
/// Sum two run timings field-by-field. Used to accumulate aggregate
/// billing totals across completed runs.
#[must_use]
pub fn saturating_add(&self, other: &Self) -> Self {
Self::new(
self.wall_time_ms.saturating_add(other.wall_time_ms),
self.inference_time_ms
.saturating_add(other.inference_time_ms),
self.tool_time_ms.saturating_add(other.tool_time_ms),
)
}
/// Return a copy with `wall_time_ms` replaced. Useful at finalize time
/// when the active breakdown is summed across stage visits but the run
/// wall time is the executor's clock duration.
#[must_use]
pub fn with_wall_time(self, wall_time_ms: u64) -> Self {
Self {
wall_time_ms,
..self
}
}
}
impl From<StageTiming> for RunTiming {
fn from(stage: StageTiming) -> Self {
Self {
wall_time_ms: stage.wall_time_ms,
inference_time_ms: stage.inference_time_ms,
tool_time_ms: stage.tool_time_ms,
active_time_ms: stage.active_time_ms,
}
}
}
#[cfg(test)]
mod tests {
use super::{RunTiming, StageTiming};
#[test]
fn stage_timing_new_derives_active_as_sum_of_inference_and_tool() {
let timing = StageTiming::new(1000, 200, 300);
assert_eq!(timing.wall_time_ms, 1000);
assert_eq!(timing.inference_time_ms, 200);
assert_eq!(timing.tool_time_ms, 300);
assert_eq!(timing.active_time_ms, 500);
}
#[test]
fn stage_timing_wall_only_zeroes_breakdown_and_active() {
let timing = StageTiming::wall_only(750);
assert_eq!(timing.wall_time_ms, 750);
assert_eq!(timing.inference_time_ms, 0);
assert_eq!(timing.tool_time_ms, 0);
assert_eq!(timing.active_time_ms, 0);
}
#[test]
fn stage_timing_saturating_add_sums_all_breakdown_fields() {
let a = StageTiming::new(100, 30, 70);
let b = StageTiming::new(200, 50, 25);
let sum = a.saturating_add(&b);
assert_eq!(sum.wall_time_ms, 300);
assert_eq!(sum.inference_time_ms, 80);
assert_eq!(sum.tool_time_ms, 95);
assert_eq!(sum.active_time_ms, 175);
}
#[test]
fn run_timing_wall_only_zeroes_breakdown_and_active() {
let timing = RunTiming::wall_only(1500);
assert_eq!(timing.wall_time_ms, 1500);
assert_eq!(timing.inference_time_ms, 0);
assert_eq!(timing.tool_time_ms, 0);
assert_eq!(timing.active_time_ms, 0);
}
#[test]
fn run_timing_saturating_add_sums_all_breakdown_fields() {
let a = RunTiming::new(1000, 100, 50);
let b = RunTiming::new(500, 200, 150);
let sum = a.saturating_add(&b);
assert_eq!(sum.wall_time_ms, 1500);
assert_eq!(sum.inference_time_ms, 300);
assert_eq!(sum.tool_time_ms, 200);
assert_eq!(sum.active_time_ms, 500);
}
#[test]
fn run_timing_from_stage_timing_copies_all_fields() {
let stage = StageTiming::new(900, 200, 300);
let run: RunTiming = stage.into();
assert_eq!(run.wall_time_ms, 900);
assert_eq!(run.inference_time_ms, 200);
assert_eq!(run.tool_time_ms, 300);
assert_eq!(run.active_time_ms, 500);
}
#[test]
fn run_timing_with_wall_time_replaces_wall_and_preserves_breakdown() {
let original = RunTiming::new(1000, 200, 300);
let updated = original.with_wall_time(5000);
assert_eq!(updated.wall_time_ms, 5000);
assert_eq!(updated.inference_time_ms, 200);
assert_eq!(updated.tool_time_ms, 300);
assert_eq!(updated.active_time_ms, 500);
}
#[test]
fn stage_timing_round_trips_json_with_serialized_active_time() {
let original = StageTiming::new(900, 250, 350);
let json = serde_json::to_value(original).unwrap();
assert_eq!(json["wall_time_ms"], 900);
assert_eq!(json["inference_time_ms"], 250);
assert_eq!(json["tool_time_ms"], 350);
assert_eq!(json["active_time_ms"], 600);
let parsed: StageTiming = serde_json::from_value(json).unwrap();
assert_eq!(parsed, original);
}
#[test]
fn run_timing_round_trips_json_with_serialized_active_time() {
let original = RunTiming::new(2000, 600, 400);
let json = serde_json::to_value(original).unwrap();
assert_eq!(json["wall_time_ms"], 2000);
assert_eq!(json["inference_time_ms"], 600);
assert_eq!(json["tool_time_ms"], 400);
assert_eq!(json["active_time_ms"], 1000);
let parsed: RunTiming = serde_json::from_value(json).unwrap();
assert_eq!(parsed, original);
}
#[test]
fn stage_timing_breakdown_fields_default_when_missing_from_json() {
let json = serde_json::json!({
"wall_time_ms": 500,
"active_time_ms": 0
});
let parsed: StageTiming = serde_json::from_value(json).unwrap();
assert_eq!(parsed.wall_time_ms, 500);
assert_eq!(parsed.inference_time_ms, 0);
assert_eq!(parsed.tool_time_ms, 0);
assert_eq!(parsed.active_time_ms, 0);
}
}

View file

@ -1,7 +1,7 @@
use fabro_types::run_event::run::RunFailedProps;
use fabro_types::{
Conclusion, EventBody, ExecOutputTail, FailureCategory, FailureDetail, FailureReason,
FailureSignature, RunDiff, RunFailure, StageOutcome, SystemActorKind,
FailureSignature, RunDiff, RunFailure, RunTiming, StageOutcome, SystemActorKind,
};
use serde_json::json;
@ -32,7 +32,7 @@ fn run_failed_serializes_nested_failure_contract() {
detail
},
},
duration_ms: 42,
timing: RunTiming::wall_only(42),
final_git_commit_sha: Some("abc123".to_string()),
final_patch: Some("diff --git a/file b/file".to_string()),
diff_summary: None,
@ -63,7 +63,12 @@ fn run_failed_serializes_nested_failure_contract() {
}
}
},
"duration_ms": 42,
"timing": {
"wall_time_ms": 42,
"inference_time_ms": 0,
"tool_time_ms": 0,
"active_time_ms": 0
},
"final_git_commit_sha": "abc123",
"final_patch": "diff --git a/file b/file"
})
@ -81,7 +86,7 @@ fn run_failed_omits_empty_failure_optional_fields() {
reason: FailureReason::WorkflowError,
detail: FailureDetail::new("boom", FailureCategory::Deterministic),
},
duration_ms: 1,
timing: RunTiming::wall_only(1),
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
@ -100,7 +105,12 @@ fn run_failed_omits_empty_failure_optional_fields() {
"category": "deterministic"
}
},
"duration_ms": 1
"timing": {
"wall_time_ms": 1,
"inference_time_ms": 0,
"tool_time_ms": 0,
"active_time_ms": 0
}
})
);
}
@ -114,7 +124,7 @@ fn conclusion_serializes_rich_failure() {
status: StageOutcome::Failed {
retry_requested: false,
},
duration_ms: 42,
timing: RunTiming::wall_only(42),
failure: Some(RunFailure {
reason: FailureReason::WorkflowError,
detail: {

View file

@ -2,7 +2,9 @@ use std::borrow::Cow;
use std::collections::HashMap;
use fabro_model::Catalog;
use fabro_types::{BilledTokenCounts, ModelRef, RunProjection, StageProjection};
use fabro_types::{
BilledTokenCounts, ModelRef, RunProjection, RunTiming, StageProjection, StageTiming,
};
fn stage_usage_with_cost<'a>(
catalog: Option<&Catalog>,
@ -28,10 +30,13 @@ fn stage_usage_with_cost<'a>(
#[derive(Debug, Clone, PartialEq)]
pub struct ProjectionBillingStage {
pub node_id: String,
pub billing: BilledTokenCounts,
pub duration_ms: u64,
pub model: Option<ModelRef>,
pub node_id: String,
pub billing: BilledTokenCounts,
/// Per-node timing summed across every visit of that node within this
/// projection. `wall_time_ms`, `inference_time_ms`, `tool_time_ms`, and
/// `active_time_ms` are all summed in lockstep.
pub timing: StageTiming,
pub model: Option<ModelRef>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -46,7 +51,9 @@ pub struct ProjectionBillingRollup {
pub stages: Vec<ProjectionBillingStage>,
pub totals: BilledTokenCounts,
pub by_model: Vec<ProjectionBillingByModel>,
pub runtime_ms: u64,
/// Run-level timing summed across every stage visit. `wall_time_ms` is
/// the sum of stage visit wall times (not the run clock duration).
pub timing: RunTiming,
pub billed_visit_count: usize,
}
@ -66,7 +73,7 @@ pub fn billing_rollup_from_projection(
let mut stages = Vec::<ProjectionBillingStage>::new();
let mut by_model = HashMap::<ModelRef, ProjectionBillingByModel>::new();
let mut totals = BilledTokenCounts::default();
let mut runtime_ms = 0_u64;
let mut run_timing = RunTiming::default();
let mut billed_visit_count = 0_usize;
for (stage_id, stage) in projection.iter_stages() {
@ -75,7 +82,7 @@ pub fn billing_rollup_from_projection(
}
let usage = stage_usage_with_cost(catalog, stage);
let usage = usage.as_ref();
if stage.completion.is_none() && stage.duration_ms.is_none() && usage.is_zero() {
if stage.completion.is_none() && stage.timing.is_none() && usage.is_zero() {
continue;
}
@ -83,18 +90,18 @@ pub fn billing_rollup_from_projection(
let index = *stage_indices.entry(node_id.to_string()).or_insert_with(|| {
let index = stages.len();
stages.push(ProjectionBillingStage {
node_id: node_id.to_string(),
billing: BilledTokenCounts::default(),
duration_ms: 0,
model: None,
node_id: node_id.to_string(),
billing: BilledTokenCounts::default(),
timing: StageTiming::default(),
model: None,
});
index
});
let row = &mut stages[index];
if let Some(duration_ms) = stage.duration_ms {
row.duration_ms = row.duration_ms.saturating_add(duration_ms);
runtime_ms = runtime_ms.saturating_add(duration_ms);
if let Some(timing) = stage.timing {
row.timing = row.timing.saturating_add(&timing);
run_timing = run_timing.saturating_add(&RunTiming::from(timing));
}
if !usage.is_zero() {
@ -137,7 +144,7 @@ pub fn billing_rollup_from_projection(
stages,
totals,
by_model,
runtime_ms,
timing: run_timing,
billed_visit_count,
}
}
@ -198,7 +205,7 @@ mod tests {
let failed_usage = test_usage("gpt-old", 100, 10);
let success_usage = test_usage("gpt-new", 200, 20);
let first = projection.stage_entry("verify", 1, first_event_seq(1));
first.duration_ms = Some(1200);
first.timing = Some(fabro_types::StageTiming::wall_only(1200));
first.usage = BilledTokenCounts::from_billed_usage(std::slice::from_ref(&failed_usage));
first.model = Some(failed_usage.model().clone());
first.completion = Some(StageCompletion {
@ -210,7 +217,7 @@ mod tests {
timestamp: chrono::Utc::now(),
});
let second = projection.stage_entry("verify", 2, first_event_seq(2));
second.duration_ms = Some(800);
second.timing = Some(fabro_types::StageTiming::wall_only(800));
second.usage = BilledTokenCounts::from_billed_usage(std::slice::from_ref(&success_usage));
second.model = Some(success_usage.model().clone());
second.completion = Some(StageCompletion {
@ -231,12 +238,12 @@ mod tests {
.map(|model| model.model_id.as_str()),
Some("gpt-new")
);
assert_eq!(rollup.stages[0].duration_ms, 2000);
assert_eq!(rollup.stages[0].timing.wall_time_ms, 2000);
assert_eq!(rollup.stages[0].billing.input_tokens, 300);
assert_eq!(rollup.stages[0].billing.output_tokens, 30);
assert_eq!(rollup.stages[0].billing.total_usd_micros, Some(330));
assert_eq!(rollup.runtime_ms, 2000);
assert_eq!(rollup.timing.wall_time_ms, 2000);
assert_eq!(rollup.totals.input_tokens, 300);
assert_eq!(rollup.totals.output_tokens, 30);
assert_eq!(rollup.totals.total_usd_micros, Some(330));
@ -255,7 +262,7 @@ mod tests {
fn rollup_includes_completed_non_llm_stage_rows_with_zero_billing() {
let mut projection = test_projection();
let stage = projection.stage_entry("build", 1, first_event_seq(1));
stage.duration_ms = Some(25);
stage.timing = Some(fabro_types::StageTiming::wall_only(25));
stage.completion = Some(StageCompletion {
outcome: StageOutcome::Succeeded,
notes: None,
@ -267,10 +274,10 @@ mod tests {
assert_eq!(rollup.stages.len(), 1);
assert_eq!(rollup.stages[0].node_id, "build");
assert_eq!(rollup.stages[0].duration_ms, 25);
assert_eq!(rollup.stages[0].timing.wall_time_ms, 25);
assert!(rollup.stages[0].model.is_none());
assert_eq!(rollup.stages[0].billing.input_tokens, 0);
assert_eq!(rollup.runtime_ms, 25);
assert_eq!(rollup.timing.wall_time_ms, 25);
assert!(rollup.by_model.is_empty());
assert!(rollup.billing_if_present().is_none());
}
@ -280,7 +287,7 @@ mod tests {
let mut projection = test_projection();
projection.spec = run_spec_with_boundary_nodes();
let start = projection.stage_entry("start", 1, first_event_seq(1));
start.duration_ms = Some(25);
start.timing = Some(fabro_types::StageTiming::wall_only(25));
start.completion = Some(StageCompletion {
outcome: StageOutcome::Succeeded,
notes: None,
@ -288,7 +295,7 @@ mod tests {
timestamp: chrono::Utc::now(),
});
let exit = projection.stage_entry("exit", 1, first_event_seq(2));
exit.duration_ms = Some(7);
exit.timing = Some(fabro_types::StageTiming::wall_only(7));
exit.completion = Some(StageCompletion {
outcome: StageOutcome::Succeeded,
notes: None,
@ -299,7 +306,7 @@ mod tests {
let rollup = billing_rollup_from_projection(&projection, None);
assert_eq!(rollup.stages.len(), 0);
assert_eq!(rollup.runtime_ms, 0);
assert_eq!(rollup.timing.wall_time_ms, 0);
}
#[test]

View file

@ -2037,14 +2037,14 @@ mod tests {
// 3. Outcome → StageFailed event
let failure = outcome.failure.clone().unwrap();
let event = Event::StageFailed {
node_id: "code".into(),
name: "code".into(),
index: 0,
failure: failure.clone(),
will_retry: false,
duration_ms: 0,
billing: None,
actor: None,
node_id: "code".into(),
name: "code".into(),
index: 0,
failure: failure.clone(),
will_retry: false,
timing: fabro_types::StageTiming::wall_only(0),
billing: None,
actor: None,
};
// 4. Verify classification survived all the way through

View file

@ -181,7 +181,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
previous_parent_id: *previous_parent_id,
}),
Event::WorkflowRunCompleted {
duration_ms,
timing,
artifact_count,
status,
reason,
@ -191,7 +191,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
diff_summary,
billing,
} => EventBody::RunCompleted(fabro_types::RunCompletedProps {
duration_ms: *duration_ms,
timing: *timing,
artifact_count: *artifact_count,
status: status.clone(),
reason: *reason,
@ -203,14 +203,14 @@ fn event_body_from_event(event: &Event) -> EventBody {
}),
Event::WorkflowRunFailed {
failure,
duration_ms,
timing,
final_git_commit_sha,
final_patch,
diff_summary,
billing,
} => EventBody::RunFailed(fabro_types::RunFailedProps {
failure: failure.clone(),
duration_ms: *duration_ms,
timing: *timing,
final_git_commit_sha: final_git_commit_sha.clone(),
final_patch: final_patch.clone(),
diff_summary: *diff_summary,
@ -285,7 +285,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
}),
Event::StageCompleted {
index,
duration_ms,
timing,
status,
preferred_label,
suggested_next_ids,
@ -305,7 +305,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
..
} => EventBody::StageCompleted(fabro_types::StageCompletedProps {
index: *index,
duration_ms: *duration_ms,
timing: *timing,
status: stage_status_from_string(status),
preferred_label: preferred_label.clone(),
suggested_next_ids: suggested_next_ids.clone(),
@ -327,15 +327,15 @@ fn event_body_from_event(event: &Event) -> EventBody {
index,
failure,
will_retry,
duration_ms,
timing,
billing,
..
} => EventBody::StageFailed(fabro_types::StageFailedProps {
index: *index,
failure: Some(failure.clone()),
will_retry: *will_retry,
duration_ms: *duration_ms,
billing: billing.clone(),
index: *index,
failure: Some(failure.clone()),
will_retry: *will_retry,
timing: *timing,
billing: billing.clone(),
}),
Event::StageRetrying {
index,
@ -1366,7 +1366,7 @@ mod tests {
node_id: "plan".to_string(),
name: "Plan".to_string(),
index: 0,
duration_ms: 5000,
timing: ::fabro_types::StageTiming::wall_only(5000),
status: "succeeded".to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),
@ -1399,7 +1399,8 @@ mod tests {
assert_eq!(stored.node_label.as_deref(), Some("Plan"));
assert_eq!(stored.stage_id, Some(StageId::new("plan", 1)));
let properties = stored.properties().unwrap();
assert_eq!(properties["duration_ms"], 5000);
assert_eq!(properties["timing"]["wall_time_ms"], 5000);
assert_eq!(properties["timing"]["active_time_ms"], 0);
assert_eq!(properties["status"], "succeeded");
assert!(stored.session_id.is_none());
}
@ -1410,7 +1411,7 @@ mod tests {
node_id: "plan".to_string(),
name: "Plan".to_string(),
index: 0,
duration_ms: 5000,
timing: ::fabro_types::StageTiming::wall_only(5000),
status: "succeeded".to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),
@ -1439,17 +1440,17 @@ mod tests {
fn run_event_stage_failure_keeps_failure_detail() {
let usage = test_usage("gpt-5.2", 321, 54);
let stored = to_run_event(&fixtures::RUN_3, &Event::StageFailed {
node_id: "code".to_string(),
name: "Code".to_string(),
index: 1,
failure: FailureDetail::new(
node_id: "code".to_string(),
name: "Code".to_string(),
index: 1,
failure: FailureDetail::new(
"lint failed",
crate::outcome::FailureCategory::Deterministic,
),
will_retry: true,
duration_ms: 5000,
billing: Some(usage.clone()),
actor: None,
will_retry: true,
timing: ::fabro_types::StageTiming::wall_only(5000),
billing: Some(usage.clone()),
actor: None,
});
assert_eq!(stored.event_name(), "stage.failed");
@ -1552,7 +1553,7 @@ mod tests {
fn run_event_workflow_failure_uses_display_error() {
let event = Event::workflow_run_failed_from_error(
&Error::handler("boom"),
900,
::fabro_types::RunTiming::wall_only(900),
FailureReason::WorkflowError,
Some("abc123".to_string()),
None,
@ -1564,7 +1565,7 @@ mod tests {
assert_eq!(stored.event_name(), "run.failed");
let properties = stored.properties().unwrap();
assert_eq!(properties["failure"]["detail"]["message"], "boom");
assert_eq!(properties["duration_ms"], 900);
assert_eq!(properties["timing"]["wall_time_ms"], 900);
}
#[test]
@ -1572,7 +1573,7 @@ mod tests {
let source = EventTestCause;
let event = Event::workflow_run_failed_from_error(
&Error::engine_with_source("Failed to initialize sandbox", source),
900,
::fabro_types::RunTiming::wall_only(900),
FailureReason::WorkflowError,
None,
None,
@ -1597,7 +1598,7 @@ mod tests {
let source = EventTestCause;
let event = Event::workflow_run_failed_from_error(
&Error::engine_with_source("Failed to initialize sandbox", source),
900,
::fabro_types::RunTiming::wall_only(900),
FailureReason::SandboxInitFailed,
Some("abc123".to_string()),
None,
@ -1621,7 +1622,7 @@ mod tests {
properties["failure"]["detail"]["category"],
"transient_infra"
);
assert_eq!(properties["duration_ms"], 900);
assert_eq!(properties["timing"]["wall_time_ms"], 900);
assert_eq!(properties["final_git_commit_sha"], "abc123");
assert!(properties.get("error").is_none());
assert!(properties.get("causes").is_none());

View file

@ -4,8 +4,8 @@ use ::fabro_types::{
BilledTokenCounts, BlockedReason, CommandTermination, DiffSummary, FailureReason,
ForkSourceRef, GitContext, PairId, PairMessageId, PairSystemMessageKind, PairTarget,
ParallelBranchId, Principal, PullRequestLink, RunBlobId, RunFailure, RunId, RunNoticeLevel,
RunPairEndedReason, RunPairFailedReason, RunProvenance, SandboxProvider, StageId,
SuccessReason, run_event as fabro_types,
RunPairEndedReason, RunPairFailedReason, RunProvenance, RunTiming, SandboxProvider, StageId,
StageTiming, SuccessReason, run_event as fabro_types,
};
use fabro_agent::{AgentEvent, SandboxEvent};
use serde::{Deserialize, Serialize};
@ -150,7 +150,7 @@ pub enum Event {
actor: Option<Principal>,
},
WorkflowRunCompleted {
duration_ms: u64,
timing: RunTiming,
artifact_count: usize,
#[serde(default)]
status: String,
@ -168,7 +168,7 @@ pub enum Event {
},
WorkflowRunFailed {
failure: RunFailure,
duration_ms: u64,
timing: RunTiming,
#[serde(default, skip_serializing_if = "Option::is_none")]
final_git_commit_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -226,7 +226,7 @@ pub enum Event {
node_id: String,
name: String,
index: usize,
duration_ms: u64,
timing: StageTiming,
status: String,
preferred_label: Option<String>,
suggested_next_ids: Vec<String>,
@ -253,15 +253,15 @@ pub enum Event {
max_attempts: usize,
},
StageFailed {
node_id: String,
name: String,
index: usize,
failure: FailureDetail,
will_retry: bool,
duration_ms: u64,
billing: Option<BilledModelUsage>,
node_id: String,
name: String,
index: usize,
failure: FailureDetail,
will_retry: bool,
timing: StageTiming,
billing: Option<BilledModelUsage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<Principal>,
actor: Option<Principal>,
},
StageRetrying {
node_id: String,
@ -724,7 +724,7 @@ impl Event {
#[must_use]
pub fn workflow_run_failed_from_error(
error: &Error,
duration_ms: u64,
timing: RunTiming,
reason: FailureReason,
final_git_commit_sha: Option<String>,
final_patch: Option<String>,
@ -733,7 +733,7 @@ impl Event {
) -> Self {
Self::WorkflowRunFailed {
failure: run_failure_from_error(error, reason),
duration_ms,
timing,
final_git_commit_sha,
final_patch,
diff_summary,
@ -868,20 +868,23 @@ impl Event {
info!(%previous_parent_id, ?actor, "Run parent unlinked");
}
Self::WorkflowRunCompleted {
duration_ms,
timing,
artifact_count,
status,
..
} => {
info!(
duration_ms,
artifact_count, status, "Workflow run completed"
wall_time_ms = timing.wall_time_ms,
active_time_ms = timing.active_time_ms,
inference_time_ms = timing.inference_time_ms,
tool_time_ms = timing.tool_time_ms,
artifact_count,
status,
"Workflow run completed"
);
}
Self::WorkflowRunFailed {
failure,
duration_ms,
..
failure, timing, ..
} => {
let detail = &failure.detail;
let tail =
@ -898,7 +901,8 @@ impl Event {
exec_stderr_tail_bytes = tail.stderr_bytes,
exec_stdout_truncated = tail.stdout_truncated,
exec_stderr_truncated = tail.stderr_truncated,
duration_ms,
wall_time_ms = timing.wall_time_ms,
active_time_ms = timing.active_time_ms,
"Workflow run failed"
);
}
@ -1009,7 +1013,7 @@ impl Event {
node_id,
name,
index,
duration_ms,
timing,
status,
attempt,
max_attempts,
@ -1019,7 +1023,10 @@ impl Event {
node_id,
stage = name.as_str(),
index,
duration_ms,
wall_time_ms = timing.wall_time_ms,
active_time_ms = timing.active_time_ms,
inference_time_ms = timing.inference_time_ms,
tool_time_ms = timing.tool_time_ms,
status,
attempt,
max_attempts,

View file

@ -500,7 +500,7 @@ mod tests {
node_id: "work".into(),
name: "Work".into(),
index: 2,
duration_ms: 100,
timing: fabro_types::StageTiming::wall_only(100),
status: "succeeded".into(),
preferred_label: None,
suggested_next_ids: Vec::new(),

View file

@ -1635,9 +1635,8 @@ mod tests {
started_at: None,
last_event_at: None,
completed_at: None,
duration_ms: None,
elapsed_secs: None,
},
timing: None,
billing: None,
diff: None,
pull_request: None,

View file

@ -29,57 +29,67 @@ pub(crate) fn millis_u64(d: std::time::Duration) -> u64 {
u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
}
/// Extract the `duration_ms` from a `stage.completed` / `stage.failed`
/// Extract the timing breakdown from a `stage.completed` / `stage.failed`
/// event body, or `None` for any other variant.
fn stage_completion_duration_ms(body: &EventBody) -> Option<u64> {
fn stage_completion_timing(body: &EventBody) -> Option<fabro_types::StageTiming> {
match body {
EventBody::StageCompleted(props) => Some(props.duration_ms),
EventBody::StageFailed(props) => Some(props.duration_ms),
EventBody::StageCompleted(props) => Some(props.timing),
EventBody::StageFailed(props) => Some(props.timing),
_ => None,
}
}
/// Extract per-stage (node_id, visit) durations from `stage.completed` /
/// Extract per-stage (node_id, visit) timing from `stage.completed` /
/// `stage.failed` events. Keys on the full [`StageId`] so multi-visit stages
/// (e.g. a looped `verify` node) keep distinct durations.
/// (e.g. a looped `verify` node) keep distinct timings.
///
/// This is the canonical primitive; [`total_stage_duration_by_node`] and
/// [`latest_stage_duration_by_node`] are explicit rollups built on top of it.
pub fn extract_stage_durations_by_stage_id(events: &[EventEnvelope]) -> HashMap<StageId, u64> {
let mut durations = HashMap::new();
/// This is the canonical primitive; [`total_stage_timing_by_node`] and
/// [`latest_stage_timing_by_node`] are explicit rollups built on top of it.
pub fn extract_stage_timings_by_stage_id(
events: &[EventEnvelope],
) -> HashMap<StageId, fabro_types::StageTiming> {
let mut timings = HashMap::new();
for envelope in events {
let Some(duration_ms) = stage_completion_duration_ms(&envelope.event.body) else {
let Some(timing) = stage_completion_timing(&envelope.event.body) else {
continue;
};
let Some(stage_id) = envelope.event.stage_id.as_ref() else {
continue;
};
durations.insert(stage_id.clone(), duration_ms);
timings.insert(stage_id.clone(), timing);
}
durations
timings
}
/// Total duration spent in each node, summed across every visit. Use for
/// billing/usage where a retried node should count its full time.
pub fn total_stage_duration_by_node(events: &[EventEnvelope]) -> HashMap<String, u64> {
let mut totals: HashMap<String, u64> = HashMap::new();
for (stage_id, duration_ms) in extract_stage_durations_by_stage_id(events) {
*totals.entry(stage_id.node_id().to_string()).or_default() += duration_ms;
/// Sum of timing in each node across every visit. Use for billing/usage
/// where a retried node should count its full time. `wall_time_ms`,
/// `inference_time_ms`, `tool_time_ms`, and `active_time_ms` are all summed
/// per node.
pub fn total_stage_timing_by_node(
events: &[EventEnvelope],
) -> HashMap<String, fabro_types::StageTiming> {
let mut totals: HashMap<String, fabro_types::StageTiming> = HashMap::new();
for (stage_id, timing) in extract_stage_timings_by_stage_id(events) {
let entry = totals.entry(stage_id.node_id().to_string()).or_default();
*entry = entry.saturating_add(&timing);
}
totals
}
/// Duration of each node's most recent visit (the highest visit number). Use
/// Timing of each node's most recent visit (the highest visit number). Use
/// for run summaries where the table shows one row per node and "the last
/// attempt" is the right representative.
pub fn latest_stage_duration_by_node(events: &[EventEnvelope]) -> HashMap<String, u64> {
let mut entries: Vec<(StageId, u64)> = extract_stage_durations_by_stage_id(events)
.into_iter()
.collect();
pub fn latest_stage_timing_by_node(
events: &[EventEnvelope],
) -> HashMap<String, fabro_types::StageTiming> {
let mut entries: Vec<(StageId, fabro_types::StageTiming)> =
extract_stage_timings_by_stage_id(events)
.into_iter()
.collect();
entries.sort_by_key(|(stage_id, _)| stage_id.visit());
let mut latest = HashMap::new();
for (stage_id, duration_ms) in entries {
latest.insert(stage_id.node_id().to_string(), duration_ms);
for (stage_id, timing) in entries {
latest.insert(stage_id.node_id().to_string(), timing);
}
latest
}
@ -89,14 +99,13 @@ mod duration_tests {
use chrono::{TimeZone, Utc};
use fabro_store::EventEnvelope;
use fabro_types::run_event::{StageCompletedProps, StageFailedProps};
use fabro_types::{EventBody, RunEvent, StageId, StageOutcome, fixtures};
use fabro_types::{EventBody, RunEvent, StageId, StageOutcome, StageTiming, fixtures};
use super::{
extract_stage_durations_by_stage_id, latest_stage_duration_by_node,
total_stage_duration_by_node,
extract_stage_timings_by_stage_id, latest_stage_timing_by_node, total_stage_timing_by_node,
};
fn completed_event(seq: u32, node: &str, visit: u32, duration_ms: u64) -> EventEnvelope {
fn completed_event(seq: u32, node: &str, visit: u32, wall_time_ms: u64) -> EventEnvelope {
let event = RunEvent {
id: format!("evt_{seq}"),
ts: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
@ -112,7 +121,7 @@ mod duration_tests {
actor: None,
body: EventBody::StageCompleted(StageCompletedProps {
index: 0,
duration_ms,
timing: StageTiming::wall_only(wall_time_ms),
status: StageOutcome::Succeeded,
preferred_label: None,
suggested_next_ids: vec![],
@ -134,7 +143,7 @@ mod duration_tests {
EventEnvelope { seq, event }
}
fn failed_event(seq: u32, node: &str, visit: u32, duration_ms: u64) -> EventEnvelope {
fn failed_event(seq: u32, node: &str, visit: u32, wall_time_ms: u64) -> EventEnvelope {
let event = RunEvent {
id: format!("evt_{seq}"),
ts: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
@ -149,62 +158,126 @@ mod duration_tests {
tool_call_id: None,
actor: None,
body: EventBody::StageFailed(StageFailedProps {
index: 0,
failure: None,
index: 0,
failure: None,
will_retry: true,
duration_ms,
billing: None,
timing: StageTiming::wall_only(wall_time_ms),
billing: None,
}),
};
EventEnvelope { seq, event }
}
#[test]
fn extract_keys_durations_by_full_stage_id() {
fn extract_keys_timings_by_full_stage_id() {
let events = vec![
completed_event(1, "verify", 1, 100),
completed_event(2, "verify", 2, 200),
];
let durations = extract_stage_durations_by_stage_id(&events);
let timings = extract_stage_timings_by_stage_id(&events);
assert_eq!(
durations.get(&StageId::new("verify", 1)).copied(),
timings
.get(&StageId::new("verify", 1))
.map(|t| t.wall_time_ms),
Some(100)
);
assert_eq!(
durations.get(&StageId::new("verify", 2)).copied(),
timings
.get(&StageId::new("verify", 2))
.map(|t| t.wall_time_ms),
Some(200)
);
}
#[test]
fn total_sums_across_visits_per_node() {
fn total_sums_wall_time_across_visits_per_node() {
let events = vec![
completed_event(1, "verify", 1, 100),
completed_event(2, "verify", 2, 200),
completed_event(3, "build", 1, 50),
];
let totals = total_stage_duration_by_node(&events);
assert_eq!(totals.get("verify").copied(), Some(300));
assert_eq!(totals.get("build").copied(), Some(50));
let totals = total_stage_timing_by_node(&events);
assert_eq!(totals.get("verify").map(|t| t.wall_time_ms), Some(300));
assert_eq!(totals.get("build").map(|t| t.wall_time_ms), Some(50));
}
#[test]
fn latest_picks_highest_visit_regardless_of_input_order() {
// Visit 2 appears in the events vector before visit 1; the result
// must still reflect visit 2's duration (the latest visit).
// must still reflect visit 2's timing (the latest visit).
let events = vec![
completed_event(1, "verify", 2, 999),
completed_event(2, "verify", 1, 100),
];
let latest = latest_stage_duration_by_node(&events);
assert_eq!(latest.get("verify").copied(), Some(999));
let latest = latest_stage_timing_by_node(&events);
assert_eq!(latest.get("verify").map(|t| t.wall_time_ms), Some(999));
}
#[test]
fn stage_failed_durations_are_included() {
fn stage_failed_timings_are_included() {
let events = vec![failed_event(1, "verify", 1, 75)];
let durations = extract_stage_durations_by_stage_id(&events);
assert_eq!(durations.get(&StageId::new("verify", 1)).copied(), Some(75));
let timings = extract_stage_timings_by_stage_id(&events);
assert_eq!(
timings
.get(&StageId::new("verify", 1))
.map(|t| t.wall_time_ms),
Some(75)
);
}
#[test]
fn total_sums_active_breakdown_across_visits() {
// Same node visited twice with different inference/tool breakdowns:
// the rollup must add inference, tool, and active fields, not just
// wall time. This guards against accidentally summing wall only.
fn timed_completed(seq: u32, visit: u32, timing: StageTiming) -> EventEnvelope {
let event = RunEvent {
id: format!("evt_{seq}"),
ts: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
run_id: fixtures::RUN_1,
node_id: Some("agent".to_string()),
node_label: None,
stage_id: Some(StageId::new("agent", visit)),
parallel_group_id: None,
parallel_branch_id: None,
session_id: None,
parent_session_id: None,
tool_call_id: None,
actor: None,
body: EventBody::StageCompleted(StageCompletedProps {
index: 0,
timing,
status: StageOutcome::Succeeded,
preferred_label: None,
suggested_next_ids: vec![],
billing: None,
failure: None,
notes: None,
files_touched: vec![],
context_updates: None,
jump_to_node: None,
context_values: None,
node_visits: None,
loop_failure_signatures: None,
restart_failure_signatures: None,
response: None,
attempt: 1,
max_attempts: 1,
}),
};
EventEnvelope { seq, event }
}
let events = vec![
timed_completed(1, 1, StageTiming::new(1000, 600, 300)),
timed_completed(2, 2, StageTiming::new(700, 400, 200)),
];
let totals = total_stage_timing_by_node(&events);
let agent = totals.get("agent").copied().unwrap();
assert_eq!(agent.wall_time_ms, 1700);
assert_eq!(agent.inference_time_ms, 1000);
assert_eq!(agent.tool_time_ms, 500);
assert_eq!(agent.active_time_ms, 1500);
}
}

View file

@ -10,7 +10,7 @@ use fabro_core::lifecycle::{
};
use fabro_core::outcome::NodeResult;
use fabro_core::state::ExecutionState;
use fabro_types::{Principal, RunId};
use fabro_types::{Principal, RunId, StageTiming};
use super::circuit_breaker::CircuitBreakerLifecycle;
use super::git::GitCheckpointResult;
@ -73,6 +73,18 @@ fn actor_for_stage_failure(failure: &FailureDetail) -> Option<Principal> {
.map(|system_kind| Principal::System { system_kind })
}
/// Build a [`StageTiming`] from a [`WfNodeResult`]. Inference and tool time
/// flow from the executor's `NodeResult` fields, which are populated from
/// `outcome.timing` by [`fabro_core`]. Handlers without an active-time
/// breakdown produce a wall-only timing.
fn node_result_timing(result: &WfNodeResult) -> StageTiming {
StageTiming::new(
crate::millis_u64(result.wall_time),
crate::millis_u64(result.inference_time),
crate::millis_u64(result.tool_time),
)
}
fn response_from_outcome(node_id: &str, outcome: &Outcome) -> Option<String> {
outcome
.context_updates
@ -154,7 +166,7 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
node_id: gv.id.clone(),
name: gv.label().to_string(),
index: stage_index,
duration_ms: 0,
timing: StageTiming::wall_only(0),
status: StageOutcome::Succeeded.to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),
@ -211,7 +223,7 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
let stage_index = state.stage_index;
let scope = stage_scope_for(state, &gv.id);
let duration_ms = crate::millis_u64(ctx.result.duration);
let timing = node_result_timing(ctx.result);
let failure = outcome.failure.clone().unwrap_or_else(|| {
FailureDetail::new("handler failed", FailureCategory::TransientInfra)
});
@ -223,7 +235,7 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
index: stage_index,
failure,
will_retry: true,
duration_ms,
timing,
billing: outcome.usage.clone(),
actor,
},
@ -259,7 +271,7 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
let gv = node.inner();
let stage_index = state.stage_index;
let scope = stage_scope_for(state, &gv.id);
let duration_ms = crate::millis_u64(result.duration);
let timing = node_result_timing(result);
let (loop_failure_signatures, restart_failure_signatures) =
snapshot_failure_signatures(&self.circuit_breaker);
@ -275,7 +287,7 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
index: stage_index,
failure,
will_retry: false,
duration_ms,
timing,
billing: outcome.usage.clone(),
actor,
},
@ -287,7 +299,7 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
node_id: gv.id.clone(),
name: gv.label().to_string(),
index: stage_index,
duration_ms,
timing,
status: outcome.status.to_string(),
preferred_label: outcome.preferred_label.clone(),
suggested_next_ids: outcome.suggested_next_ids.clone(),

View file

@ -956,7 +956,14 @@ mod tests {
let node = graph.get_node("build").unwrap();
let mut state = ExecutionState::new(&graph).unwrap();
state.increment_visits("build");
let result = WfNodeResult::new(Outcome::success(), Duration::from_millis(10), 1, 1);
let result = WfNodeResult::new(
Outcome::success(),
Duration::from_millis(10),
Duration::ZERO,
Duration::ZERO,
1,
1,
);
lifecycle
.on_checkpoint(&node, &result, Some("exit"), &state)
@ -999,7 +1006,14 @@ mod tests {
let node = graph.get_node("build").unwrap();
let mut state = ExecutionState::new(&graph).unwrap();
state.increment_visits("build");
let result = WfNodeResult::new(Outcome::success(), Duration::from_millis(10), 1, 1);
let result = WfNodeResult::new(
Outcome::success(),
Duration::from_millis(10),
Duration::ZERO,
Duration::ZERO,
1,
1,
);
lifecycle
.on_checkpoint(&node, &result, Some("exit"), &state)
@ -1060,7 +1074,14 @@ mod tests {
let node = graph.get_node("build").unwrap();
let mut state = ExecutionState::new(&graph).unwrap();
state.increment_visits("build");
let result = WfNodeResult::new(Outcome::success(), Duration::from_millis(10), 1, 1);
let result = WfNodeResult::new(
Outcome::success(),
Duration::from_millis(10),
Duration::ZERO,
Duration::ZERO,
1,
1,
);
lifecycle
.on_checkpoint(&node, &result, Some("exit"), &state)
@ -1127,7 +1148,14 @@ mod tests {
let node = graph.get_node("build").unwrap();
let mut state = ExecutionState::new(&graph).unwrap();
state.increment_visits("build");
let result = WfNodeResult::new(Outcome::success(), Duration::from_millis(10), 1, 1);
let result = WfNodeResult::new(
Outcome::success(),
Duration::from_millis(10),
Duration::ZERO,
Duration::ZERO,
1,
1,
);
lifecycle
.on_checkpoint(&node, &result, Some("exit"), &state)
@ -1189,7 +1217,14 @@ mod tests {
let node = graph.get_node("build").unwrap();
let mut checkpoint_state = ExecutionState::new(&graph).unwrap();
checkpoint_state.increment_visits("build");
let result = WfNodeResult::new(Outcome::success(), Duration::from_millis(10), 1, 1);
let result = WfNodeResult::new(
Outcome::success(),
Duration::from_millis(10),
Duration::ZERO,
Duration::ZERO,
1,
1,
);
lifecycle
.on_checkpoint(&node, &result, Some("exit"), &checkpoint_state)
.await
@ -1220,7 +1255,7 @@ mod tests {
let conclusion = Conclusion {
timestamp: chrono::Utc::now(),
status: StageOutcome::Succeeded,
duration_ms: 10,
timing: fabro_types::RunTiming::wall_only(10),
failure: None,
final_git_commit_sha: None,
stages: Vec::new(),

View file

@ -160,7 +160,7 @@ mod tests {
.await
.unwrap();
event::append_event(&run_store, run_id, &Event::WorkflowRunCompleted {
duration_ms: 10,
timing: fabro_types::RunTiming::wall_only(10),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
@ -185,7 +185,7 @@ mod tests {
.unwrap();
let failure_event = Event::workflow_run_failed_from_error(
&crate::error::Error::engine("boom"),
10,
fabro_types::RunTiming::wall_only(10),
FailureReason::WorkflowError,
None,
None,

View file

@ -392,7 +392,7 @@ mod tests {
node_id: "work".to_string(),
name: "Work".to_string(),
index: 1,
duration_ms: 10,
timing: fabro_types::StageTiming::wall_only(10),
status: "succeeded".to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),

View file

@ -275,7 +275,7 @@ async fn persist_terminal_engine_failure(
};
let failure_event = Event::workflow_run_failed_from_error(
error,
crate::millis_u64(duration),
fabro_types::RunTiming::wall_only(crate::millis_u64(duration)),
reason,
None,
None,
@ -1003,7 +1003,7 @@ impl Drop for DetachedRunBootstrapGuard {
handle.spawn(async move {
let failure_event = Event::workflow_run_failed_from_error(
&Error::engine(reason.to_string()),
0,
fabro_types::RunTiming::default(),
reason,
None,
None,
@ -1070,7 +1070,7 @@ impl Drop for DetachedRunCompletionGuard {
handle.spawn(async move {
let failure_event = Event::workflow_run_failed_from_error(
&Error::engine(message.to_string()),
0,
fabro_types::RunTiming::default(),
reason,
None,
None,
@ -1100,8 +1100,15 @@ async fn persist_detached_failure(
) -> Result<(), Error> {
let message = error.to_string();
let failure_event =
Event::workflow_run_failed_from_error(error, 0, reason, None, None, None, None);
let failure_event = Event::workflow_run_failed_from_error(
error,
fabro_types::RunTiming::default(),
reason,
None,
None,
None,
None,
);
if let Err(err) = append_event_to_sink(event_sink, &run_id, &failure_event).await {
tracing::warn!(error = %err, "Failed to append detached failure event");
}
@ -1719,7 +1726,7 @@ reasoning = false
let conclusion = crate::records::Conclusion {
timestamp: Utc::now(),
status: StageOutcome::Succeeded,
duration_ms: 1,
timing: fabro_types::RunTiming::wall_only(1),
failure: None,
final_git_commit_sha: None,
stages: vec![],
@ -1761,7 +1768,7 @@ reasoning = false
.await
.unwrap();
crate::event::append_event(&run_store, &fixtures::RUN_1, &Event::WorkflowRunCompleted {
duration_ms: conclusion.duration_ms,
timing: conclusion.timing,
artifact_count: 0,
status: "succeeded".to_string(),
reason: crate::run_status::SuccessReason::Completed,

View file

@ -143,7 +143,7 @@ pub async fn execute(init: Initialized) -> Executed {
graph,
outcome: Err(err),
run_options,
duration_ms: crate::millis_u64(start.elapsed()),
wall_time_ms: crate::millis_u64(start.elapsed()),
final_context: seed_context_from_checkpoint(checkpoint.as_ref()),
engine,
model,
@ -163,7 +163,7 @@ pub async fn execute(init: Initialized) -> Executed {
graph,
outcome: Err(err),
run_options,
duration_ms: crate::millis_u64(start.elapsed()),
wall_time_ms: crate::millis_u64(start.elapsed()),
final_context: seed,
engine,
model,
@ -178,7 +178,7 @@ pub async fn execute(init: Initialized) -> Executed {
graph,
outcome: Err(err),
run_options,
duration_ms: crate::millis_u64(start.elapsed()),
wall_time_ms: crate::millis_u64(start.elapsed()),
final_context: Context::new(),
engine,
model,
@ -294,13 +294,13 @@ pub async fn execute(init: Initialized) -> Executed {
engine.registry.shutdown_all(&engine.run.emitter).await;
let duration_ms = crate::millis_u64(start.elapsed());
let wall_time_ms = crate::millis_u64(start.elapsed());
Executed {
graph,
outcome,
run_options,
duration_ms,
wall_time_ms,
final_context,
engine,
model,

View file

@ -72,7 +72,7 @@ pub(crate) async fn build_conclusion_from_store(
run_store: &RunStoreHandle,
status: StageOutcome,
failure: Option<RunFailure>,
run_duration_ms: u64,
run_wall_time_ms: u64,
final_git_commit_sha: Option<String>,
) -> Conclusion {
let projection = run_store.state().await.ok();
@ -94,7 +94,7 @@ pub(crate) async fn build_conclusion_from_store(
&projection_order,
status,
failure,
run_duration_ms,
run_wall_time_ms,
final_git_commit_sha,
)
}
@ -105,7 +105,7 @@ fn build_conclusion_from_parts(
projection_order: &HashMap<String, u32>,
status: StageOutcome,
failure: Option<RunFailure>,
run_duration_ms: u64,
run_wall_time_ms: u64,
final_git_commit_sha: Option<String>,
) -> Conclusion {
// Looping workflows revisit nodes; `completed_nodes` accumulates duplicates
@ -154,7 +154,8 @@ fn build_conclusion_from_parts(
let summary = StageSummary {
stage_id: node_id.to_string(),
stage_label: node_id.to_string(),
duration_ms: billing.map_or(0, |stage| stage.duration_ms),
timing: billing
.map_or_else(fabro_types::StageTiming::default, |stage| stage.timing),
billing_usd_micros: billing.and_then(|stage| stage.billing.total_usd_micros),
retries,
};
@ -182,7 +183,7 @@ fn build_conclusion_from_parts(
Conclusion {
timestamp: chrono::Utc::now(),
status,
duration_ms: run_duration_ms,
timing: projection_billing.timing.with_wall_time(run_wall_time_ms),
failure,
final_git_commit_sha,
stages,
@ -443,7 +444,7 @@ pub(crate) fn billing_from_projection(projection: &RunProjection) -> Option<Bill
pub(crate) fn build_terminal_event(
outcome: &Result<Outcome, Error>,
duration_ms: u64,
timing: fabro_types::RunTiming,
artifact_count: usize,
final_git_commit_sha: Option<String>,
final_patch: Option<String>,
@ -462,7 +463,7 @@ pub(crate) fn build_terminal_event(
{
let total_usd_micros = billing.as_ref().and_then(|b| b.total_usd_micros);
return Event::WorkflowRunCompleted {
duration_ms,
timing,
artifact_count,
status: outcome_status.to_string(),
reason: match outcome_status {
@ -493,7 +494,7 @@ pub(crate) fn build_terminal_event(
};
Event::WorkflowRunFailed {
failure,
duration_ms,
timing,
final_git_commit_sha,
final_patch,
diff_summary,
@ -533,7 +534,7 @@ pub async fn finalize(executed: Executed, options: &FinalizeOptions) -> Result<C
graph,
outcome,
run_options,
duration_ms,
wall_time_ms,
final_context: _,
engine,
model: _,
@ -565,7 +566,7 @@ pub async fn finalize(executed: Executed, options: &FinalizeOptions) -> Result<C
&projection_order,
final_status,
failure_reason,
duration_ms,
wall_time_ms,
options.last_git_sha.clone(),
);
@ -584,7 +585,7 @@ pub async fn finalize(executed: Executed, options: &FinalizeOptions) -> Result<C
let terminal_event = build_terminal_event(
&outcome,
duration_ms,
conclusion.timing,
artifact_count,
options.last_git_sha.clone(),
final_patch,
@ -698,7 +699,7 @@ mod tests {
graph: Graph,
outcome: Result<Outcome, Error>,
run_options: RunOptions,
duration_ms: u64,
wall_time_ms: u64,
services: Arc<RunServices>,
) -> Executed {
let mut engine = EngineServices::test_default();
@ -707,7 +708,7 @@ mod tests {
graph,
outcome,
run_options,
duration_ms,
wall_time_ms,
final_context: Context::new(),
engine: Arc::new(engine),
model: "test-model".to_string(),
@ -955,7 +956,7 @@ mod tests {
let failed_usage = test_usage("gpt-old", 100, 10);
let success_usage = test_usage("gpt-new", 200, 20);
let failed = projection.stage_entry("verify", 1, first_event_seq(1));
failed.duration_ms = Some(1200);
failed.timing = Some(fabro_types::StageTiming::wall_only(1200));
failed.usage = BilledTokenCounts::from_billed_usage(std::slice::from_ref(&failed_usage));
failed.model = Some(failed_usage.model().clone());
failed.completion = Some(StageCompletion {
@ -967,7 +968,7 @@ mod tests {
timestamp: chrono::Utc::now(),
});
let succeeded = projection.stage_entry("verify", 2, first_event_seq(2));
succeeded.duration_ms = Some(800);
succeeded.timing = Some(fabro_types::StageTiming::wall_only(800));
succeeded.usage =
BilledTokenCounts::from_billed_usage(std::slice::from_ref(&success_usage));
succeeded.model = Some(success_usage.model().clone());
@ -982,7 +983,7 @@ mod tests {
let projection_billing = billing_rollup_from_projection(&projection, None);
let mut latest_outcome = Outcome::success();
latest_outcome.usage = Some(success_usage);
latest_outcome.duration_ms = Some(800);
latest_outcome.timing = Some(fabro_types::StageTiming::wall_only(800));
let mut checkpoint = checkpoint_with(
vec!["verify", "verify"],
HashMap::from([("verify".to_string(), latest_outcome)]),
@ -1007,7 +1008,7 @@ mod tests {
);
assert_eq!(conclusion.stages.len(), 1);
assert_eq!(conclusion.stages[0].stage_id, "verify");
assert_eq!(conclusion.stages[0].duration_ms, 2000);
assert_eq!(conclusion.stages[0].timing.wall_time_ms, 2000);
assert_eq!(conclusion.stages[0].billing_usd_micros, Some(330));
assert_eq!(conclusion.stages[0].retries, 1);
}
@ -1104,7 +1105,7 @@ mod tests {
let conclusion = Conclusion {
timestamp: chrono::Utc::now(),
status: StageOutcome::Succeeded,
duration_ms: 10,
timing: fabro_types::RunTiming::wall_only(10),
failure: None,
final_git_commit_sha: None,
stages: Vec::new(),
@ -1167,7 +1168,7 @@ mod tests {
let conclusion = Conclusion {
timestamp: chrono::Utc::now(),
status: StageOutcome::Succeeded,
duration_ms: 10,
timing: fabro_types::RunTiming::wall_only(10),
failure: None,
final_git_commit_sha: None,
stages: Vec::new(),
@ -1219,7 +1220,7 @@ mod tests {
let conclusion = Conclusion {
timestamp: chrono::Utc::now(),
status: StageOutcome::Succeeded,
duration_ms: 10,
timing: fabro_types::RunTiming::wall_only(10),
failure: None,
final_git_commit_sha: None,
stages: Vec::new(),

View file

@ -172,7 +172,7 @@ fn format_arc_details_section(
parts.push(String::new());
// Cost table
let total_duration = format_duration_ms(conclusion.duration_ms);
let total_duration = format_duration_ms(conclusion.timing.wall_time_ms);
let total_cost_str = format_cost(conclusion.billing.as_ref().and_then(|b| b.total_usd_micros));
let stage_count = conclusion.stages.len();
parts.push(format!(
@ -184,7 +184,7 @@ fn format_arc_details_section(
parts.push("| Stage | Duration | Cost | Retries |".to_string());
parts.push("|---|---|---|---|".to_string());
for stage in &conclusion.stages {
let dur = format_duration_ms(stage.duration_ms);
let dur = format_duration_ms(stage.timing.wall_time_ms);
let cost = format_cost(stage.billing_usd_micros);
parts.push(format!(
"| {} | {} | {} | {} |",
@ -870,28 +870,28 @@ mod tests {
Conclusion {
timestamp: Utc::now(),
status: crate::outcome::StageOutcome::Succeeded,
duration_ms: 150_000,
timing: fabro_types::RunTiming::wall_only(150_000),
failure: None,
final_git_commit_sha: None,
stages: vec![
StageSummary {
stage_id: "plan".to_string(),
stage_label: "plan".to_string(),
duration_ms: 45_000,
timing: fabro_types::StageTiming::wall_only(45_000),
billing_usd_micros: Some(120_000),
retries: 0,
},
StageSummary {
stage_id: "implement".to_string(),
stage_label: "implement".to_string(),
duration_ms: 90_000,
timing: fabro_types::StageTiming::wall_only(90_000),
billing_usd_micros: Some(250_000),
retries: 0,
},
StageSummary {
stage_id: "simplify".to_string(),
stage_label: "simplify".to_string(),
duration_ms: 15_000,
timing: fabro_types::StageTiming::wall_only(15_000),
billing_usd_micros: Some(50_000),
retries: 0,
},
@ -1244,7 +1244,7 @@ mod tests {
node_id: "plan".to_string(),
name: "plan".to_string(),
index: 0,
duration_ms: 1,
timing: fabro_types::StageTiming::wall_only(1),
status: "succeeded".to_string(),
preferred_label: None,
suggested_next_ids: vec![],
@ -1600,7 +1600,7 @@ mod tests {
.await
.unwrap();
append_event(&run_store, &fixtures::RUN_1, &Event::WorkflowRunCompleted {
duration_ms: 1,
timing: fabro_types::RunTiming::wall_only(1),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
@ -1717,7 +1717,7 @@ mod tests {
node_id: "plan".to_string(),
name: "plan".to_string(),
index: 0,
duration_ms: 1,
timing: fabro_types::StageTiming::wall_only(1),
status: "succeeded".to_string(),
preferred_label: None,
suggested_next_ids: vec![],
@ -1888,7 +1888,7 @@ mod tests {
.await
.unwrap();
append_event(&run_store, &fixtures::RUN_1, &Event::WorkflowRunCompleted {
duration_ms: 1,
timing: fabro_types::RunTiming::wall_only(1),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,

View file

@ -299,7 +299,8 @@ pub struct Executed {
pub graph: Graph,
pub outcome: Result<Outcome, Error>,
pub run_options: RunOptions,
pub duration_ms: u64,
/// Run wall-clock time in milliseconds from EXECUTE start to outcome.
pub wall_time_ms: u64,
pub final_context: Context,
pub engine: Arc<EngineServices>,
pub model: String,

View file

@ -140,10 +140,10 @@ impl RunInfo {
}
}
pub fn duration_ms(&self) -> Option<u64> {
pub fn wall_time_ms(&self) -> Option<u64> {
self.summary
.as_ref()
.and_then(|summary| summary.timestamps.duration_ms)
.and_then(|summary| summary.timing.as_ref().map(|t| t.wall_time_ms))
}
pub fn total_cost(&self) -> Option<f64> {
@ -284,8 +284,11 @@ fn run_info_from_summary(summary: &Run, scratch_base: &Path) -> Option<RunInfo>
let dir_name = path.file_name()?.to_string_lossy().to_string();
let start_time_dt = summary.id.created_at();
let end_time = if summary.lifecycle.status.is_terminal() {
summary.timestamps.duration_ms.and_then(|duration_ms| {
Some(start_time_dt + chrono::Duration::milliseconds(i64::try_from(duration_ms).ok()?))
summary.timing.as_ref().and_then(|timing| {
Some(
start_time_dt
+ chrono::Duration::milliseconds(i64::try_from(timing.wall_time_ms).ok()?),
)
})
} else {
None

View file

@ -40,7 +40,7 @@ async fn execute_and_emit_terminal(initialized: InitializedState) -> Executed {
let billing = state.as_ref().and_then(billing_from_projection);
let event = build_terminal_event(
&executed.outcome,
executed.duration_ms,
fabro_types::RunTiming::wall_only(executed.wall_time_ms),
0,
None,
None,

View file

@ -7130,7 +7130,7 @@ async fn workflow_run_with_vault_only_openai_codex_builds_pr_body() {
Some(&Conclusion {
timestamp: Utc::now(),
status: StageOutcome::Succeeded,
duration_ms: 1,
timing: fabro_types::RunTiming::wall_only(1),
failure: None,
final_git_commit_sha: None,
stages: Vec::new(),

View file

@ -8,6 +8,7 @@
"typecheck": "tsc"
},
"devDependencies": {
"@openapitools/openapi-generator-cli": "2.20.2",
"typescript": "^5.9.2"
},
"dependencies": {

View file

@ -332,7 +332,7 @@ models/run-status-succeeded.ts
models/run-status.ts
models/run-superseded-by-props.ts
models/run-timestamps.ts
models/run-timings.ts
models/run-timing.ts
models/run.ts
models/sandbox-details.ts
models/sandbox-file-entry.ts
@ -384,6 +384,7 @@ models/stage-outcome.ts
models/stage-projection.ts
models/stage-state.ts
models/stage-summary.ts
models/stage-timing.ts
models/start-record.ts
models/start-run-request.ts
models/steer-run-request.ts
@ -425,4 +426,4 @@ models/workflow-ref.ts
models/workflow-reference.ts
models/workflow-schedule-summary.ts
models/workflow-settings.ts
models/write-blob-response.ts
models/write-blob-response.ts

View file

@ -13,6 +13,9 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { RunTiming } from './run-timing';
/**
* Aggregate billing totals across all runs.
@ -51,7 +54,7 @@ export interface AggregateBillingTotals {
*/
'total_usd_micros'?: number | null;
/**
* Total runtime in seconds.
* Aggregate timing rollup across every completed run. Active timing sums work across stage visits, so `active_time_ms` can exceed `wall_time_ms`.
*/
'runtime_secs': number;
'timing': RunTiming;
}

View file

@ -27,7 +27,7 @@ export interface CheckRun {
'name': string;
'status': CheckRunStatus;
/**
* Duration of the check run in seconds.
* Wall-clock duration of the check run in milliseconds.
*/
'duration_secs'?: number;
'wall_time_ms'?: number;
}

View file

@ -24,6 +24,9 @@ import type { RunDiff } from './run-diff';
import type { RunFailure } from './run-failure';
// May contain unused imports in some cases
// @ts-ignore
import type { RunTiming } from './run-timing';
// May contain unused imports in some cases
// @ts-ignore
import type { StageOutcome } from './stage-outcome';
// May contain unused imports in some cases
// @ts-ignore
@ -35,7 +38,7 @@ import type { StageSummary } from './stage-summary';
export interface Conclusion {
'timestamp': string;
'status': StageOutcome;
'duration_ms': number;
'timing': RunTiming;
'failure'?: RunFailure | null;
'final_git_commit_sha'?: string | null;
'stages': Array<StageSummary>;

View file

@ -309,7 +309,7 @@ export * from './run-status-submitted';
export * from './run-status-succeeded';
export * from './run-superseded-by-props';
export * from './run-timestamps';
export * from './run-timings';
export * from './run-timing';
export * from './sandbox-details';
export * from './sandbox-file-entry';
export * from './sandbox-file-list-response';
@ -360,6 +360,7 @@ export * from './stage-outcome';
export * from './stage-projection';
export * from './stage-state';
export * from './stage-summary';
export * from './stage-timing';
export * from './start-record';
export * from './start-run-request';
export * from './steer-run-request';
@ -401,4 +402,4 @@ export * from './workflow-ref';
export * from './workflow-reference';
export * from './workflow-schedule-summary';
export * from './workflow-settings';
export * from './write-blob-response';
export * from './write-blob-response';

View file

@ -25,18 +25,21 @@ import type { BillingStageRef } from './billing-stage-ref';
// May contain unused imports in some cases
// @ts-ignore
import type { StageState } from './stage-state';
// May contain unused imports in some cases
// @ts-ignore
import type { StageTiming } from './stage-timing';
/**
* Token counts and billed totals for one workflow node within a run. Rows are grouped by node; billing and runtime sum every visit of that node.
* Token counts and billed totals for one workflow node within a run. Rows are grouped by node; billing and timing sum every visit of that node.
*/
export interface RunBillingStage {
'stage': BillingStageRef;
'model': BillingModelRef | null;
'billing': BilledTokenCounts;
/**
* Wall-clock runtime in seconds, summed across every visit of this node.
* Per-node timing summed across every visit. `wall_time_ms` is the sum of visit wall times; the active breakdown sums work timing.
*/
'runtime_secs': number;
'timing': StageTiming;
/**
* Wall-clock time the latest attempt of this stage started, if known.
*/

View file

@ -13,15 +13,18 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { RunTiming } from './run-timing';
/**
* Aggregate billing totals across all stages of a run.
*/
export interface RunBillingTotals {
/**
* Total wall-clock runtime in seconds.
* Run-level timing rollup. `wall_time_ms` is summed across stage visits; active timing sums work across visits.
*/
'runtime_secs': number;
'timing': RunTiming;
/**
* Total input tokens consumed.
*/

View file

@ -35,9 +35,9 @@ export interface RunStage {
'handler': StageHandler;
'status': StageState;
/**
* Time spent in this stage, in seconds.
* Wall-clock time the latest attempt spent in this stage, in milliseconds.
*/
'duration_secs'?: number;
'wall_time_ms'?: number;
/**
* Node id in the workflow graph; multiple stages with different visits share the same node_id.
*/

View file

@ -19,6 +19,4 @@ export interface RunTimestamps {
'started_at': string | null;
'last_event_at': string | null;
'completed_at': string | null;
'duration_ms'?: number | null;
'elapsed_secs'?: number | null;
}

View file

@ -15,15 +15,14 @@
/**
* Timing information for a run.
* Timing rollup for an entire run. Active fields sum work across stage visits, so `active_time_ms` can exceed `wall_time_ms` when parallel branches run concurrently.
*/
export interface RunTimings {
export interface RunTiming {
'wall_time_ms': number;
'inference_time_ms'?: number;
'tool_time_ms'?: number;
/**
* Wall-clock time elapsed in seconds.
* Equals `inference_time_ms + tool_time_ms`.
*/
'elapsed_secs': number;
/**
* Whether the elapsed time exceeds the expected threshold.
*/
'elapsed_warning'?: boolean;
'active_time_ms': number;
}

View file

@ -54,6 +54,9 @@ import type { RunSandbox } from './run-sandbox';
import type { RunTimestamps } from './run-timestamps';
// May contain unused imports in some cases
// @ts-ignore
import type { RunTiming } from './run-timing';
// May contain unused imports in some cases
// @ts-ignore
import type { WorkflowRef } from './workflow-ref';
/**
@ -82,6 +85,7 @@ export interface Run {
'models': Array<RunModel>;
'source_directory': string | null;
'timestamps': RunTimestamps;
'timing': RunTiming | null;
'billing': RunBillingSummary | null;
'diff': DiffSummary | null;
'pull_request': PullRequestLink | null;

View file

@ -28,6 +28,9 @@ import type { StageCompletion } from './stage-completion';
// May contain unused imports in some cases
// @ts-ignore
import type { StageState } from './stage-state';
// May contain unused imports in some cases
// @ts-ignore
import type { StageTiming } from './stage-timing';
/**
* Observable projection data for one workflow stage execution.
@ -62,10 +65,7 @@ export interface StageProjection {
* Wall-clock time the latest attempt of this stage started, if known.
*/
'started_at'?: string | null;
/**
* Wall-clock duration of the stage\'s latest terminal attempt, if known.
*/
'duration_ms'?: number | null;
'timing'?: StageTiming | null;
'usage': BilledTokenCounts;
'model'?: BillingModelRef | null;
/**

View file

@ -13,6 +13,9 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { StageTiming } from './stage-timing';
/**
* Terminal summary for one stage in a run conclusion.
@ -20,7 +23,7 @@
export interface StageSummary {
'stage_id': string;
'stage_label': string;
'duration_ms': number;
'timing': StageTiming;
'billing_usd_micros'?: number | null;
'retries': number;
}

View file

@ -0,0 +1,28 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Timing breakdown for one stage visit. Fields are all milliseconds. `wall_time_ms` is elapsed clock time; `inference_time_ms` is Fabro- observed LLM request/stream elapsed time; `tool_time_ms` is tool or command execution elapsed time; `active_time_ms` equals `inference_time_ms + tool_time_ms`.
*/
export interface StageTiming {
'wall_time_ms': number;
'inference_time_ms'?: number;
'tool_time_ms'?: number;
/**
* Equals `inference_time_ms + tool_time_ms`.
*/
'active_time_ms': number;
}