datadoom 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. datadoom/__init__.py +23 -0
  2. datadoom/adapters/__init__.py +29 -0
  3. datadoom/adapters/frameworks.py +94 -0
  4. datadoom/adapters/loaders.py +72 -0
  5. datadoom/api/__init__.py +11 -0
  6. datadoom/api/app.py +109 -0
  7. datadoom/api/deps.py +30 -0
  8. datadoom/api/errors.py +89 -0
  9. datadoom/api/estimate.py +82 -0
  10. datadoom/api/routes/__init__.py +7 -0
  11. datadoom/api/routes/artifacts.py +147 -0
  12. datadoom/api/routes/datasets.py +180 -0
  13. datadoom/api/routes/meta.py +45 -0
  14. datadoom/api/routes/plugins.py +22 -0
  15. datadoom/api/routes/runs.py +144 -0
  16. datadoom/api/routes/specs.py +73 -0
  17. datadoom/api/routes/templates.py +30 -0
  18. datadoom/api/schemas.py +230 -0
  19. datadoom/api/serializers.py +143 -0
  20. datadoom/api/state.py +24 -0
  21. datadoom/api/store_helpers.py +56 -0
  22. datadoom/api/ws.py +72 -0
  23. datadoom/cli/__init__.py +1 -0
  24. datadoom/cli/main.py +313 -0
  25. datadoom/config.py +108 -0
  26. datadoom/engine/__init__.py +38 -0
  27. datadoom/engine/advice.py +289 -0
  28. datadoom/engine/audit.py +290 -0
  29. datadoom/engine/causal/__init__.py +15 -0
  30. datadoom/engine/causal/execute.py +116 -0
  31. datadoom/engine/causal/functions.py +116 -0
  32. datadoom/engine/causal/graph.py +54 -0
  33. datadoom/engine/difficulty/__init__.py +36 -0
  34. datadoom/engine/difficulty/calibrate.py +235 -0
  35. datadoom/engine/difficulty/knobs.py +171 -0
  36. datadoom/engine/difficulty/probes.py +181 -0
  37. datadoom/engine/dist/__init__.py +35 -0
  38. datadoom/engine/dist/base.py +46 -0
  39. datadoom/engine/dist/builtins.py +172 -0
  40. datadoom/engine/dist/compliance.py +344 -0
  41. datadoom/engine/dist/providers.py +117 -0
  42. datadoom/engine/errors.py +32 -0
  43. datadoom/engine/export/__init__.py +27 -0
  44. datadoom/engine/export/base.py +49 -0
  45. datadoom/engine/export/checksums.py +18 -0
  46. datadoom/engine/export/csv_exporter.py +34 -0
  47. datadoom/engine/export/json_exporter.py +67 -0
  48. datadoom/engine/export/metadata.py +58 -0
  49. datadoom/engine/export/parquet_exporter.py +45 -0
  50. datadoom/engine/failure/__init__.py +18 -0
  51. datadoom/engine/failure/apply.py +37 -0
  52. datadoom/engine/failure/base.py +116 -0
  53. datadoom/engine/failure/modes.py +442 -0
  54. datadoom/engine/pipeline.py +418 -0
  55. datadoom/engine/profile.py +327 -0
  56. datadoom/engine/progress.py +14 -0
  57. datadoom/engine/reference.py +338 -0
  58. datadoom/engine/reports.py +206 -0
  59. datadoom/engine/rng.py +79 -0
  60. datadoom/engine/spec/__init__.py +45 -0
  61. datadoom/engine/spec/hashing.py +57 -0
  62. datadoom/engine/spec/models.py +238 -0
  63. datadoom/engine/spec/validate.py +345 -0
  64. datadoom/engine/timeseries.py +88 -0
  65. datadoom/jobs/__init__.py +14 -0
  66. datadoom/jobs/progress.py +155 -0
  67. datadoom/jobs/worker.py +162 -0
  68. datadoom/plugin.py +35 -0
  69. datadoom/plugins/__init__.py +47 -0
  70. datadoom/plugins/contracts.py +72 -0
  71. datadoom/plugins/loader.py +125 -0
  72. datadoom/plugins/registry.py +214 -0
  73. datadoom/plugins/scaffold.py +434 -0
  74. datadoom/store/__init__.py +47 -0
  75. datadoom/store/artifacts.py +67 -0
  76. datadoom/store/db.py +104 -0
  77. datadoom/store/migrations/__init__.py +0 -0
  78. datadoom/store/migrations/env.py +53 -0
  79. datadoom/store/migrations/script.py.mako +24 -0
  80. datadoom/store/migrations/versions/0001_init.py +149 -0
  81. datadoom/store/migrations/versions/0002_report_mutual_information.py +23 -0
  82. datadoom/store/migrations/versions/0003_run_name.py +23 -0
  83. datadoom/store/migrations/versions/0004_report_profile.py +24 -0
  84. datadoom/store/models.py +170 -0
  85. datadoom/store/repositories.py +279 -0
  86. datadoom/templates/__init__.py +239 -0
  87. datadoom/templates/ab_test.datadoom.yaml +46 -0
  88. datadoom/templates/clinical_deterioration.datadoom.yaml +124 -0
  89. datadoom/templates/credit_default_challenge.datadoom.yaml +147 -0
  90. datadoom/templates/customer_churn.datadoom.yaml +60 -0
  91. datadoom/templates/ecommerce_orders.datadoom.yaml +46 -0
  92. datadoom/templates/fraud_detection.datadoom.yaml +57 -0
  93. datadoom/templates/hospital_readmission.datadoom.yaml +61 -0
  94. datadoom/templates/insurance_claims.datadoom.yaml +43 -0
  95. datadoom/templates/iot_sensors.datadoom.yaml +44 -0
  96. datadoom/templates/people_directory.datadoom.yaml +56 -0
  97. datadoom/templates/predictive_maintenance.datadoom.yaml +107 -0
  98. datadoom/templates/telecom_churn_challenge.datadoom.yaml +125 -0
  99. datadoom/version.py +3 -0
  100. datadoom/webdist/assets/index-V8VAuTJG.js +445 -0
  101. datadoom/webdist/assets/index-doRjyG5s.css +1 -0
  102. datadoom/webdist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  103. datadoom/webdist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  104. datadoom/webdist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  105. datadoom/webdist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  106. datadoom/webdist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  107. datadoom/webdist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  108. datadoom/webdist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  109. datadoom/webdist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  110. datadoom/webdist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  111. datadoom/webdist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  112. datadoom/webdist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  113. datadoom/webdist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  114. datadoom/webdist/assets/space-grotesk-latin-ext-wght-normal-D9tNdqV9.woff2 +0 -0
  115. datadoom/webdist/assets/space-grotesk-latin-wght-normal-BhU9QXUp.woff2 +0 -0
  116. datadoom/webdist/assets/space-grotesk-vietnamese-wght-normal-D0rl6rjA.woff2 +0 -0
  117. datadoom/webdist/index.html +15 -0
  118. datadoom-0.1.0.dev0.dist-info/METADATA +143 -0
  119. datadoom-0.1.0.dev0.dist-info/RECORD +122 -0
  120. datadoom-0.1.0.dev0.dist-info/WHEEL +4 -0
  121. datadoom-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  122. datadoom-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,230 @@
1
+ """Request/response models — the typed surface FastAPI turns into OpenAPI.
2
+
3
+ The frontend generates its API client from ``/api/openapi.json``, so these
4
+ shapes ARE the contract (doc 08). Spec bodies travel as open ``dict`` payloads
5
+ (the authoritative validation lives in ``engine.spec``); these models describe
6
+ the persistence/metadata envelope around them.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, Field
14
+
15
+ SpecBody = dict[str, Any]
16
+
17
+
18
+ # --- errors -------------------------------------------------------------------
19
+ class ErrorDetail(BaseModel):
20
+ code: str
21
+ message: str
22
+ locator: str | None = None
23
+
24
+
25
+ class ErrorResponse(BaseModel):
26
+ error: ErrorDetail
27
+
28
+
29
+ # --- specs (stateless helpers) ------------------------------------------------
30
+ class ValidateResponse(BaseModel):
31
+ valid: bool = True
32
+ spec_hash: str
33
+ warnings: list[str] = Field(default_factory=list)
34
+
35
+
36
+ class ParseTextRequest(BaseModel):
37
+ """Raw spec text (YAML or JSON) for the web 'New from YAML' import flow."""
38
+
39
+ text: str
40
+
41
+
42
+ class ParseResponse(BaseModel):
43
+ valid: bool = True
44
+ spec_hash: str
45
+ spec: SpecBody # the parsed, validated spec body (JSON form)
46
+
47
+
48
+ class HashResponse(BaseModel):
49
+ spec_hash: str
50
+
51
+
52
+ class EstimateResponse(BaseModel):
53
+ estimated_runtime_seconds: float
54
+ estimated_ram_mb: float
55
+ estimated_size_bytes: int
56
+ features: int
57
+ edges: int
58
+ gpu_required: bool = False
59
+
60
+
61
+ # --- specs of a dataset -------------------------------------------------------
62
+ class SpecSummary(BaseModel):
63
+ spec_id: str
64
+ spec_hash: str
65
+ version: int
66
+ datadoom_version: str
67
+ created_at: str
68
+
69
+
70
+ class SpecDetail(SpecSummary):
71
+ body: SpecBody
72
+
73
+
74
+ class SaveSpecResponse(BaseModel):
75
+ spec_id: str
76
+ spec_hash: str
77
+ version: int
78
+
79
+
80
+ # --- runs ---------------------------------------------------------------------
81
+ class RunSummary(BaseModel):
82
+ run_id: str
83
+ dataset_id: str
84
+ spec_id: str
85
+ spec_hash: str | None = None
86
+ name: str | None = None
87
+ seed: int
88
+ status: str
89
+ stage: str | None = None
90
+ progress_pct: int = 0
91
+ compliance_score: float | None = None
92
+ error: dict[str, Any] | None = None
93
+ metrics: dict[str, Any] | None = None
94
+ started_at: str | None = None
95
+ finished_at: str | None = None
96
+ created_at: str
97
+
98
+
99
+ class CreateRunRequest(BaseModel):
100
+ seed: int | None = None
101
+ name: str | None = None
102
+
103
+
104
+ class UpdateRunRequest(BaseModel):
105
+ name: str
106
+
107
+
108
+ class CreateRunResponse(BaseModel):
109
+ run_id: str
110
+ status: str
111
+ seed: int
112
+ ws: str
113
+
114
+
115
+ class CancelResponse(BaseModel):
116
+ status: str
117
+
118
+
119
+ # --- datasets -----------------------------------------------------------------
120
+ class LatestRun(BaseModel):
121
+ run_id: str
122
+ status: str
123
+ compliance_score: float | None = None
124
+
125
+
126
+ class DatasetSummary(BaseModel):
127
+ dataset_id: str
128
+ name: str
129
+ description: str | None = None
130
+ status: str
131
+ rows: int | None = None
132
+ features: int | None = None
133
+ compliance_score: float | None = None
134
+ created_at: str
135
+ updated_at: str
136
+
137
+
138
+ class DatasetList(BaseModel):
139
+ items: list[DatasetSummary]
140
+ total: int
141
+
142
+
143
+ class Dataset(BaseModel):
144
+ dataset_id: str
145
+ name: str
146
+ description: str | None = None
147
+ status: str
148
+ current_spec: SpecDetail | None = None
149
+ latest_run: LatestRun | None = None
150
+ created_at: str
151
+ updated_at: str
152
+
153
+
154
+ class CreateDatasetRequest(BaseModel):
155
+ name: str
156
+ description: str | None = None
157
+ spec: SpecBody | None = None
158
+
159
+
160
+ class UpdateDatasetRequest(BaseModel):
161
+ name: str | None = None
162
+ description: str | None = None
163
+
164
+
165
+ # --- artifacts & reports ------------------------------------------------------
166
+ class Artifact(BaseModel):
167
+ artifact_id: str
168
+ run_id: str
169
+ version: str
170
+ split: str | None = None
171
+ format: str
172
+ filename: str
173
+ size_bytes: int
174
+ checksum_sha256: str
175
+ created_at: str
176
+
177
+
178
+ class Report(BaseModel):
179
+ report_id: str
180
+ run_id: str
181
+ compliance_score: float | None = None
182
+ distribution: dict[str, Any] | None = None
183
+ correlation: dict[str, Any] | None = None
184
+ mutual_information: dict[str, Any] | None = None
185
+ causal_truth: dict[str, Any] | None = None
186
+ difficulty: dict[str, Any] | None = None
187
+ failures: dict[str, Any] | None = None
188
+ profile: dict[str, Any] | None = None
189
+ determinism: dict[str, Any] | None = None
190
+
191
+
192
+ class PreviewResponse(BaseModel):
193
+ columns: list[str]
194
+ rows: list[list[Any]]
195
+ total: int
196
+
197
+
198
+ # --- templates & plugins & meta ----------------------------------------------
199
+ class TemplateSummary(BaseModel):
200
+ id: str
201
+ name: str
202
+ domain: str
203
+ description: str
204
+ tags: list[str] = Field(default_factory=list)
205
+ level: str = "starter" # "starter" | "hackathon"
206
+
207
+
208
+ class TemplateDetail(TemplateSummary):
209
+ spec: dict[str, Any]
210
+
211
+
212
+ class PluginInfo(BaseModel):
213
+ name: str
214
+ kind: str
215
+ version: str | None = None
216
+ schema_: dict[str, Any] | None = Field(default=None, alias="schema")
217
+ source: str = "builtin" # builtin | entrypoint | local
218
+ builtin: bool = True
219
+ enabled: bool = True
220
+
221
+
222
+ class HealthResponse(BaseModel):
223
+ status: str = "ok"
224
+
225
+
226
+ class VersionResponse(BaseModel):
227
+ version: str
228
+ datadoom_version: str
229
+ python: str
230
+ platform: str
@@ -0,0 +1,143 @@
1
+ """ORM row -> API schema converters (keeps route bodies thin)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from datadoom.store import (
8
+ ArtifactRow,
9
+ DatasetRow,
10
+ GenerationRunRow,
11
+ ReportRow,
12
+ SpecRow,
13
+ )
14
+
15
+ from . import schemas
16
+
17
+
18
+ def spec_summary(row: SpecRow) -> schemas.SpecSummary:
19
+ return schemas.SpecSummary(
20
+ spec_id=row.spec_id,
21
+ spec_hash=row.spec_hash,
22
+ version=row.version,
23
+ datadoom_version=row.datadoom_version,
24
+ created_at=row.created_at,
25
+ )
26
+
27
+
28
+ def spec_detail(row: SpecRow) -> schemas.SpecDetail:
29
+ return schemas.SpecDetail(
30
+ spec_id=row.spec_id,
31
+ spec_hash=row.spec_hash,
32
+ version=row.version,
33
+ datadoom_version=row.datadoom_version,
34
+ created_at=row.created_at,
35
+ body=dict(row.body),
36
+ )
37
+
38
+
39
+ def run_summary(row: GenerationRunRow) -> schemas.RunSummary:
40
+ compliance = None
41
+ if row.metrics:
42
+ compliance = row.metrics.get("compliance_score")
43
+ return schemas.RunSummary(
44
+ run_id=row.run_id,
45
+ dataset_id=row.dataset_id,
46
+ spec_id=row.spec_id,
47
+ spec_hash=row.spec.spec_hash if row.spec is not None else None,
48
+ name=row.name,
49
+ seed=row.seed,
50
+ status=row.status,
51
+ stage=row.stage,
52
+ progress_pct=row.progress_pct,
53
+ compliance_score=compliance,
54
+ error=row.error,
55
+ metrics=row.metrics,
56
+ started_at=row.started_at,
57
+ finished_at=row.finished_at,
58
+ created_at=row.created_at,
59
+ )
60
+
61
+
62
+ def latest_run(row: GenerationRunRow | None) -> schemas.LatestRun | None:
63
+ if row is None:
64
+ return None
65
+ compliance = row.metrics.get("compliance_score") if row.metrics else None
66
+ return schemas.LatestRun(
67
+ run_id=row.run_id, status=row.status, compliance_score=compliance
68
+ )
69
+
70
+
71
+ def _spec_stats(body: dict[str, Any] | None) -> tuple[int | None, int | None]:
72
+ if not body:
73
+ return None, None
74
+ rows = body.get("rows")
75
+ features = body.get("features")
76
+ return rows, (len(features) if isinstance(features, dict) else None)
77
+
78
+
79
+ def dataset_summary(
80
+ row: DatasetRow, current_spec: SpecRow | None, latest: GenerationRunRow | None
81
+ ) -> schemas.DatasetSummary:
82
+ rows, features = _spec_stats(current_spec.body if current_spec else None)
83
+ compliance = latest.metrics.get("compliance_score") if latest and latest.metrics else None
84
+ return schemas.DatasetSummary(
85
+ dataset_id=row.dataset_id,
86
+ name=row.name,
87
+ description=row.description,
88
+ status=row.status,
89
+ rows=rows,
90
+ features=features,
91
+ compliance_score=compliance,
92
+ created_at=row.created_at,
93
+ updated_at=row.updated_at,
94
+ )
95
+
96
+
97
+ def dataset(
98
+ row: DatasetRow, current_spec: SpecRow | None, latest: GenerationRunRow | None
99
+ ) -> schemas.Dataset:
100
+ return schemas.Dataset(
101
+ dataset_id=row.dataset_id,
102
+ name=row.name,
103
+ description=row.description,
104
+ status=row.status,
105
+ current_spec=spec_detail(current_spec) if current_spec else None,
106
+ latest_run=latest_run(latest),
107
+ created_at=row.created_at,
108
+ updated_at=row.updated_at,
109
+ )
110
+
111
+
112
+ def artifact(row: ArtifactRow) -> schemas.Artifact:
113
+ # The real on-disk filename is the basename of the storage URI — the
114
+ # authoritative name (data.csv, data.injected.csv, metadata.json, …) so the
115
+ # UI never has to guess clean-vs-injected from version/format.
116
+ filename = row.storage_uri.replace("\\", "/").rsplit("/", 1)[-1]
117
+ return schemas.Artifact(
118
+ artifact_id=row.artifact_id,
119
+ run_id=row.run_id,
120
+ version=row.version,
121
+ split=row.split,
122
+ format=row.format,
123
+ filename=filename,
124
+ size_bytes=row.size_bytes,
125
+ checksum_sha256=row.checksum_sha256,
126
+ created_at=row.created_at,
127
+ )
128
+
129
+
130
+ def report(row: ReportRow) -> schemas.Report:
131
+ return schemas.Report(
132
+ report_id=row.report_id,
133
+ run_id=row.run_id,
134
+ compliance_score=row.compliance_score,
135
+ distribution=row.distribution,
136
+ correlation=row.correlation,
137
+ mutual_information=row.mutual_information,
138
+ causal_truth=row.causal_truth,
139
+ difficulty=row.difficulty,
140
+ failures=row.failures,
141
+ profile=row.profile,
142
+ determinism=row.determinism,
143
+ )
datadoom/api/state.py ADDED
@@ -0,0 +1,24 @@
1
+ """Shared application state, assembled by the app factory and hung on
2
+ ``app.state.dd``. Holds the singletons the routes need: config, the database,
3
+ the artifact store, the event hub, and the worker pool.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+
10
+ from datadoom.config import Config
11
+ from datadoom.jobs import EventHub, WorkerPool
12
+ from datadoom.store import ArtifactStore, Database
13
+
14
+
15
+ @dataclass
16
+ class AppState:
17
+ config: Config
18
+ db: Database
19
+ artifacts: ArtifactStore
20
+ hub: EventHub
21
+ worker: WorkerPool
22
+ # In-process idempotency map: (dataset_id, key) -> run_id (08 §1). Sufficient
23
+ # for the single-process local server; team mode would persist this.
24
+ idempotency: dict[tuple[str, str], str] = field(default_factory=dict)
@@ -0,0 +1,56 @@
1
+ """Thin helpers shared by routes: dataset loading + latest-run lookup.
2
+
3
+ Re-exports the store repositories so route modules import them from one place,
4
+ and centralizes the "404 if missing" and "latest run" patterns.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from sqlalchemy.orm import Session
10
+
11
+ from datadoom.store import (
12
+ ArtifactRepository,
13
+ DatasetRepository,
14
+ DatasetRow,
15
+ GenerationRunRow,
16
+ ReportRepository,
17
+ RunRepository,
18
+ SpecRepository,
19
+ )
20
+
21
+ from .errors import http_error
22
+
23
+ __all__ = [
24
+ "DatasetRepository",
25
+ "SpecRepository",
26
+ "RunRepository",
27
+ "ArtifactRepository",
28
+ "ReportRepository",
29
+ "load_dataset",
30
+ "load_run",
31
+ "latest_run_row",
32
+ ]
33
+
34
+
35
+ def load_dataset(session: Session, dataset_id: str) -> DatasetRow:
36
+ row = DatasetRepository(session).get(dataset_id)
37
+ if row is None:
38
+ raise http_error(404, "not_found", f"dataset {dataset_id} not found")
39
+ return row
40
+
41
+
42
+ def load_run(session: Session, run_id: str) -> GenerationRunRow:
43
+ row = RunRepository(session).get(run_id)
44
+ if row is None:
45
+ raise http_error(404, "not_found", f"run {run_id} not found")
46
+ return row
47
+
48
+
49
+ def latest_run_row(runs: RunRepository, dataset: DatasetRow) -> GenerationRunRow | None:
50
+ """Prefer the dataset's recorded latest run, else the most recent by time."""
51
+ if dataset.latest_run_id:
52
+ found = runs.get(dataset.latest_run_id)
53
+ if found is not None:
54
+ return found
55
+ rows = runs.list_for_dataset(dataset.dataset_id)
56
+ return rows[0] if rows else None
datadoom/api/ws.py ADDED
@@ -0,0 +1,72 @@
1
+ """Live progress transport (08 §7): WebSocket primary, SSE fallback.
2
+
3
+ Both subscribe to the :class:`~datadoom.jobs.progress.EventHub`, which replays
4
+ the stage events so far to a late subscriber, then streams live updates until a
5
+ terminal event (``completed`` / ``failed`` / ``cancelled``). The WS channel also
6
+ accepts ``{"type":"cancel"}`` from the client.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import contextlib
13
+ import json
14
+
15
+ from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
16
+ from fastapi.responses import StreamingResponse
17
+
18
+ from .state import AppState
19
+
20
+ router = APIRouter(tags=["ws"])
21
+
22
+ _TERMINAL = {"completed", "failed", "cancelled"}
23
+
24
+
25
+ @router.websocket("/api/ws/runs/{run_id}")
26
+ async def ws_run(websocket: WebSocket, run_id: str) -> None:
27
+ await websocket.accept()
28
+ state: AppState = websocket.app.state.dd
29
+ hub = state.hub
30
+
31
+ async def pump_client() -> None:
32
+ # Listen for client -> server messages (only "cancel" is meaningful).
33
+ try:
34
+ while True:
35
+ msg = await websocket.receive_text()
36
+ try:
37
+ data = json.loads(msg)
38
+ except ValueError:
39
+ continue
40
+ if data.get("type") == "cancel":
41
+ hub.request_cancel(run_id)
42
+ except WebSocketDisconnect:
43
+ return
44
+
45
+ client_task = asyncio.create_task(pump_client())
46
+ try:
47
+ async for event in hub.subscribe(run_id):
48
+ await websocket.send_json(event)
49
+ if event.get("type") in _TERMINAL:
50
+ break
51
+ except WebSocketDisconnect:
52
+ pass
53
+ finally:
54
+ client_task.cancel()
55
+ with contextlib.suppress(RuntimeError):
56
+ await websocket.close()
57
+
58
+
59
+ @router.get("/api/runs/{run_id}/events")
60
+ async def sse_run(run_id: str, request: Request) -> StreamingResponse:
61
+ state: AppState = request.app.state.dd
62
+ hub = state.hub
63
+
64
+ async def event_stream(): # noqa: ANN202
65
+ async for event in hub.subscribe(run_id):
66
+ if await request.is_disconnected():
67
+ break
68
+ yield f"data: {json.dumps(event)}\n\n"
69
+ if event.get("type") in _TERMINAL:
70
+ break
71
+
72
+ return StreamingResponse(event_stream(), media_type="text/event-stream")
@@ -0,0 +1 @@
1
+ """DataDoom command-line interface."""