value-stream 0.1.1__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 (81) hide show
  1. value_stream/__init__.py +1 -0
  2. value_stream/app/SPEC.md +79 -0
  3. value_stream/app/__init__.py +1 -0
  4. value_stream/app/__main__.py +28 -0
  5. value_stream/app/contracts.py +18 -0
  6. value_stream/app/coordinator.py +359 -0
  7. value_stream/app/errors.py +17 -0
  8. value_stream/app/exports.py +142 -0
  9. value_stream/app/gateway.py +130 -0
  10. value_stream/app/generation.py +269 -0
  11. value_stream/app/insights.py +75 -0
  12. value_stream/app/metrics.py +132 -0
  13. value_stream/app/openapi.json +3699 -0
  14. value_stream/app/schemas.py +288 -0
  15. value_stream/app/server.py +298 -0
  16. value_stream/app/settings.py +46 -0
  17. value_stream/app/storage.py +651 -0
  18. value_stream/client/__init__.py +3 -0
  19. value_stream/client/simulation_runner.py +83 -0
  20. value_stream/client/views/__init__.py +4 -0
  21. value_stream/client/views/metadata_viewer.py +235 -0
  22. value_stream/client/views/result_viewer.py +76 -0
  23. value_stream/client/views/viewer.py +8 -0
  24. value_stream/client/web/__init__.py +10 -0
  25. value_stream/client/web/client.py +115 -0
  26. value_stream/client/web/errors.py +29 -0
  27. value_stream/client/web/local_service.py +92 -0
  28. value_stream/factory/__init__.py +5 -0
  29. value_stream/factory/developer_factory.py +33 -0
  30. value_stream/factory/factory.py +43 -0
  31. value_stream/factory/task_factory.py +64 -0
  32. value_stream/factory/task_generator.py +102 -0
  33. value_stream/policy/__init__.py +3 -0
  34. value_stream/policy/simulation_policy.py +20 -0
  35. value_stream/resources/__init__.py +23 -0
  36. value_stream/resources/developer.py +53 -0
  37. value_stream/resources/qa_tester.py +51 -0
  38. value_stream/resources/resource.py +162 -0
  39. value_stream/resources/resource_metadata.py +24 -0
  40. value_stream/resources/resource_policy.py +13 -0
  41. value_stream/resources/resource_pool.py +39 -0
  42. value_stream/resources/resource_tracker.py +160 -0
  43. value_stream/resources/toolchain.py +43 -0
  44. value_stream/service/SPEC.md +46 -0
  45. value_stream/service/__init__.py +1 -0
  46. value_stream/service/app.py +218 -0
  47. value_stream/service/codec.py +237 -0
  48. value_stream/service/job_store.py +322 -0
  49. value_stream/service/openapi.json +1283 -0
  50. value_stream/service/scheduler.py +234 -0
  51. value_stream/service/schemas.py +248 -0
  52. value_stream/service/settings.py +37 -0
  53. value_stream/service/worker.py +82 -0
  54. value_stream/simulation/__init__.py +16 -0
  55. value_stream/simulation/default_simulation_policy.py +68 -0
  56. value_stream/simulation/model.py +30 -0
  57. value_stream/simulation/model_factory.py +54 -0
  58. value_stream/simulation/simulation.py +144 -0
  59. value_stream/simulation/simulation_metadata.py +12 -0
  60. value_stream/simulation/simulation_result.py +20 -0
  61. value_stream/task/__init__.py +23 -0
  62. value_stream/task/epoch.py +21 -0
  63. value_stream/task/event_status.py +8 -0
  64. value_stream/task/task.py +260 -0
  65. value_stream/task/task_event.py +97 -0
  66. value_stream/task/task_history.py +208 -0
  67. value_stream/task/task_router.py +70 -0
  68. value_stream/task/task_state.py +7 -0
  69. value_stream/task/task_type.py +8 -0
  70. value_stream/workflow/__init__.py +18 -0
  71. value_stream/workflow/assignment_strategy.py +10 -0
  72. value_stream/workflow/pool_manager.py +136 -0
  73. value_stream/workflow/resource_operator.py +204 -0
  74. value_stream/workflow/sdlc_workflow.py +99 -0
  75. value_stream/workflow/support_workflow.py +23 -0
  76. value_stream/workflow/task_store.py +125 -0
  77. value_stream/workflow/workflow_policy.py +12 -0
  78. value_stream-0.1.1.dist-info/METADATA +288 -0
  79. value_stream-0.1.1.dist-info/RECORD +81 -0
  80. value_stream-0.1.1.dist-info/WHEEL +4 -0
  81. value_stream-0.1.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,288 @@
1
+ """Versioned browser/application wire contract."""
2
+
3
+ from typing import Annotated, Literal
4
+ from uuid import UUID, uuid4
5
+ from pydantic import ConfigDict, Field, FiniteFloat, model_validator
6
+ from value_stream.service.schemas import WireModel, ModelInput, TaskInput, ErrorEnvelope, Status
7
+
8
+ GENERATOR_VERSION = "1"
9
+ ENGINE_VERSION = "value-stream-app-1"
10
+
11
+
12
+ class AppModel(WireModel):
13
+ """Base model for application request and response data."""
14
+
15
+ model_config = ConfigDict(extra="forbid", json_schema_serialization_defaults_required=True)
16
+
17
+
18
+ class ConstantValue(AppModel):
19
+ """Defines a fixed value for a model setting."""
20
+
21
+ kind: Literal["constant"] = "constant"
22
+ value: FiniteFloat = Field(default=1, ge=0)
23
+
24
+
25
+ class UniformValue(AppModel):
26
+ """Defines a uniformly sampled value for a model setting."""
27
+
28
+ kind: Literal["uniform"] = "uniform"
29
+ minimum: FiniteFloat = Field(ge=0)
30
+ maximum: FiniteFloat = Field(ge=0)
31
+
32
+ @model_validator(mode="after")
33
+ def ordered(self):
34
+ if self.minimum > self.maximum:
35
+ raise ValueError("minimum must not exceed maximum")
36
+ return self
37
+
38
+
39
+ ValueSpec = Annotated[ConstantValue | UniformValue, Field(discriminator="kind")]
40
+
41
+
42
+ class TaskSetSpec(AppModel):
43
+ """Describes a task set used to build a simulation."""
44
+
45
+ name: str = Field(default="Task set", min_length=1, max_length=120)
46
+ count: int = Field(default=500, gt=0)
47
+ story_points: ValueSpec = Field(default_factory=lambda: UniformValue(minimum=0.5, maximum=2))
48
+ initial_value: ValueSpec = Field(default_factory=ConstantValue)
49
+ depreciation_rate: FiniteFloat = Field(default=0.02, ge=0, le=1)
50
+ seed: int = Field(default=42, ge=0, le=2**53 - 1)
51
+
52
+
53
+ class ModelSettings(AppModel):
54
+ """Defines resource and workflow settings for a simulation model."""
55
+
56
+ team_size: int = Field(default=4, gt=0)
57
+ efficiency_min: FiniteFloat = Field(default=0.5, gt=0)
58
+ efficiency_max: FiniteFloat = Field(default=1.5, gt=0)
59
+ distribution: Literal["linear", "normal"] = "linear"
60
+ deployment_cadence: int = Field(default=5, ge=0)
61
+ qa_size: int = Field(default=5, gt=0)
62
+ qa_time_cost: FiniteFloat = Field(default=0.1, ge=0)
63
+ qa_failure_rate: FiniteFloat = Field(default=0.15, ge=0, le=1)
64
+ qa_failure_cost: FiniteFloat = Field(default=0.25, ge=0, le=1)
65
+ toolchain_size: int = Field(default=20, gt=0)
66
+ deployment_duration: FiniteFloat = Field(default=0.25, ge=0)
67
+ deployment_failure_rate: FiniteFloat = Field(default=0.1, ge=0, le=1)
68
+ support_interval: FiniteFloat | None = Field(default=5, gt=0)
69
+ support_task_story_points: FiniteFloat = Field(default=2, ge=0)
70
+
71
+ @model_validator(mode="after")
72
+ def ordered(self):
73
+ if self.efficiency_min > self.efficiency_max:
74
+ raise ValueError("minimum efficiency must not exceed maximum")
75
+ return self
76
+
77
+
78
+ SweepValue = FiniteFloat | str | None
79
+
80
+
81
+ class SweepRange(AppModel):
82
+ """Defines a numeric range of values to sweep."""
83
+
84
+ start: FiniteFloat
85
+ end: FiniteFloat
86
+ step: FiniteFloat = Field(gt=0)
87
+
88
+
89
+ class ScenarioDefinition(AppModel):
90
+ """Defines a simulation scenario and its parameter values."""
91
+
92
+ id: UUID = Field(default_factory=uuid4)
93
+ name: str = Field(default="Delivery model", min_length=1, max_length=120)
94
+ revision: int = Field(default=1, gt=0)
95
+ settings: ModelSettings = Field(default_factory=ModelSettings)
96
+ sweeps: dict[str, list[SweepValue] | SweepRange] = Field(default_factory=dict)
97
+ team_seed: int = Field(default=123, ge=0, le=2**53 - 1)
98
+ execution_seed: int = Field(default=456, ge=0, le=2**53 - 1)
99
+
100
+
101
+ class ConcreteScenario(AppModel):
102
+ """Stores one fully expanded simulation scenario."""
103
+
104
+ id: UUID
105
+ definition_id: UUID
106
+ revision: int
107
+ name: str
108
+ settings: ModelSettings
109
+ team_seed: int
110
+ execution_seed: int
111
+ model: ModelInput
112
+
113
+
114
+ class TaskSet(AppModel):
115
+ """Stores task-generation settings for a workspace."""
116
+
117
+ id: UUID = Field(default_factory=uuid4)
118
+ revision: int = 1
119
+ spec: TaskSetSpec
120
+ content_hash: str
121
+ tasks: list[TaskInput]
122
+ generator_version: str = GENERATOR_VERSION
123
+
124
+
125
+ class EditorRequest(AppModel):
126
+ """Carries editor changes submitted by a client."""
127
+
128
+ expected_revision: int = Field(ge=0)
129
+ task_spec: TaskSetSpec
130
+ definitions: list[ScenarioDefinition] = Field(min_length=1, max_length=100)
131
+
132
+
133
+ class Preview(AppModel):
134
+ """Stores the result of validating an editor request."""
135
+
136
+ digest: str
137
+ task_set_id: UUID
138
+ workspace_revision: int
139
+ scenarios: list[ConcreteScenario]
140
+ count: int
141
+
142
+
143
+ class RunRequest(AppModel):
144
+ """Describes a simulation run requested by a client."""
145
+
146
+ request_id: UUID
147
+ preview_digest: str
148
+ name: str = Field(default="Baseline", min_length=1, max_length=120)
149
+ intent: Literal["manual", "interactive"] = "manual"
150
+ source_run_id: UUID | None = None
151
+ property: str | None = None
152
+ value: SweepValue = None
153
+ definition_id: UUID | None = None
154
+ family_value: SweepValue = None
155
+
156
+
157
+ RunState = Literal[
158
+ "submitting",
159
+ "queued",
160
+ "running",
161
+ "reconnecting",
162
+ "cancelling",
163
+ "completed",
164
+ "completed_with_errors",
165
+ "cancelled",
166
+ "failed",
167
+ ]
168
+ TERMINAL = {"completed", "completed_with_errors", "cancelled", "failed"}
169
+
170
+
171
+ class OutcomeSummary(AppModel):
172
+ """Summarizes the result of one simulated outcome."""
173
+
174
+ scenario: ConcreteScenario
175
+ status: Status = Status.QUEUED
176
+ cursor: int = 0
177
+ cached: bool = False
178
+ loss_percent: float | None = None
179
+ completion_time: float | None = None
180
+ delivered_value: float | None = None
181
+ error: ErrorEnvelope | None = None
182
+
183
+
184
+ class RunStatus(AppModel):
185
+ """Tracks the state and outcomes of a simulation run."""
186
+
187
+ id: UUID
188
+ name: str
189
+ task_set_id: UUID
190
+ state: RunState
191
+ intent: str
192
+ job_id: UUID | None = None
193
+ outcomes: list[OutcomeSummary]
194
+ last_cursor: int = 0
195
+ error: ErrorEnvelope | None = None
196
+ pinned: bool = False
197
+ superseded: bool = False
198
+ cancel_requested: bool = False
199
+ retry_paused: bool = False
200
+
201
+
202
+ class Workspace(AppModel):
203
+ """Stores editable simulation inputs and run history."""
204
+
205
+ id: UUID = Field(default_factory=uuid4)
206
+ revision: int = 0
207
+ task_spec: TaskSetSpec = Field(default_factory=TaskSetSpec)
208
+ definitions: list[ScenarioDefinition] = Field(
209
+ default_factory=lambda: [
210
+ ScenarioDefinition(sweeps={"team_size": [1, 13, 25], "deployment_cadence": [0, 5, 10]})
211
+ ]
212
+ )
213
+ task_sets: list[TaskSet] = Field(default_factory=list)
214
+ current_task_set: UUID | None = None
215
+ baseline_id: UUID | None = None
216
+ latest_id: UUID | None = None
217
+ runs: list[RunStatus] = Field(default_factory=list)
218
+
219
+
220
+ class ComparisonMutation(AppModel):
221
+ """Describes a change to a comparison workspace."""
222
+
223
+ action: Literal["pin", "unpin", "baseline", "delete"]
224
+
225
+
226
+ class StageLoss(AppModel):
227
+ """Stores value lost during one workflow stage."""
228
+
229
+ stage: str
230
+ label: str
231
+ loss_percent: float
232
+ visits: int
233
+
234
+
235
+ class ResourceActivity(AppModel):
236
+ """Summarizes activity for one simulation resource."""
237
+
238
+ stage: str
239
+ label: str
240
+ durations: dict[str, float]
241
+ shares: dict[str, float | None]
242
+
243
+
244
+ class Backlog(AppModel):
245
+ """Stores backlog counts for a simulation stage."""
246
+
247
+ stage: str
248
+ label: str
249
+ time: list[float]
250
+ waiting: list[int]
251
+ reduced: bool = False
252
+
253
+
254
+ class PlotData(AppModel):
255
+ """Stores chart labels and series values."""
256
+
257
+ loss_percent: float | None
258
+ stages: list[StageLoss]
259
+ activity: list[ResourceActivity]
260
+ backlog: list[Backlog]
261
+
262
+
263
+ class Observation(AppModel):
264
+ """Stores a derived observation about simulation results."""
265
+
266
+ rule: str
267
+ message: str
268
+ evidence: str
269
+ property: str | None = None
270
+ value: SweepValue = None
271
+
272
+
273
+ class ResultView(AppModel):
274
+ """Collects metrics and observations for a result view."""
275
+
276
+ scenario_id: UUID
277
+ metrics: PlotData
278
+ observations: list[Observation]
279
+
280
+
281
+ class AppConfig(AppModel):
282
+ """Stores application runtime configuration."""
283
+
284
+ limits: dict[str, int | float]
285
+ ready: bool
286
+ error: ErrorEnvelope | None = None
287
+ generator_version: str = GENERATOR_VERSION
288
+ engine_version: str = ENGINE_VERSION
@@ -0,0 +1,298 @@
1
+ """Single-origin web application and JSON contract."""
2
+
3
+ import logging
4
+ import os
5
+ from contextlib import asynccontextmanager
6
+ from dataclasses import asdict
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+ from uuid import UUID
10
+ from fastapi import FastAPI, Query
11
+ from fastapi.exceptions import RequestValidationError
12
+ from fastapi.responses import JSONResponse, FileResponse, StreamingResponse
13
+ from fastapi.staticfiles import StaticFiles
14
+ from starlette.types import ASGIApp, Scope, Receive, Send
15
+ from pydantic import ValidationError
16
+ from value_stream.service.schemas import ErrorEnvelope, HealthResponse
17
+ from .settings import AppSettings
18
+ from .storage import InMemoryWorkspaceStore, WorkspaceStore
19
+ from .gateway import HttpSimulationGateway
20
+ from .coordinator import RunCoordinator
21
+ from .errors import AppError
22
+ from .schemas import (
23
+ Workspace,
24
+ EditorRequest,
25
+ Preview,
26
+ RunRequest,
27
+ RunStatus,
28
+ OutcomeSummary,
29
+ ComparisonMutation,
30
+ ResultView,
31
+ AppConfig,
32
+ TERMINAL,
33
+ )
34
+ from .metrics import plot_data
35
+ from .insights import observations
36
+ from .exports import export_csv
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ class BodyLimitMiddleware:
42
+ """Rejects requests whose bodies exceed the configured size limit."""
43
+
44
+ def __init__(self, app: ASGIApp, limit: int):
45
+ self.app, self.limit = app, limit
46
+
47
+ async def __call__(self, scope: Scope, receive: Receive, send: Send):
48
+ if scope["type"] != "http" or scope["method"] not in {"POST", "PUT", "PATCH"}:
49
+ return await self.app(scope, receive, send)
50
+ size, messages = 0, []
51
+ while True:
52
+ message = await receive()
53
+ if message["type"] == "http.disconnect":
54
+ return
55
+ size += len(message.get("body", b""))
56
+ if size > self.limit:
57
+ return await JSONResponse(
58
+ AppError(
59
+ "BODY_TOO_LARGE",
60
+ "Request exceeds the configured body limit",
61
+ 413,
62
+ ).body(),
63
+ status_code=413,
64
+ )(scope, receive, send)
65
+ messages.append(message)
66
+ if not message.get("more_body"):
67
+ break
68
+ iterator = iter(messages)
69
+
70
+ async def replay():
71
+ return next(iterator)
72
+
73
+ await self.app(scope, replay, send)
74
+
75
+
76
+ def create_app(
77
+ settings: Optional[AppSettings] = None,
78
+ store: Optional[WorkspaceStore] = None,
79
+ gateway=None,
80
+ service_url=None,
81
+ ):
82
+ settings = settings or AppSettings.from_environment()
83
+ _store: WorkspaceStore = store or InMemoryWorkspaceStore(settings)
84
+ gateway = gateway or HttpSimulationGateway(
85
+ service_url or os.getenv("VALUE_STREAM_SERVICE_URL"),
86
+ settings.max_run_bytes + 1024 * 1024,
87
+ )
88
+ coordinator = RunCoordinator(_store, gateway)
89
+
90
+ @asynccontextmanager
91
+ async def lifespan(app):
92
+ try:
93
+ await gateway.start()
94
+ except Exception:
95
+ logger.exception("Simulation service startup failed; workspace UI remains available")
96
+ yield
97
+ await coordinator.close()
98
+ await gateway.close()
99
+
100
+ app = FastAPI(title="Value Stream Application", version="1.0.0", lifespan=lifespan)
101
+ app.state.store, app.state.coordinator, app.state.gateway = (
102
+ _store,
103
+ coordinator,
104
+ gateway,
105
+ )
106
+ app.add_middleware(BodyLimitMiddleware, limit=settings.max_body_bytes)
107
+
108
+ @app.exception_handler(AppError)
109
+ async def app_error(request, exc):
110
+ return JSONResponse(exc.body(), status_code=exc.status)
111
+
112
+ @app.exception_handler(RequestValidationError)
113
+ @app.exception_handler(ValidationError)
114
+ async def invalid(request, exc):
115
+ issues = [{"location": list(e["loc"]), "message": e["msg"]} for e in exc.errors()]
116
+ return JSONResponse(
117
+ AppError(
118
+ "INVALID_INPUT",
119
+ "Please correct the highlighted inputs.",
120
+ details={"issues": issues},
121
+ ).body(),
122
+ status_code=422,
123
+ )
124
+
125
+ @app.exception_handler(Exception)
126
+ async def unexpected(request, exc):
127
+ logger.exception("Unexpected application error", exc_info=exc)
128
+ return JSONResponse(
129
+ AppError("INTERNAL_ERROR", "Unexpected application error", 500).body(),
130
+ status_code=500,
131
+ )
132
+
133
+ errors: dict[int | str, dict[str, Any]] = {
134
+ code: {"model": ErrorEnvelope} for code in [404, 409, 413, 422, 429, 500, 502, 503]
135
+ }
136
+ prefix = "/api/v1/workspaces/{workspace_id}"
137
+
138
+ @app.get("/health", response_model=HealthResponse)
139
+ async def health():
140
+ return HealthResponse()
141
+
142
+ @app.get("/ready", response_model=HealthResponse, responses=errors)
143
+ async def ready():
144
+ await gateway.ready()
145
+ return HealthResponse()
146
+
147
+ @app.get("/api/v1/config", response_model=AppConfig)
148
+ async def config():
149
+ try:
150
+ await gateway.ready()
151
+ return AppConfig(limits=asdict(settings), ready=True)
152
+ except AppError as exc:
153
+ return AppConfig(
154
+ limits=asdict(settings), ready=False, error=ErrorEnvelope(**exc.body())
155
+ )
156
+
157
+ @app.post(
158
+ "/api/v1/workspaces",
159
+ response_model=Workspace,
160
+ status_code=201,
161
+ responses=errors,
162
+ )
163
+ async def create_workspace():
164
+ return _store.create()
165
+
166
+ @app.get(prefix, response_model=Workspace, responses=errors)
167
+ async def workspace(workspace_id: UUID):
168
+ return _store.get(workspace_id)
169
+
170
+ @app.delete(prefix, status_code=204, responses=errors)
171
+ async def delete_workspace(workspace_id: UUID):
172
+ _store.delete_workspace(workspace_id)
173
+
174
+ @app.put(prefix + "/editor", response_model=Preview, responses=errors)
175
+ async def save_editor(workspace_id: UUID, request: EditorRequest):
176
+ return _store.save_editor(workspace_id, request)
177
+
178
+ @app.post(prefix + "/preview", response_model=Preview, responses=errors)
179
+ async def preview(workspace_id: UUID):
180
+ return _store.preview(workspace_id)
181
+
182
+ @app.delete(prefix + "/task-sets/{task_set_id}", status_code=204, responses=errors)
183
+ async def delete_task_set(workspace_id: UUID, task_set_id: UUID, expected_revision: int):
184
+ _store.delete_task_set(workspace_id, task_set_id, expected_revision)
185
+
186
+ @app.post(prefix + "/runs", response_model=RunStatus, status_code=202, responses=errors)
187
+ async def start_run(workspace_id: UUID, request: RunRequest):
188
+ return await coordinator.start_run(workspace_id, request)
189
+
190
+ @app.get(prefix + "/runs/{run_id}", response_model=RunStatus, responses=errors)
191
+ async def run(workspace_id: UUID, run_id: UUID):
192
+ return _store.run(workspace_id, run_id).status
193
+
194
+ @app.get(
195
+ prefix + "/runs/{run_id}/outcomes",
196
+ response_model=list[OutcomeSummary],
197
+ responses=errors,
198
+ )
199
+ async def outcomes(workspace_id: UUID, run_id: UUID, after: int = Query(0, ge=0)):
200
+ return sorted(
201
+ [o for o in _store.run(workspace_id, run_id).status.outcomes if o.cursor > after],
202
+ key=lambda o: o.cursor,
203
+ )
204
+
205
+ @app.delete(
206
+ prefix + "/runs/{run_id}",
207
+ response_model=RunStatus,
208
+ status_code=202,
209
+ responses=errors,
210
+ )
211
+ async def cancel(workspace_id: UUID, run_id: UUID):
212
+ return await coordinator.cancel_run(workspace_id, run_id)
213
+
214
+ @app.post(prefix + "/runs/{run_id}/resume", response_model=RunStatus, responses=errors)
215
+ async def resume(workspace_id: UUID, run_id: UUID):
216
+ return coordinator.resume(workspace_id, run_id)
217
+
218
+ @app.post(prefix + "/runs/{run_id}/comparison", response_model=Workspace, responses=errors)
219
+ async def comparison(workspace_id: UUID, run_id: UUID, mutation: ComparisonMutation):
220
+ return _store.comparison(workspace_id, run_id, mutation.action)
221
+
222
+ @app.get(
223
+ prefix + "/runs/{run_id}/results/{scenario_id}",
224
+ response_model=ResultView,
225
+ responses=errors,
226
+ )
227
+ async def result(workspace_id: UUID, run_id: UUID, scenario_id: UUID):
228
+ record = _store.run(workspace_id, run_id)
229
+ result = _store.result(record, scenario_id)
230
+ scenario = next(o.scenario for o in record.status.outcomes if o.scenario.id == scenario_id)
231
+ metrics = plot_data(result, sum(t.initial_value for t in record.task_set.tasks))
232
+ insights = [
233
+ o
234
+ for o in observations(metrics, scenario.settings)
235
+ if o.property not in {"qa_size", "team_size", "toolchain_size"}
236
+ or ((o.value is not None) and (int(o.value) <= settings.max_resources))
237
+ ]
238
+ return ResultView(scenario_id=scenario_id, metrics=metrics, observations=insights)
239
+
240
+ @app.get(
241
+ prefix + "/runs/{run_id}/exports/{kind}",
242
+ responses={200: {"content": {"text/csv": {}}}, **errors},
243
+ )
244
+ async def export(workspace_id: UUID, run_id: UUID, kind: str):
245
+ if kind not in {"summary", "events", "resources"}:
246
+ raise AppError("INVALID_INPUT", "Choose summary, events, or resources")
247
+ record = _store.run(workspace_id, run_id)
248
+ if record.status.state not in TERMINAL:
249
+ raise AppError(
250
+ "RUN_ACTIVE",
251
+ "Wait for completion or cancel before exporting a stable comparison",
252
+ 409,
253
+ )
254
+ # Snapshot both rows and result references before streaming across threads.
255
+ import copy
256
+
257
+ snapshot = copy.copy(record)
258
+ snapshot.status = record.status.model_copy(deep=True)
259
+ frozen_results = {sid: _store.result(record, sid) for sid in record.results}
260
+
261
+ class ExportStore:
262
+ """Stores generated exports for later download."""
263
+
264
+ def result(self, record, sid):
265
+ return frozen_results[sid]
266
+
267
+ return StreamingResponse(
268
+ export_csv(ExportStore(), snapshot, kind),
269
+ media_type="text/csv; charset=utf-8",
270
+ headers={"Content-Disposition": f'attachment; filename="{kind}-{run_id}.csv"'},
271
+ )
272
+
273
+ class CachedAssets(StaticFiles):
274
+ """Serves cached frontend assets."""
275
+
276
+ async def get_response(self, path, scope):
277
+ response = await super().get_response(path, scope)
278
+ if response.status_code == 200:
279
+ response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
280
+ return response
281
+
282
+ static = Path(__file__).parent / "static"
283
+ if (static / "assets").exists():
284
+ app.mount("/assets", CachedAssets(directory=static / "assets"), name="assets")
285
+
286
+ @app.get("/", include_in_schema=False)
287
+ async def index():
288
+ if not (static / "index.html").is_file():
289
+ return JSONResponse(
290
+ {
291
+ "code": "ASSETS_MISSING",
292
+ "message": "Build the UI: npm run build --prefix src/value_stream/app/frontend",
293
+ },
294
+ status_code=503,
295
+ )
296
+ return FileResponse(static / "index.html", headers={"Cache-Control": "no-cache"})
297
+
298
+ return app
@@ -0,0 +1,46 @@
1
+ """Bounded single-instance application configuration."""
2
+
3
+ import os
4
+ import math
5
+ from dataclasses import dataclass, fields
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class AppSettings:
10
+ """Stores application settings."""
11
+
12
+ max_workspaces: int = 4
13
+ max_task_sets: int = 20
14
+ max_definitions: int = 100
15
+ max_tasks: int = 1000
16
+ max_models: int = 500
17
+ max_resources: int = 100
18
+ max_axis_values: int = 32
19
+ max_pins: int = 12
20
+ max_active_runs: int = 4
21
+ max_retained_runs: int = 32
22
+ max_body_bytes: int = 2 * 1024 * 1024
23
+ max_input_bytes: int = 16 * 1024 * 1024
24
+ max_result_bytes: int = 1024 * 1024 * 1024
25
+ max_run_bytes: int = 1024 * 1024 * 1024
26
+ max_model_bytes: int = 8 * 1024 * 1024
27
+ poll_seconds: float = 0.5
28
+ retry_seconds: float = 60.0
29
+ min_interactive_seconds: float = 1.0
30
+
31
+ def __post_init__(self):
32
+ if any(
33
+ not math.isfinite(getattr(self, f.name)) or getattr(self, f.name) <= 0
34
+ for f in fields(self)
35
+ ):
36
+ raise ValueError("application limits must be finite and positive")
37
+
38
+ @classmethod
39
+ def from_environment(cls):
40
+ defaults = cls()
41
+ values = {}
42
+ for f in fields(cls):
43
+ key = "VALUE_STREAM_APP_" + f.name.upper()
44
+ if key in os.environ:
45
+ values[f.name] = type(getattr(defaults, f.name))(os.environ[key])
46
+ return cls(**values)