diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8c2a20d..02bf87e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -41,6 +41,37 @@ jobs:
- name: Tests
run: uv run pytest -q
+ ui:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: ui
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Enable pnpm via corepack (Node is preinstalled on the runner)
+ # No version here: ui/package.json's "packageManager" field pins it, so
+ # the version has one owner and corepack reads it from there.
+ run: corepack enable
+ - name: Install
+ run: pnpm install --frozen-lockfile
+ - name: Lint, typecheck, and test
+ run: pnpm check
+ - name: Build
+ run: pnpm build
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
+ with:
+ python-version: "3.11"
+ - name: Verify src/apiSchema.ts still matches the server's OpenAPI schema
+ # The browser's types are generated from the server's own declaration.
+ # A contract change that lands without regenerating puts them silently
+ # out of step again, which is exactly what generating them replaced.
+ run: |
+ pnpm gen:api
+ if ! git diff --exit-code src/apiSchema.ts; then
+ echo "src/apiSchema.ts is stale: run \`pnpm gen:api\` and commit the result." >&2
+ exit 1
+ fi
+
links:
runs-on: ubuntu-latest
steps:
diff --git a/docs/SERVE.md b/docs/SERVE.md
index 1954e10..2d44fe5 100644
--- a/docs/SERVE.md
+++ b/docs/SERVE.md
@@ -16,6 +16,11 @@ this package. The server ships no frontend of its own; point
`HFLOW_UI_ASSETS` at a directory containing an `index.html` to serve one, or
install a wheel that packages assets under `hflow_server/static/`.
+One such client lives in this repo at [`ui/`](../ui/README.md): a single canvas
+that draws an ingest run and drills from the run into a stage, into the steps
+that run inside a batch, and into the episodes that run recorded. It is built
+separately (`cd ui && pnpm build`) and served through `HFLOW_UI_ASSETS`.
+
It ships as a separate package, `hflow-server`, on purpose: pipeline workers
install the `hflow` wheel into every task venv, and they should never carry a
web server. **It is not published to PyPI yet** -- until the first release,
diff --git a/ui/.gitignore b/ui/.gitignore
new file mode 100644
index 0000000..e5537be
--- /dev/null
+++ b/ui/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+dist/
+*.local
diff --git a/ui/README.md b/ui/README.md
new file mode 100644
index 0000000..2d11aab
--- /dev/null
+++ b/ui/README.md
@@ -0,0 +1,99 @@
+# hflow workspace UI
+
+One canvas over an ingest run. It draws the run's graph, and every node you can
+open leads one level further in:
+
+```
+run -> stage -> steps the orchestration, and the code inside it
+run -> episodes -> episode the data that run produced
+```
+
+- **run** -- the ingest DAG as a chain: resolve the profile, then each stage.
+- **stage** -- one stage's sub-DAG: plan the batches, fan `process_batch` out
+ over them, close on a budget gate.
+- **steps** -- what one `process_batch` does to each episode: the pipeline's
+ registered checks and enrichments, plus the engine's own work.
+- **episodes** -- the episodes whose current catalog row came out of this run.
+- **episode** -- every check recorded for it, with its verdict, its gate, and
+ the measurements it was judged on.
+
+A node with a `>` opens; the inspector on the right explains whatever is
+selected; Escape walks back out.
+
+This is a **client of the `hflow-server` REST API** and holds no knowledge the
+server does not serve. It is not published as a package: build it and point the
+server at the output.
+
+## Running it
+
+```bash
+pnpm install
+pnpm dev # http://localhost:5173, proxying /api to :4356
+```
+
+`pnpm dev` needs a server to talk to. In another terminal:
+
+```bash
+uv run hflow serve --no-browser --pipeline path/to/pipeline.py
+```
+
+`--pipeline` is what makes the **steps** level non-empty: without it the server
+does not know which checks run inside a batch, and the canvas says so rather
+than guessing.
+
+To serve the built bundle from the API server itself:
+
+```bash
+pnpm build
+HFLOW_UI_ASSETS=$PWD/dist uv run hflow serve --no-browser
+```
+
+## Checks
+
+```bash
+pnpm check # tsc --noEmit, biome check, vitest
+pnpm format # biome check --write
+pnpm gen:api # regenerate src/apiSchema.ts from the server's OpenAPI schema
+```
+
+CI runs `pnpm check`, `pnpm build`, and re-runs `pnpm gen:api` to verify the
+generated types are not stale.
+
+## How it is put together
+
+Five files carry the whole thing, and only one of them has decisions in it:
+
+| file | what it owns |
+| --- | --- |
+| `src/canvas/buildGraph.ts` | focus + server payloads -> nodes and edges. Pure, and where every judgement about what is honest to draw lives. |
+| `src/canvas/focus.ts` | where the canvas is pointed, and the breadcrumb derived from it |
+| `src/canvas/layout.ts` | dagre positions, left to right |
+| `src/api.ts` | every request, typed against the generated schema |
+| `src/App.tsx` | the screen, and what a click does |
+
+`src/apiSchema.ts` is **generated** by `pnpm gen:api` from the server's own
+OpenAPI declaration -- do not hand-edit it. Nothing else in `src/` restates a
+payload field name, so a contract change surfaces as a TypeScript error rather
+than as an `undefined` at runtime.
+
+`buildGraph` is tested (`pnpm test`) because it is pure and because its rules
+matter: **an edge means a real dependency.** The server is explicit that a
+pipeline's registered steps have no dependency edges on each other, so the
+steps level groups them into tier columns and draws arrows only at the
+boundaries that are real.
+
+`src/tones.ts` is the one owner of "what colour does this outcome read as", for
+two separate vocabularies that must not be confused: Airflow's task states and
+hflow's own recorded check statuses.
+
+## Constraints it keeps
+
+- **No network beyond the API.** No CDN, no fonts, no telemetry. The workspace
+ server makes an offline promise (`docs/SERVE.md`, "Trust posture") and a
+ frontend that phones home would break it.
+- **No theme toggle.** Both palettes are in `styles.css` under
+ `prefers-color-scheme`, so there is nothing stored and nothing to keep in
+ sync with a pre-paint script.
+- **TypeScript stays on 5.x.** `openapi-typescript` drives the TypeScript
+ compiler API through `ts.factory`, which TypeScript 7's native port does not
+ expose; on 7 `pnpm gen:api` dies before emitting anything.
diff --git a/ui/biome.json b/ui/biome.json
new file mode 100644
index 0000000..a03c8fb
--- /dev/null
+++ b/ui/biome.json
@@ -0,0 +1,35 @@
+{
+ "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
+ "vcs": {
+ "enabled": false,
+ "clientKind": "git",
+ "useIgnoreFile": false
+ },
+ "files": {
+ "includes": ["**", "!dist", "!node_modules"]
+ },
+ "formatter": {
+ "enabled": true,
+ "indentStyle": "space",
+ "indentWidth": 2,
+ "lineWidth": 100
+ },
+ "javascript": {
+ "formatter": {
+ "quoteStyle": "double"
+ }
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "preset": "recommended"
+ }
+ },
+ "assist": {
+ "actions": {
+ "source": {
+ "organizeImports": "on"
+ }
+ }
+ }
+}
diff --git a/ui/index.html b/ui/index.html
new file mode 100644
index 0000000..991ab93
--- /dev/null
+++ b/ui/index.html
@@ -0,0 +1,37 @@
+
+
+
+ );
+}
+
+/**
+ * The flow itself, split out so it MOUNTS WITH ReactFlow.
+ *
+ * The framing effect below has to run against a mounted flow, and the
+ * message states above unmount it -- so an effect living beside them would
+ * fire while there is no canvas to frame.
+ */
+function CanvasFlow({
+ graph,
+ selectedNodeId,
+ onSelect,
+ onDrill,
+}: {
+ graph: CanvasGraph;
+ selectedNodeId: string | null;
+ onSelect: (nodeId: string) => void;
+ onDrill: (focus: CanvasFocus) => void;
+}) {
+ const { fitBounds } = useReactFlow();
+ // The flow's own measured container. React Flow fills it from a
+ // ResizeObserver, which fires AFTER the render that changed the layout, so
+ // framing without watching this uses the previous level's canvas height --
+ // and clips the new graph by however much the notices above it grew.
+ const flowSize = useStore((state) => `${Math.round(state.width)}x${Math.round(state.height)}`);
+ const positioned = useMemo(() => layoutGraph(graph.nodes, graph.edges), [graph]);
+ const flowNodes = useMemo(
+ () =>
+ positioned.map((node) => ({
+ id: node.id,
+ type: "canvas" as const,
+ position: { x: node.position.x, y: node.position.y },
+ data: node.data,
+ selected: node.id === selectedNodeId,
+ draggable: false,
+ // The box goes on `style`, not on the node's own width/height fields.
+ // Setting those tells React Flow the size is already known, which
+ // leaves `measured` unset -- and useNodesInitialized never turns true,
+ // so nothing ever frames the graph. Styling it lets React Flow measure
+ // the wrapper it just sized, which is the same number either way.
+ style: { width: node.width, height: node.height },
+ })),
+ [positioned, selectedNodeId],
+ );
+ const flowEdges = useMemo(() => {
+ // React Flow warns about an edge naming a node that is not on the canvas,
+ // so a rewired level's leftover edge is dropped rather than handed over.
+ const drawnNodeIds = new Set(positioned.map((node) => node.id));
+ return graph.edges
+ .filter((edge) => drawnNodeIds.has(edge.source) && drawnNodeIds.has(edge.target))
+ .map((edge) => ({
+ id: edge.id,
+ source: edge.source,
+ target: edge.target,
+ type: "smoothstep" as const,
+ label: edge.label ?? undefined,
+ className: edge.dashed ? "edge-dashed" : undefined,
+ }));
+ }, [graph, positioned]);
+
+ // Re-frame whenever the graph's own BOX changes: a new level, or a fan that
+ // just expanded, has nothing to do with the previous viewport. Deliberately
+ // not fitView: that one needs every node measured in the DOM first, so on a
+ // first paint it silently frames nothing. The box is already known here.
+ //
+ // Keyed on the box and not on the graph object, because the 4s poll rebuilds
+ // an identical graph and re-framing on every tick would pan under the reader.
+ const bounds = useMemo(() => graphBounds(positioned), [positioned]);
+ const lastFramedBox = useRef("");
+ useEffect(() => {
+ if (bounds === null) return;
+ const box = `${bounds.x}:${bounds.y}:${bounds.width}:${bounds.height}@${flowSize}`;
+ if (box === lastFramedBox.current) return;
+ lastFramedBox.current = box;
+ // One frame later: the flow measures its own container on mount, and
+ // framing against a zero-sized container would land nowhere.
+ const framed = requestAnimationFrame(() => fitBounds(bounds, { padding: 0.15, duration: 200 }));
+ return () => cancelAnimationFrame(framed);
+ }, [bounds, flowSize, fitBounds]);
+
+ return (
+ onSelect(node.id)}
+ onNodeDoubleClick={(_event, node) => {
+ const drillTo = (node.data as CanvasNodeData).drillTo;
+ if (drillTo !== null) onDrill(drillTo);
+ }}
+ proOptions={{ hideAttribution: false }}
+ minZoom={0.15}
+ maxZoom={1.6}
+ >
+
+
+
+ );
+}
+
+function Inspector({
+ node,
+ onDrill,
+}: {
+ node: CanvasNodeData | null;
+ onDrill: (focus: CanvasFocus) => void;
+}) {
+ if (node === null) {
+ return (
+
+
Select a node to see what it is.
+
+ A node with a › has more inside it: open it from here, or double-click it. Escape
+ goes back out.
+
+
+ );
+ }
+ return (
+ <>
+ {/* Tone on the heading, badges below it. The node's SHAPE was here once
+ and it told the reader nothing: "task" and "item" are this canvas's
+ own vocabulary, not facts about what they selected. */}
+
+ >
+ );
+}
+
+/** Its own component so the non-null focus is a narrowed value, not an assertion. */
+function DrillButton({
+ drillTo,
+ onDrill,
+}: {
+ drillTo: CanvasFocus | null;
+ onDrill: (focus: CanvasFocus) => void;
+}) {
+ if (drillTo === null) return null;
+ return (
+
+ );
+}
diff --git a/ui/src/api.ts b/ui/src/api.ts
new file mode 100644
index 0000000..313752c
--- /dev/null
+++ b/ui/src/api.ts
@@ -0,0 +1,167 @@
+// The only place this app talks to the server. Every payload type is an alias
+// into src/apiSchema.ts, which `pnpm gen:api` regenerates from the server's own
+// OpenAPI declaration -- so nothing here hand-copies a field name, and a
+// contract change surfaces as a TypeScript error rather than as an undefined at
+// runtime.
+
+import { useQuery } from "@tanstack/react-query";
+import type { components } from "./apiSchema";
+
+type Schemas = components["schemas"];
+
+export type Stage = Schemas["Stage"];
+export type DagTaskNode = Schemas["DagTaskNodePayload"];
+export type EpisodeCheckRun = Schemas["EpisodeCheckRunRecord"];
+export type EpisodeDossier = Schemas["EpisodeDossierResponse"];
+export type EpisodePage = Schemas["EpisodePageResponse"];
+export type PipelineEngineStep = Schemas["PipelineEngineStep"];
+export type PipelineGate = Schemas["PipelineGate"];
+export type PipelineGraph = Schemas["PipelineGraphResponse"];
+export type PipelineGraphStage = Schemas["PipelineGraphStage"];
+export type PipelineUserStep = Schemas["PipelineUserStep"];
+export type QuarantineGate = Schemas["QuarantineGate"];
+export type RunGraph = Schemas["RunGraphResponse"];
+export type RunGraphStage = Schemas["RunGraphStage"];
+export type RunTaskInstance = Schemas["RunTaskInstance"];
+export type RuntimeRunSummary = Schemas["RuntimeRunSummary"];
+export type RuntimeRuns = Schemas["RuntimeRunsResponse"];
+export type RuntimeStatus = Schemas["RuntimeStatusResponse"];
+export type WorkspaceConfig = Schemas["WorkspaceConfigResponse"];
+
+/** A refused request, carrying the server's own detail string. */
+export class ApiError extends Error {
+ constructor(
+ readonly status: number,
+ detail: string,
+ ) {
+ super(detail);
+ this.name = "ApiError";
+ }
+}
+
+/** FastAPI answers a refusal with `detail`, either a string or a validation list. */
+function refusalDetail(body: unknown, status: number): string {
+ if (typeof body === "object" && body !== null && "detail" in body) {
+ const { detail } = body as { detail: unknown };
+ if (typeof detail === "string") return detail;
+ if (Array.isArray(detail)) return detail.map((entry) => JSON.stringify(entry)).join("; ");
+ }
+ return `request failed with status ${status}`;
+}
+
+type QueryValue = string | number | readonly string[] | undefined;
+
+async function getJson(path: string, query: Record = {}): Promise {
+ const search = new URLSearchParams();
+ for (const [key, value] of Object.entries(query)) {
+ if (value === undefined) continue;
+ // An array becomes the same key repeated, which is how FastAPI's
+ // `list[str] | None = Query()` filters read a multi-value filter.
+ if (Array.isArray(value)) for (const entry of value) search.append(key, entry);
+ else search.set(key, String(value));
+ }
+ const suffix = search.size > 0 ? `?${search}` : "";
+ // Relative on purpose: the server serves this bundle and the API from the
+ // same origin, and Vite's dev proxy forwards /api to it.
+ const response = await fetch(`/api/v1${path}${suffix}`, {
+ headers: { accept: "application/json" },
+ });
+ if (!response.ok) {
+ const body = await response.json().catch(() => null);
+ throw new ApiError(response.status, refusalDetail(body, response.status));
+ }
+ return (await response.json()) as T;
+}
+
+// Airflow states that mean "this run is finished". Anything else -- running,
+// queued, a state a newer Airflow invented -- keeps the poll alive, so an
+// unrecognized state errs toward refreshing rather than toward going stale.
+const TERMINAL_RUN_STATES = new Set(["success", "failed", "skipped", "upstream_failed"]);
+
+export function isTerminalRunState(state: string | null | undefined): boolean {
+ return state !== null && state !== undefined && TERMINAL_RUN_STATES.has(state.toLowerCase());
+}
+
+const LIVE_POLL_MS = 4000;
+
+export function useWorkspaceConfig() {
+ return useQuery({
+ queryKey: ["config"],
+ queryFn: () => getJson("/config"),
+ staleTime: Number.POSITIVE_INFINITY,
+ });
+}
+
+export function useRuntimeStatus() {
+ return useQuery({
+ queryKey: ["runtime", "status"],
+ queryFn: () => getJson("/runtime/status"),
+ refetchInterval: LIVE_POLL_MS,
+ });
+}
+
+/** The master runs the canvas can be pointed at, newest first. */
+export function useRuntimeRuns(enabled: boolean) {
+ return useQuery({
+ queryKey: ["runtime", "runs"],
+ queryFn: () => getJson("/runtime/runs", { limit: 25 }),
+ enabled,
+ refetchInterval: LIVE_POLL_MS,
+ });
+}
+
+/** The topology: what the DAGs and the pipeline's steps ARE, run or no run. */
+export function usePipelineGraph() {
+ return useQuery({
+ queryKey: ["pipeline", "graph"],
+ queryFn: () => getJson("/pipeline/graph"),
+ // The bundle is re-rendered by `hflow up`, so the shape can change under a
+ // long-lived tab -- just far less often than a run's state does.
+ staleTime: 60_000,
+ });
+}
+
+/** One master run's live state over that topology. */
+export function useRunGraph(dagRunId: string | null) {
+ return useQuery({
+ queryKey: ["runtime", "runs", dagRunId, "graph"],
+ queryFn: () => getJson(`/runtime/runs/${encodeURIComponent(dagRunId ?? "")}/graph`),
+ enabled: dagRunId !== null,
+ // Stop polling once the master run is finished: its stages are finished
+ // too, so there is nothing left to refresh.
+ refetchInterval: (query) =>
+ isTerminalRunState(query.state.data?.master.state) ? false : LIVE_POLL_MS,
+ });
+}
+
+/**
+ * The episodes one ingest run produced, asked for by ALL of its stage run ids.
+ *
+ * This is the join the canvas drills through: every stage's `process_batch`
+ * stamps its own Airflow run id onto the catalog rows it appends
+ * (`episodes.orchestrator_run_id`).
+ *
+ * Every stage run id at once, not one: the catalog's `episodes` view is one row
+ * per episode -- the most recent append wins -- so in a full ingest the media
+ * stage's rows supersede sync's, meta's and labels'. Asking with a single
+ * stage's id therefore answers 0 for every stage but the last one to record,
+ * which was measured, not assumed. The union is the honest question: which
+ * episodes' current catalog row came out of this run.
+ */
+export function useRunEpisodes(orchestratorRunIds: readonly string[], limit = 200) {
+ // Sorted so the cache key does not depend on the order the stages arrived in.
+ const runIds = [...orchestratorRunIds].sort();
+ return useQuery({
+ queryKey: ["episodes", "byRun", runIds, limit],
+ queryFn: () => getJson("/episodes", { orchestrator_run_id: runIds, limit }),
+ enabled: runIds.length > 0,
+ });
+}
+
+export function useEpisodeDossier(episodeId: string | null) {
+ return useQuery({
+ queryKey: ["episodes", episodeId],
+ queryFn: () => getJson(`/episodes/${encodeURIComponent(episodeId ?? "")}`),
+ enabled: episodeId !== null,
+ });
+}
diff --git a/ui/src/apiSchema.ts b/ui/src/apiSchema.ts
new file mode 100644
index 0000000..c8490ab
--- /dev/null
+++ b/ui/src/apiSchema.ts
@@ -0,0 +1,2245 @@
+/**
+ * This file was auto-generated by openapi-typescript.
+ * Do not make direct changes to the file.
+ */
+
+export interface paths {
+ "/api/v1/catalog/tables": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** List Catalog Tables */
+ get: operations["list_catalog_tables_api_v1_catalog_tables_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/catalog/tables/{table_name}/summary": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Read Catalog Table Summary */
+ get: operations["read_catalog_table_summary_api_v1_catalog_tables__table_name__summary_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/config": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Read Config */
+ get: operations["read_config_api_v1_config_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/curation/pin": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Pin Manifest */
+ post: operations["pin_manifest_api_v1_curation_pin_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/curation/preview": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Run Curation Preview */
+ post: operations["run_curation_preview_api_v1_curation_preview_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/curation/report": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Run Curation Report */
+ post: operations["run_curation_report_api_v1_curation_report_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/episodes": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** List Episodes */
+ get: operations["list_episodes_api_v1_episodes_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/episodes/facets": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Read Episode Facets */
+ get: operations["read_episode_facets_api_v1_episodes_facets_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/episodes/stats": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Read Episode Stats */
+ get: operations["read_episode_stats_api_v1_episodes_stats_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/episodes/{episode_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Read Episode */
+ get: operations["read_episode_api_v1_episodes__episode_id__get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/episodes/{episode_id}/canonical": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Read Episode Canonical */
+ get: operations["read_episode_canonical_api_v1_episodes__episode_id__canonical_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/episodes/{episode_id}/media/{artifact_name}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Read Episode Media */
+ get: operations["read_episode_media_api_v1_episodes__episode_id__media__artifact_name__get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/episodes/{episode_id}/timeline": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Read Episode Timeline */
+ get: operations["read_episode_timeline_api_v1_episodes__episode_id__timeline_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/health": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Read Health */
+ get: operations["read_health_api_v1_health_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/manifests": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** List Manifests */
+ get: operations["list_manifests_api_v1_manifests_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/manifests/{manifest_id}/download": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Download Manifest */
+ get: operations["download_manifest_api_v1_manifests__manifest_id__download_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/pipeline": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Read Pipeline */
+ get: operations["read_pipeline_api_v1_pipeline_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/pipeline/graph": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Read Pipeline Graph
+ * @description The merged picture: the DAG topology plus the pipeline's user steps.
+ *
+ * Three degraded states, each explicit rather than an error: no runtime
+ * addressed (``dag_ids_known: false``, display-only ids), no
+ * ``--pipeline`` (``steps_known: false``, no user steps and no
+ * quarantine gate), and both at once -- the common first-run case.
+ */
+ get: operations["read_pipeline_graph_api_v1_pipeline_graph_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/queries": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** List Saved Queries */
+ get: operations["list_saved_queries_api_v1_queries_get"];
+ put?: never;
+ /** Create Saved Query */
+ post: operations["create_saved_query_api_v1_queries_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/queries/{query_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ /** Update Saved Query */
+ put: operations["update_saved_query_api_v1_queries__query_id__put"];
+ post?: never;
+ /** Delete Saved Query */
+ delete: operations["delete_saved_query_api_v1_queries__query_id__delete"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/runtime/ingest": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Trigger Ingest */
+ post: operations["trigger_ingest_api_v1_runtime_ingest_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/runtime/runs": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** List Runtime Runs */
+ get: operations["list_runtime_runs_api_v1_runtime_runs_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/runtime/runs/{dag_run_id}/graph": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Read Run Graph
+ * @description One master run's live state over the same topology.
+ *
+ * The master run is addressed directly; each stage's sub-DAG run is
+ * resolved by the documented heuristic in :func:`_matched_stage_run`.
+ */
+ get: operations["read_run_graph_api_v1_runtime_runs__dag_run_id__graph_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/runtime/status": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Read Runtime Status */
+ get: operations["read_runtime_status_api_v1_runtime_status_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+}
+export type webhooks = Record;
+export interface components {
+ schemas: {
+ /**
+ * CatalogTableDescription
+ * @description One browsable catalog relation and its live columns.
+ */
+ CatalogTableDescription: {
+ /** Columns */
+ columns: components["schemas"]["ColumnDescriptor"][];
+ /**
+ * Kind
+ * @enum {string}
+ */
+ kind: "view" | "table";
+ /** Name */
+ name: string;
+ };
+ /**
+ * CatalogTableSummaryResponse
+ * @description One relation's row count and DuckDB's own column profile.
+ */
+ CatalogTableSummaryResponse: {
+ /**
+ * Columns
+ * @description DuckDB SUMMARIZE rows; see CurationPreviewResponse.column_stats.
+ */
+ columns: {
+ [key: string]: unknown;
+ }[];
+ /** Row Count */
+ row_count: number;
+ };
+ /** CatalogTablesResponse */
+ CatalogTablesResponse: {
+ /** Tables */
+ tables: components["schemas"]["CatalogTableDescription"][];
+ };
+ /**
+ * CategoricalColumnStats
+ * @description A low-cardinality column's top values under the current filters.
+ */
+ CategoricalColumnStats: {
+ /**
+ * @description discriminator enum property added by openapi-typescript
+ * @enum {string}
+ */
+ kind: "categorical";
+ /** Name */
+ name: string;
+ /**
+ * Other Count
+ * @description Non-null rows beyond the served top values.
+ */
+ other_count: number;
+ /** Values */
+ values: components["schemas"]["ValueCount"][];
+ };
+ /**
+ * CheckCoverageEntry
+ * @description One check's coverage denominator over the WHOLE catalog, not the cut.
+ *
+ * Also the sidecar's stored shape, nested inside every stored manifest
+ * entry's ``coverage`` (see the module note).
+ */
+ CheckCoverageEntry: {
+ /** Check Name */
+ check_name: string;
+ /** Episodes Ran */
+ episodes_ran: number;
+ /** Fraction */
+ fraction: number;
+ /** Total Episodes */
+ total_episodes: number;
+ };
+ /**
+ * ColumnDescriptor
+ * @description One result column as DuckDB's ``DESCRIBE`` reports it.
+ */
+ ColumnDescriptor: {
+ /** Name */
+ name: string;
+ /** Type */
+ type: string;
+ };
+ /**
+ * CurationPreviewResponse
+ * @description A user SELECT's first rows, its full count, and optional column stats.
+ */
+ CurationPreviewResponse: {
+ /**
+ * Column Stats
+ * @description DuckDB SUMMARIZE rows (column_name, column_type, min, max, null_percentage, ...). DuckDB owns that shape and varies it by version, so it is served as-is. Null unless the request asked for stats.
+ */
+ column_stats:
+ | {
+ [key: string]: unknown;
+ }[]
+ | null;
+ /** Columns */
+ columns: components["schemas"]["ColumnDescriptor"][];
+ /**
+ * Row Count
+ * @description Rows the SELECT returns in full, independent of limit.
+ */
+ row_count: number;
+ /**
+ * Rows
+ * @description Rows of the user's own SELECT; its columns are described by 'columns'.
+ */
+ rows: {
+ [key: string]: unknown;
+ }[];
+ /**
+ * Sql
+ * @description The logical wrapped SELECT, copy-pastable as-is. The executed statement adds a '* REPLACE (...)' projection rendering TIMESTAMPTZ columns as UTC ISO text (the locked connection cannot SET TimeZone), which is a rendering detail of these rows rather than part of the query a user wrote.
+ */
+ sql: string;
+ /** Truncated */
+ truncated: boolean;
+ };
+ /**
+ * CurationReportResponse
+ * @description What a cut would contain, and what evidence backs it -- no files written.
+ */
+ CurationReportResponse: {
+ /** Coverage */
+ coverage: components["schemas"]["CheckCoverageEntry"][];
+ /** Row Count */
+ row_count: number;
+ /** Total Episodes */
+ total_episodes: number;
+ };
+ /**
+ * DagTaskNodePayload
+ * @description One task of a generated DAG (mirrors ``hflow.runtime.DagTaskNode``).
+ */
+ DagTaskNodePayload: {
+ /**
+ * Deferred
+ * @description Defers instead of holding a worker slot.
+ */
+ deferred: boolean;
+ /**
+ * Mapped
+ * @description Dynamically mapped: one instance per planned batch.
+ */
+ mapped: boolean;
+ /** Summary */
+ summary: string;
+ /** Task Id */
+ task_id: string;
+ };
+ /**
+ * DagTopologyPayload
+ * @description One DAG's real shape: its tasks and their real dependency edges.
+ */
+ DagTopologyPayload: {
+ /** Dag Id */
+ dag_id: string;
+ /**
+ * Edges
+ * @description [upstream, downstream] task-id pairs, in declaration order.
+ */
+ edges: [string, string][];
+ /** Tasks */
+ tasks: components["schemas"]["DagTaskNodePayload"][];
+ };
+ /**
+ * DossierEpisode
+ * @description The episode's own ``episodes_latest`` row plus the two derived fields.
+ *
+ * ``extra="allow"``: every column of that row rides along unchanged, because
+ * the catalog's columns are data this module cannot enumerate.
+ */
+ DossierEpisode: {
+ /**
+ * Quarantine Tags
+ * @description Parsed out of the row's quarantine_tags_json; empty when not quarantined.
+ */
+ quarantine_tags: string[];
+ /**
+ * Status
+ * @enum {string}
+ */
+ status: "ok" | "quarantined";
+ } & {
+ [key: string]: unknown;
+ };
+ /**
+ * EpisodeCheckRunRecord
+ * @description One recorded check invocation.
+ */
+ EpisodeCheckRunRecord: {
+ /** Check Name */
+ check_name: string | null;
+ /** Check Version */
+ check_version: string | null;
+ /** Critical */
+ critical: boolean | null;
+ /** Duration S */
+ duration_s: number | null;
+ /** Error */
+ error: string | null;
+ /** Recorded At */
+ recorded_at: string | null;
+ /** Run Fingerprint */
+ run_fingerprint: string | null;
+ /** Status */
+ status: string | null;
+ };
+ /**
+ * EpisodeDossierResponse
+ * @description Everything the episode page shows for one episode.
+ */
+ EpisodeDossierResponse: {
+ /** Canonical Url */
+ canonical_url: string | null;
+ /** Check Runs */
+ check_runs: components["schemas"]["EpisodeCheckRunRecord"][];
+ episode: components["schemas"]["DossierEpisode"];
+ /**
+ * History
+ * @description Every append of this episode, newest first: raw episodes_raw rows, whose columns are the catalog's (see EpisodePageResponse.rows).
+ */
+ history: {
+ [key: string]: unknown;
+ }[];
+ /** Intervals */
+ intervals: components["schemas"]["EpisodeIntervalRecord"][];
+ /** Measurements */
+ measurements: components["schemas"]["EpisodeMeasurementRecord"][];
+ /** Media */
+ media: components["schemas"]["EpisodeMediaArtifact"][];
+ /** Tags */
+ tags: components["schemas"]["EpisodeTagRecord"][];
+ };
+ /**
+ * EpisodeFacetsResponse
+ * @description Facet value counts over the wide episodes view; NULL buckets skipped.
+ *
+ * This model is the one owner of WHICH columns are faceted: ``_catalog``
+ * reads the column list off these fields rather than restating it.
+ */
+ EpisodeFacetsResponse: {
+ /** Embodiment */
+ embodiment: components["schemas"]["ValueCount"][];
+ /** Operator */
+ operator: components["schemas"]["ValueCount"][];
+ /** Pipeline Version */
+ pipeline_version: components["schemas"]["ValueCount"][];
+ /** Status */
+ status: components["schemas"]["ValueCount"][];
+ /** Task */
+ task: components["schemas"]["ValueCount"][];
+ };
+ /**
+ * EpisodeIntervalRecord
+ * @description One interval of the episode's LATEST run.
+ *
+ * ``check_version`` rides in from that run's ``check_runs`` row (a LEFT
+ * JOIN -- the intervals table carries no version of its own).
+ */
+ EpisodeIntervalRecord: {
+ /** Check Name */
+ check_name: string | null;
+ /** Check Version */
+ check_version: string | null;
+ /** End Ns */
+ end_ns: number | null;
+ /** Label */
+ label: string | null;
+ /** Start Ns */
+ start_ns: number | null;
+ };
+ /**
+ * EpisodeMeasurementRecord
+ * @description One measurement, latest per key.
+ */
+ EpisodeMeasurementRecord: {
+ /** Check Name */
+ check_name: string | null;
+ /** Check Version */
+ check_version: string | null;
+ /** Key */
+ key: string | null;
+ /** Recorded At */
+ recorded_at: string | null;
+ /** Value Bool */
+ value_bool: boolean | null;
+ /** Value Double */
+ value_double: number | null;
+ /** Value Text */
+ value_text: string | null;
+ };
+ /**
+ * EpisodeMediaArtifact
+ * @description One cataloged media artifact and, when servable, its byte URL.
+ */
+ EpisodeMediaArtifact: {
+ /** Name */
+ name: string;
+ /** Uri */
+ uri: string;
+ /**
+ * Url
+ * @description Same-origin byte-serving path, or null when the cataloged file is missing or lands outside the workspace data root.
+ */
+ url: string | null;
+ };
+ /**
+ * EpisodePageResponse
+ * @description One filtered, ordered page of the wide ``episodes`` view.
+ */
+ EpisodePageResponse: {
+ /** Columns */
+ columns: components["schemas"]["ColumnDescriptor"][];
+ /**
+ * Rows
+ * @description Rows of the wide episodes view. Its columns are data (one per measurement key present at open time), so they are described by 'columns' rather than enumerated here.
+ */
+ rows: {
+ [key: string]: unknown;
+ }[];
+ /**
+ * Sql
+ * @description The SELECT compiled for exactly these filters, with values inlined so it is copy-pastable and runs against the same catalog.
+ */
+ sql: string;
+ /**
+ * Total
+ * @description Rows matching the SAME filters, ignoring limit/offset.
+ */
+ total: number;
+ };
+ /**
+ * EpisodeStatsResponse
+ * @description Per-column mini-distributions; degenerate columns are omitted entirely.
+ */
+ EpisodeStatsResponse: {
+ /** Columns */
+ columns: (
+ | components["schemas"]["NumericColumnStats"]
+ | components["schemas"]["CategoricalColumnStats"]
+ )[];
+ };
+ /**
+ * EpisodeTagRecord
+ * @description One tag of the episode's LATEST run.
+ */
+ EpisodeTagRecord: {
+ /** Check Name */
+ check_name: string | null;
+ /** Recorded At */
+ recorded_at: string | null;
+ /** Tag */
+ tag: string | null;
+ };
+ /**
+ * EpisodeTimelineResponse
+ * @description One episode's time axis. All-null bounds mean the span is unknown --
+ * a client must say so rather than draw a fabricated axis.
+ */
+ EpisodeTimelineResponse: {
+ /** Duration S */
+ duration_s: number | null;
+ /** End Ns */
+ end_ns: number | null;
+ /** Intervals */
+ intervals: components["schemas"]["TimelineInterval"][];
+ /** Measurements */
+ measurements: components["schemas"]["TimelineMeasurement"][];
+ /** Start Ns */
+ start_ns: number | null;
+ };
+ /** HTTPValidationError */
+ HTTPValidationError: {
+ /** Detail */
+ detail?: components["schemas"]["ValidationError"][];
+ };
+ /**
+ * HealthResponse
+ * @description The liveness answer: the cheapest endpoint a probe can poll.
+ */
+ HealthResponse: {
+ /** Ok */
+ ok: boolean;
+ };
+ /** IngestRequest */
+ IngestRequest: {
+ /** Batch Count */
+ batch_count?: number | null;
+ /**
+ * Mode
+ * @default batch
+ */
+ mode: string;
+ /**
+ * Profile
+ * @default full
+ */
+ profile: string;
+ /** Uris */
+ uris: string[];
+ };
+ /**
+ * IngestTriggerResponse
+ * @description What Airflow answered when the run was triggered.
+ */
+ IngestTriggerResponse: {
+ /** Dag Run Id */
+ dag_run_id: string | null;
+ /** State */
+ state: string | null;
+ };
+ /**
+ * MappedFanOutSummary
+ * @description The fan-out's live split, counted server-side over EVERY mapped instance.
+ *
+ * Complete on its own: ``by_state`` partitions all ``total`` instances of
+ * ``task_id`` (an instance Airflow has not scheduled yet counts under
+ * ``no_status``), so ``total == sum(by_state.values())`` always holds and a
+ * client never has to recount the raw instances to size or colour the
+ * fan-out. Only a replay at some earlier instant is a different fact, and
+ * that one the server cannot answer.
+ */
+ MappedFanOutSummary: {
+ /** By State */
+ by_state: {
+ [key: string]: number;
+ };
+ /** Task Id */
+ task_id: string;
+ /**
+ * Total
+ * @description Instances reported for the mapped task. Before the fan-out expands Airflow reports one unexpanded instance, which is counted -- that is the truth at that moment.
+ */
+ total: number;
+ };
+ /**
+ * NumericColumnStats
+ * @description A numeric column's mini-distribution under the current filters.
+ */
+ NumericColumnStats: {
+ /** Buckets */
+ buckets: components["schemas"]["NumericHistogramBucket"][];
+ /**
+ * @description discriminator enum property added by openapi-typescript
+ * @enum {string}
+ */
+ kind: "numeric";
+ /** Name */
+ name: string;
+ };
+ /**
+ * NumericHistogramBucket
+ * @description One histogram bucket: ``lo`` inclusive, ``hi`` inclusive on the last.
+ */
+ NumericHistogramBucket: {
+ /** Count */
+ count: number;
+ /** Hi */
+ hi: number;
+ /** Lo */
+ lo: number;
+ };
+ /**
+ * ObservedCheckVersion
+ * @description What the catalog has SEEN of one (check, version) pair.
+ */
+ ObservedCheckVersion: {
+ /** Check Name */
+ check_name: string | null;
+ /** Check Version */
+ check_version: string | null;
+ /** First Seen */
+ first_seen: string | null;
+ /** Last Seen */
+ last_seen: string | null;
+ /** Run Count */
+ run_count: number;
+ };
+ /** PinRequest */
+ PinRequest: {
+ /**
+ * Description
+ * @default
+ */
+ description: string;
+ /** Name */
+ name: string;
+ /** Sql */
+ sql: string;
+ };
+ /**
+ * PinnedManifestEntry
+ * @description One registry entry for an immutable pinned manifest file.
+ *
+ * Also the sidecar's stored shape for a manifest (see the module note).
+ */
+ PinnedManifestEntry: {
+ /**
+ * Coverage
+ * @description Frozen at pin time.
+ */
+ coverage: components["schemas"]["CheckCoverageEntry"][];
+ /**
+ * Created At
+ * @description ISO-8601 UTC.
+ */
+ created_at: string;
+ /** Description */
+ description: string;
+ /** Id */
+ id: string;
+ /**
+ * Manifest Path
+ * @description Data-root-relative, e.g. 'manifests/-.parquet'.
+ */
+ manifest_path: string;
+ /** Name */
+ name: string;
+ /** Row Count */
+ row_count: number;
+ /** Sql */
+ sql: string;
+ /** Total Episodes */
+ total_episodes: number;
+ };
+ /** PinnedManifestListResponse */
+ PinnedManifestListResponse: {
+ /** Manifests */
+ manifests: components["schemas"]["PinnedManifestEntry"][];
+ };
+ /**
+ * PipelineEngineStep
+ * @description Engine work inside one stage that no manifest lists.
+ */
+ PipelineEngineStep: {
+ /** Name */
+ name: string;
+ /** Summary */
+ summary: string;
+ };
+ /**
+ * PipelineGate
+ * @description A step's declarative accept policy: every threshold must hold.
+ */
+ PipelineGate: {
+ /** Accept When */
+ accept_when: components["schemas"]["PipelineGateThreshold"][];
+ };
+ /**
+ * PipelineGateThreshold
+ * @description One accept condition of a step's gate.
+ */
+ PipelineGateThreshold: {
+ /**
+ * Across
+ * @description How several matching keys fold: 'every_key' or 'any_key'.
+ */
+ across: string;
+ /**
+ * Comparison
+ * @description 'at_most' or 'at_least', both inclusive.
+ */
+ comparison: string;
+ /**
+ * Key Pattern
+ * @description Glob over the measurement keys this compares.
+ */
+ key_pattern: string;
+ /** Value */
+ value: number;
+ };
+ /**
+ * PipelineGraphResponse
+ * @description The ingest DAG's shape merged with the pipeline's own steps.
+ */
+ PipelineGraphResponse: {
+ /**
+ * Dag Ids Known
+ * @description False when no runtime is addressed: the dag ids are display-only.
+ */
+ dag_ids_known: boolean;
+ master: components["schemas"]["DagTopologyPayload"];
+ /** @description Null exactly when steps_known is false. */
+ quarantine_gate: components["schemas"]["QuarantineGate"] | null;
+ /** Stages */
+ stages: components["schemas"]["PipelineGraphStage"][];
+ /**
+ * Steps Known
+ * @description False without --pipeline: what runs inside process_batch is unknown.
+ */
+ steps_known: boolean;
+ };
+ /**
+ * PipelineGraphStage
+ * @description One stage lane of the pipeline graph: its DAG plus what runs inside it.
+ */
+ PipelineGraphStage: {
+ dag: components["schemas"]["DagTopologyPayload"];
+ /** Description */
+ description: string;
+ /** Enabling Profiles */
+ enabling_profiles: string[];
+ /** Engine Steps */
+ engine_steps: components["schemas"]["PipelineEngineStep"][];
+ /** Gate Task Id */
+ gate_task_id: string;
+ stage: components["schemas"]["Stage"];
+ /** Title */
+ title: string;
+ /** Trigger Task Id */
+ trigger_task_id: string;
+ /** User Steps */
+ user_steps: components["schemas"]["PipelineUserStep"][];
+ };
+ /**
+ * PipelineResponse
+ * @description The startup-imported App, described over this workspace's catalog.
+ */
+ PipelineResponse: {
+ /**
+ * Manifest
+ * @description The pipeline manifest exactly as hflow.manifest.PipelineManifest renders it. hflow.manifest owns that shape and stamps it with 'manifest_version', so it is forwarded rather than mirrored here.
+ */
+ manifest: {
+ [key: string]: unknown;
+ };
+ /** Observed */
+ observed: components["schemas"]["ObservedCheckVersion"][];
+ /** @description Null when staleness is unknowable (no catalog yet). */
+ stale: components["schemas"]["StaleSummary"] | null;
+ };
+ /**
+ * PipelineUserStep
+ * @description A registered step as the graph endpoint serves it.
+ *
+ * ``tier`` mirrors ``hflow.App._ordered_checks``: tier 2 is exactly the steps
+ * declaring ``requires`` or ``uses``. Steps within a tier have NO ordering.
+ */
+ PipelineUserStep: {
+ /** Critical */
+ critical: boolean;
+ /** @description The policy this step rejects on, when it declares one. `critical` says a gate exists; this says which threshold on which measurement it is. */
+ gate?: components["schemas"]["PipelineGate"] | null;
+ kind: components["schemas"]["StepKind"];
+ /** Name */
+ name: string;
+ /** Requires */
+ requires: string[];
+ /**
+ * Tier
+ * @enum {integer}
+ */
+ tier: 1 | 2;
+ /** Uses */
+ uses: string | null;
+ /**
+ * Version
+ * @description Content hash of the live function.
+ */
+ version: string;
+ };
+ /** PreviewRequest */
+ PreviewRequest: {
+ /**
+ * Limit
+ * @default 100
+ */
+ limit: number;
+ /** Sql */
+ sql: string;
+ /**
+ * Stats
+ * @default false
+ */
+ stats: boolean;
+ };
+ /**
+ * QuarantineGate
+ * @description The one real cross-step edge, served as its own object rather than as
+ * an edge in either graph.
+ */
+ QuarantineGate: {
+ /** Critical Step Names */
+ critical_step_names: string[];
+ /** Explanation */
+ explanation: string;
+ from_stage: components["schemas"]["Stage"];
+ /** To Stages */
+ to_stages: components["schemas"]["Stage"][];
+ };
+ /** ReportRequest */
+ ReportRequest: {
+ /** Sql */
+ sql: string;
+ };
+ /**
+ * RunGraphMaster
+ * @description The master run's own live state.
+ */
+ RunGraphMaster: {
+ /** Dag Run Id */
+ dag_run_id: string;
+ /** State */
+ state: string | null;
+ /** Tasks */
+ tasks: components["schemas"]["RunTaskInstance"][];
+ };
+ /**
+ * RunGraphResponse
+ * @description One master run's live state over the ingest topology.
+ */
+ RunGraphResponse: {
+ master: components["schemas"]["RunGraphMaster"];
+ /** Stages */
+ stages: components["schemas"]["RunGraphStage"][];
+ };
+ /**
+ * RunGraphStage
+ * @description One stage's live state for this master run, or explicit nulls when the
+ * stage never ran for it.
+ */
+ RunGraphStage: {
+ /** Dag Id */
+ dag_id: string;
+ /** Dag Run Id */
+ dag_run_id: string | null;
+ mapped_summary: components["schemas"]["MappedFanOutSummary"] | null;
+ /**
+ * Match
+ * @description How this stage run was attributed to the master run. Airflow stores no parent-run link, so the only honest answer is 'heuristic' -- the earliest stage run started inside this master run's own window -- or null (nothing matched). Two master runs OVERLAPPING in time can still be attributed the same stage run.
+ */
+ match: "heuristic" | null;
+ stage: components["schemas"]["Stage"];
+ /** State */
+ state: string | null;
+ /** Tasks */
+ tasks: components["schemas"]["RunTaskInstance"][];
+ };
+ /**
+ * RunTaskInstance
+ * @description One Airflow task instance, reduced to what the graph draws.
+ */
+ RunTaskInstance: {
+ /** Duration S */
+ duration_s: number | null;
+ /** End Date */
+ end_date: string | null;
+ /**
+ * Map Index
+ * @description -1 means the task is not mapped.
+ */
+ map_index: number;
+ /**
+ * Queued At
+ * @description When the scheduler queued the task, so a replay can tell 'waiting for a worker' from 'running'. Airflow may omit it.
+ */
+ queued_at: string | null;
+ /** Start Date */
+ start_date: string | null;
+ /** State */
+ state: string | null;
+ /** Task Id */
+ task_id: string | null;
+ /** Try Number */
+ try_number: number | null;
+ };
+ /**
+ * RuntimeHealthComponents
+ * @description Airflow's per-component health.
+ *
+ * This model is the one owner of WHICH components /runtime/status reports:
+ * ``_runtime`` reads the names off these fields. A component absent from the
+ * deployment (a minimal stack runs no triggerer) reports null.
+ */
+ RuntimeHealthComponents: {
+ /** Dag Processor */
+ dag_processor: string | null;
+ /** Metadatabase */
+ metadatabase: string | null;
+ /** Scheduler */
+ scheduler: string | null;
+ /** Triggerer */
+ triggerer: string | null;
+ };
+ /**
+ * RuntimeRunSummary
+ * @description One master DAG run, reduced to what the Runs page shows.
+ */
+ RuntimeRunSummary: {
+ /**
+ * Conf
+ * @description The trigger's own input, forwarded verbatim.
+ */
+ conf: {
+ [key: string]: unknown;
+ };
+ /** Dag Run Id */
+ dag_run_id: string | null;
+ /** End Date */
+ end_date: string | null;
+ /** Logical Date */
+ logical_date: string | null;
+ /** Start Date */
+ start_date: string | null;
+ /** State */
+ state: string | null;
+ };
+ /** RuntimeRunsResponse */
+ RuntimeRunsResponse: {
+ /** Runs */
+ runs: components["schemas"]["RuntimeRunSummary"][];
+ /**
+ * Stages
+ * @description Per-stage recent runs; null for a remote runtime, whose stage sub-DAG ids only a bundle manifest records.
+ */
+ stages: components["schemas"]["StageRecentRuns"][] | null;
+ };
+ /**
+ * RuntimeStatusResponse
+ * @description Whether this workspace's ingest runtime is addressed AND answering.
+ *
+ * Every field except ``available`` defaults to "not known", so an
+ * unavailable answer states only the facts it actually has -- there is no
+ * second hand-written shape for the unavailable case to drift from.
+ */
+ RuntimeStatusResponse: {
+ /**
+ * Airflow Web Url
+ * @description Deep-link base for the Airflow web UI, AS ADDRESSED FROM THE WORKSPACE HOST. Only a local bundle records its own address; a remote endpoint's is unknown, never guessed.
+ */
+ airflow_web_url?: string | null;
+ /**
+ * Airflow Web Url Host Only
+ * @description True when airflow_web_url is a loopback address, so it resolves only on the workspace host: a browser on another machine cannot follow it, and the runtime is reachable there only through a tunnel or a wider `hflow up --api-bind-host`.
+ * @default false
+ */
+ airflow_web_url_host_only: boolean;
+ /** Available */
+ available: boolean;
+ /** Dag Id */
+ dag_id?: string | null;
+ /**
+ * Detail
+ * @description Why the runtime is unavailable; null when it is available.
+ */
+ detail?: string | null;
+ health?: components["schemas"]["RuntimeHealthComponents"] | null;
+ /**
+ * Registered
+ * @description Whether the master DAG is registered. Null means unknown (an auth or transient failure), which is not the same as false.
+ */
+ registered?: boolean | null;
+ /** Source */
+ source?: ("bundle" | "remote") | null;
+ };
+ /** SavedQueryCreateRequest */
+ SavedQueryCreateRequest: {
+ /** Name */
+ name: string;
+ /** Sql */
+ sql: string;
+ };
+ /**
+ * SavedQueryEntry
+ * @description One saved studio query.
+ *
+ * Also the sidecar's stored shape for a saved query (see the module note).
+ */
+ SavedQueryEntry: {
+ /** Id */
+ id: string;
+ /** Name */
+ name: string;
+ /** Sql */
+ sql: string;
+ /**
+ * Updated At
+ * @description ISO-8601 UTC.
+ */
+ updated_at: string;
+ };
+ /** SavedQueryListResponse */
+ SavedQueryListResponse: {
+ /** Queries */
+ queries: components["schemas"]["SavedQueryEntry"][];
+ };
+ /** SavedQueryUpdateRequest */
+ SavedQueryUpdateRequest: {
+ /** Name */
+ name?: string | null;
+ /** Sql */
+ sql?: string | null;
+ };
+ /**
+ * Stage
+ * @description The ingest stage graph's toggleable sub-DAGs, as stage names shared with the DAGs.
+ *
+ * These strings are conf vocabulary: the master DAG resolves a run profile
+ * to a stage set and triggers only the sub-DAGs it names, and
+ * ``App.process(stages=...)`` runs the same set in-process. One owner --
+ * here -- so the runner and the DAG bundle can never disagree.
+ * @enum {string}
+ */
+ Stage: "sync" | "meta" | "labels" | "media";
+ /**
+ * StageRecentRuns
+ * @description One stage's most recent runs. NOT correlated with any master run.
+ */
+ StageRecentRuns: {
+ /** Dag Id */
+ dag_id: string;
+ /** Recent */
+ recent: components["schemas"]["StageRunSummary"][];
+ stage: components["schemas"]["Stage"];
+ };
+ /**
+ * StageRunSummary
+ * @description One stage sub-DAG run in a stage's recent strip.
+ */
+ StageRunSummary: {
+ /** Dag Run Id */
+ dag_run_id: string | null;
+ /** End Date */
+ end_date: string | null;
+ /** Start Date */
+ start_date: string | null;
+ /** State */
+ state: string | null;
+ };
+ /**
+ * StaleSummary
+ * @description How many recorded episodes are stale against the App's current versions.
+ */
+ StaleSummary: {
+ /** Count */
+ count: number;
+ /** Pipeline Version */
+ pipeline_version: string;
+ };
+ /**
+ * StepKind
+ * @description Which registration surface a step came from.
+ * @enum {string}
+ */
+ StepKind: "check" | "enrichment";
+ /**
+ * TimelineInterval
+ * @description One interval placed on the episode's axis, in absolute ns and in
+ * seconds RELATIVE to the span start (both computed server-side).
+ */
+ TimelineInterval: {
+ /** Check Name */
+ check_name: string | null;
+ /** End Ns */
+ end_ns: number | null;
+ /** End S */
+ end_s: number | null;
+ /**
+ * Kind
+ * @description Colour group: the label's ':' prefix, else the whole label, else the check that produced it.
+ */
+ kind: string;
+ /** Label */
+ label: string | null;
+ /** Start Ns */
+ start_ns: number | null;
+ /** Start S */
+ start_s: number | null;
+ };
+ /**
+ * TimelineMeasurement
+ * @description One numeric measurement, ready to draw as a bar.
+ */
+ TimelineMeasurement: {
+ /** Key */
+ key: string;
+ /**
+ * Unit
+ * @description Inferred from the key's unit suffix; null when no dimension is known.
+ */
+ unit: string | null;
+ /** Value */
+ value: number;
+ };
+ /** ValidationError */
+ ValidationError: {
+ /** Context */
+ ctx?: Record;
+ /** Input */
+ input?: unknown;
+ /** Location */
+ loc: (string | number)[];
+ /** Message */
+ msg: string;
+ /** Error Type */
+ type: string;
+ };
+ /**
+ * ValueCount
+ * @description One value and how many episodes carry it.
+ */
+ ValueCount: {
+ /** Count */
+ count: number;
+ /** Value */
+ value: string;
+ };
+ /**
+ * WorkspaceCapabilities
+ * @description What this launch can actually do over this data root.
+ *
+ * ``runtime`` means ADDRESSED (a rendered bundle or an exported remote URL),
+ * not reachable -- /runtime/status owns liveness.
+ */
+ WorkspaceCapabilities: {
+ /** Catalog */
+ catalog: boolean;
+ /**
+ * Curation
+ * @description Whether the curation studio's durable state can be written at all: saved queries, the pinned-manifest registry, and the manifest files need a LOCAL data root, so a bucket-backed workspace answers 501 for every one of them and the frontend should not offer them.
+ */
+ curation: boolean;
+ /** Media */
+ media: boolean;
+ /** Pipeline */
+ pipeline: boolean;
+ /** Runtime */
+ runtime: boolean;
+ };
+ /**
+ * WorkspaceConfigResponse
+ * @description What this server is serving, and what the frontend may offer.
+ *
+ * Deliberately carries no Airflow deep-link base: /runtime/status is the one
+ * owner of the runtime's addressing facts, including its web URL.
+ */
+ WorkspaceConfigResponse: {
+ capabilities: components["schemas"]["WorkspaceCapabilities"];
+ /** Data Root */
+ data_root: string;
+ /** Hflow Server Version */
+ hflow_server_version: string;
+ /** Hflow Version */
+ hflow_version: string;
+ /**
+ * Ingest Modes
+ * @description Live ingest modes from hflow.steps.IngestMode; same contract as run_profiles.
+ */
+ ingest_modes: string[];
+ /**
+ * Mode
+ * @constant
+ */
+ mode: "local";
+ /** Read Only */
+ read_only: boolean;
+ /**
+ * Run Profiles
+ * @description Live run-profile names from hflow.steps.RUN_PROFILES, served so the frontend never hardcodes them.
+ */
+ run_profiles: string[];
+ /** Workspace Id */
+ workspace_id: string | null;
+ };
+ };
+ responses: never;
+ parameters: never;
+ requestBodies: never;
+ headers: never;
+ pathItems: never;
+}
+export type $defs = Record;
+export interface operations {
+ list_catalog_tables_api_v1_catalog_tables_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CatalogTablesResponse"];
+ };
+ };
+ };
+ };
+ read_catalog_table_summary_api_v1_catalog_tables__table_name__summary_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ table_name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CatalogTableSummaryResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ read_config_api_v1_config_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["WorkspaceConfigResponse"];
+ };
+ };
+ };
+ };
+ pin_manifest_api_v1_curation_pin_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["PinRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PinnedManifestEntry"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ run_curation_preview_api_v1_curation_preview_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["PreviewRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CurationPreviewResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ run_curation_report_api_v1_curation_report_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["ReportRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CurationReportResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ list_episodes_api_v1_episodes_get: {
+ parameters: {
+ query?: {
+ order_by?: string;
+ order?: "asc" | "desc";
+ limit?: number;
+ offset?: number;
+ task?: string[] | null;
+ operator?: string[] | null;
+ embodiment?: string[] | null;
+ orchestrator_run_id?: string[] | null;
+ status?: ("ok" | "quarantined") | null;
+ success?: ("true" | "false") | null;
+ search?: string | null;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["EpisodePageResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ read_episode_facets_api_v1_episodes_facets_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["EpisodeFacetsResponse"];
+ };
+ };
+ };
+ };
+ read_episode_stats_api_v1_episodes_stats_get: {
+ parameters: {
+ query?: {
+ task?: string[] | null;
+ operator?: string[] | null;
+ embodiment?: string[] | null;
+ orchestrator_run_id?: string[] | null;
+ status?: ("ok" | "quarantined") | null;
+ success?: ("true" | "false") | null;
+ search?: string | null;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["EpisodeStatsResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ read_episode_api_v1_episodes__episode_id__get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ episode_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["EpisodeDossierResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ read_episode_canonical_api_v1_episodes__episode_id__canonical_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ episode_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The file's bytes. An allowlisted inert media type (image, audio, video) is served inline under its own content type; anything else -- and every download -- is opaque application/octet-stream. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/octet-stream": string;
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ read_episode_media_api_v1_episodes__episode_id__media__artifact_name__get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ episode_id: string;
+ artifact_name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The file's bytes. An allowlisted inert media type (image, audio, video) is served inline under its own content type; anything else -- and every download -- is opaque application/octet-stream. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/octet-stream": string;
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ read_episode_timeline_api_v1_episodes__episode_id__timeline_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ episode_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["EpisodeTimelineResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ read_health_api_v1_health_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HealthResponse"];
+ };
+ };
+ };
+ };
+ list_manifests_api_v1_manifests_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PinnedManifestListResponse"];
+ };
+ };
+ };
+ };
+ download_manifest_api_v1_manifests__manifest_id__download_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ manifest_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The file's bytes. An allowlisted inert media type (image, audio, video) is served inline under its own content type; anything else -- and every download -- is opaque application/octet-stream. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/octet-stream": string;
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ read_pipeline_api_v1_pipeline_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PipelineResponse"];
+ };
+ };
+ };
+ };
+ read_pipeline_graph_api_v1_pipeline_graph_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PipelineGraphResponse"];
+ };
+ };
+ };
+ };
+ list_saved_queries_api_v1_queries_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["SavedQueryListResponse"];
+ };
+ };
+ };
+ };
+ create_saved_query_api_v1_queries_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["SavedQueryCreateRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["SavedQueryEntry"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ update_saved_query_api_v1_queries__query_id__put: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ query_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["SavedQueryUpdateRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["SavedQueryEntry"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ delete_saved_query_api_v1_queries__query_id__delete: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ query_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ trigger_ingest_api_v1_runtime_ingest_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["IngestRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["IngestTriggerResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ list_runtime_runs_api_v1_runtime_runs_get: {
+ parameters: {
+ query?: {
+ limit?: number;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["RuntimeRunsResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ read_run_graph_api_v1_runtime_runs__dag_run_id__graph_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ dag_run_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["RunGraphResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ read_runtime_status_api_v1_runtime_status_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["RuntimeStatusResponse"];
+ };
+ };
+ };
+ };
+}
diff --git a/ui/src/canvas/CanvasNodeView.tsx b/ui/src/canvas/CanvasNodeView.tsx
new file mode 100644
index 0000000..7abce98
--- /dev/null
+++ b/ui/src/canvas/CanvasNodeView.tsx
@@ -0,0 +1,47 @@
+// The one node renderer. Every level draws the same box, differing only in its
+// tone, its shape and whether it offers a drill-down, so there is nothing per
+// level to keep consistent.
+
+import { Handle, type Node, type NodeProps, Position } from "@xyflow/react";
+import type { CanvasNodeData } from "./buildGraph";
+
+export type FlowNode = Node;
+
+export function CanvasNodeView({ data, selected }: NodeProps) {
+ const drillable = data.drillTo !== null;
+ return (
+
+ {/* Left in, right out: the layout is left-to-right, so the handles have
+ to match or every edge would leave from the wrong side. */}
+
+
+ {data.title}
+ {/* Decorative, so hidden from assistive tech: what it announces is
+ already on the inspector's own "Open" button, and a bare chevron
+ read aloud on every second node would be noise. */}
+ {drillable ? (
+
+ ›
+
+ ) : null}
+