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.
- value_stream/__init__.py +1 -0
- value_stream/app/SPEC.md +79 -0
- value_stream/app/__init__.py +1 -0
- value_stream/app/__main__.py +28 -0
- value_stream/app/contracts.py +18 -0
- value_stream/app/coordinator.py +359 -0
- value_stream/app/errors.py +17 -0
- value_stream/app/exports.py +142 -0
- value_stream/app/gateway.py +130 -0
- value_stream/app/generation.py +269 -0
- value_stream/app/insights.py +75 -0
- value_stream/app/metrics.py +132 -0
- value_stream/app/openapi.json +3699 -0
- value_stream/app/schemas.py +288 -0
- value_stream/app/server.py +298 -0
- value_stream/app/settings.py +46 -0
- value_stream/app/storage.py +651 -0
- value_stream/client/__init__.py +3 -0
- value_stream/client/simulation_runner.py +83 -0
- value_stream/client/views/__init__.py +4 -0
- value_stream/client/views/metadata_viewer.py +235 -0
- value_stream/client/views/result_viewer.py +76 -0
- value_stream/client/views/viewer.py +8 -0
- value_stream/client/web/__init__.py +10 -0
- value_stream/client/web/client.py +115 -0
- value_stream/client/web/errors.py +29 -0
- value_stream/client/web/local_service.py +92 -0
- value_stream/factory/__init__.py +5 -0
- value_stream/factory/developer_factory.py +33 -0
- value_stream/factory/factory.py +43 -0
- value_stream/factory/task_factory.py +64 -0
- value_stream/factory/task_generator.py +102 -0
- value_stream/policy/__init__.py +3 -0
- value_stream/policy/simulation_policy.py +20 -0
- value_stream/resources/__init__.py +23 -0
- value_stream/resources/developer.py +53 -0
- value_stream/resources/qa_tester.py +51 -0
- value_stream/resources/resource.py +162 -0
- value_stream/resources/resource_metadata.py +24 -0
- value_stream/resources/resource_policy.py +13 -0
- value_stream/resources/resource_pool.py +39 -0
- value_stream/resources/resource_tracker.py +160 -0
- value_stream/resources/toolchain.py +43 -0
- value_stream/service/SPEC.md +46 -0
- value_stream/service/__init__.py +1 -0
- value_stream/service/app.py +218 -0
- value_stream/service/codec.py +237 -0
- value_stream/service/job_store.py +322 -0
- value_stream/service/openapi.json +1283 -0
- value_stream/service/scheduler.py +234 -0
- value_stream/service/schemas.py +248 -0
- value_stream/service/settings.py +37 -0
- value_stream/service/worker.py +82 -0
- value_stream/simulation/__init__.py +16 -0
- value_stream/simulation/default_simulation_policy.py +68 -0
- value_stream/simulation/model.py +30 -0
- value_stream/simulation/model_factory.py +54 -0
- value_stream/simulation/simulation.py +144 -0
- value_stream/simulation/simulation_metadata.py +12 -0
- value_stream/simulation/simulation_result.py +20 -0
- value_stream/task/__init__.py +23 -0
- value_stream/task/epoch.py +21 -0
- value_stream/task/event_status.py +8 -0
- value_stream/task/task.py +260 -0
- value_stream/task/task_event.py +97 -0
- value_stream/task/task_history.py +208 -0
- value_stream/task/task_router.py +70 -0
- value_stream/task/task_state.py +7 -0
- value_stream/task/task_type.py +8 -0
- value_stream/workflow/__init__.py +18 -0
- value_stream/workflow/assignment_strategy.py +10 -0
- value_stream/workflow/pool_manager.py +136 -0
- value_stream/workflow/resource_operator.py +204 -0
- value_stream/workflow/sdlc_workflow.py +99 -0
- value_stream/workflow/support_workflow.py +23 -0
- value_stream/workflow/task_store.py +125 -0
- value_stream/workflow/workflow_policy.py +12 -0
- value_stream-0.1.1.dist-info/METADATA +288 -0
- value_stream-0.1.1.dist-info/RECORD +81 -0
- value_stream-0.1.1.dist-info/WHEEL +4 -0
- 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)
|