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,160 @@
1
+ from typing import Optional, Protocol, runtime_checkable
2
+
3
+ from simpy import Environment
4
+
5
+ from value_stream.task import TaskState
6
+ from value_stream.task import EventStatus
7
+
8
+ from .resource_metadata import ResourceMetadata
9
+
10
+
11
+ class ResourceTracker(Protocol):
12
+ """Defines hooks for tracking resource work and wait times."""
13
+
14
+ @property
15
+ def data(self) -> list[ResourceMetadata]: ...
16
+ def register(self, workflow_state: TaskState) -> None: ...
17
+ def start_work(self, workflow_state: TaskState, elapsed_t: float) -> None: ...
18
+ def complete_work(
19
+ self, workflow_state: TaskState, status: EventStatus, elapsed_t: Optional[float] = None
20
+ ) -> None: ...
21
+ def interruption(self, workflow_state: TaskState, elapsed_t: float) -> None: ...
22
+ def start_waiting(self, workflow_state: TaskState) -> None: ...
23
+ def complete_waiting(self, workflow_state: TaskState, waiting_t: float) -> None: ...
24
+
25
+
26
+ class NoOpResourceTracker: # pylint: disable=W0613
27
+ """Implements resource tracking hooks without recording data."""
28
+
29
+ @property
30
+ def data(self) -> list[ResourceMetadata]:
31
+ return []
32
+
33
+ def register(self, workflow_state: TaskState):
34
+ return
35
+
36
+ def start_work(self, workflow_state: TaskState, elapsed_t: float):
37
+ return
38
+
39
+ def complete_work(
40
+ self, workflow_state: TaskState, status: EventStatus, elapsed_t: Optional[float] = None
41
+ ):
42
+ return
43
+
44
+ def interruption(self, workflow_state: TaskState, elapsed_t: float):
45
+ return
46
+
47
+ def start_waiting(self, workflow_state: TaskState):
48
+ return
49
+
50
+ def complete_waiting(self, workflow_state: TaskState, waiting_t: float):
51
+ return
52
+
53
+
54
+ class InMemoryResourceTracker:
55
+ """Records resource work and wait times in memory."""
56
+
57
+ def __init__(self, env: Environment) -> None:
58
+ """Initialize the resource activity records.
59
+
60
+ Args:
61
+ env (Environment): Simulation environment.
62
+ """
63
+ self._env = env
64
+ self._epoch_t = env.now
65
+ self._data: list[ResourceMetadata] = []
66
+
67
+ @property
68
+ def data(self):
69
+ return self._data.copy()
70
+
71
+ def register(self, workflow_state: TaskState) -> None:
72
+ """Register a simulation run.
73
+
74
+ Args:
75
+ workflow_state (TaskState): Workflow state associated with the operation.
76
+ """
77
+ self._data.append(
78
+ ResourceMetadata(time=self._env.now - self._epoch_t, state=workflow_state, allocated=1)
79
+ )
80
+
81
+ def start_work(self, workflow_state: TaskState, elapsed_t: float) -> None:
82
+ """Record the start of resource work.
83
+
84
+ Args:
85
+ workflow_state (TaskState): Workflow state associated with the operation.
86
+ elapsed_t (float): Elapsed t.
87
+ """
88
+ self._data.append(
89
+ ResourceMetadata(
90
+ time=self._env.now - self._epoch_t, state=workflow_state, active=1, idle_t=elapsed_t
91
+ )
92
+ )
93
+
94
+ def complete_work(
95
+ self, workflow_state: TaskState, status: EventStatus, elapsed_t: Optional[float] = None
96
+ ) -> None:
97
+ """Record completed resource work.
98
+
99
+ Args:
100
+ workflow_state (TaskState): Workflow state associated with the operation.
101
+ status (EventStatus): Current outcome status.
102
+ elapsed_t (Optional[float]): Elapsed t.
103
+ """
104
+ if status == EventStatus.FAILURE:
105
+ self._data.append(
106
+ ResourceMetadata(
107
+ time=self._env.now - self._epoch_t,
108
+ state=workflow_state,
109
+ active=-1,
110
+ failure_t=elapsed_t,
111
+ )
112
+ )
113
+ else:
114
+ self._data.append(
115
+ ResourceMetadata(
116
+ time=self._env.now - self._epoch_t,
117
+ state=workflow_state,
118
+ active=-1,
119
+ success_t=elapsed_t,
120
+ )
121
+ )
122
+
123
+ def interruption(self, workflow_state: TaskState, elapsed_t: float) -> None:
124
+ """Record an interruption to resource work.
125
+
126
+ Args:
127
+ workflow_state (TaskState): Workflow state associated with the operation.
128
+ elapsed_t (float): Elapsed t.
129
+ """
130
+ self._data.append(
131
+ ResourceMetadata(
132
+ time=self._env.now - self._epoch_t, state=workflow_state, interruption_t=elapsed_t
133
+ )
134
+ )
135
+
136
+ def start_waiting(self, workflow_state: TaskState) -> None:
137
+ """Record that a resource began waiting.
138
+
139
+ Args:
140
+ workflow_state (TaskState): Workflow state associated with the operation.
141
+ """
142
+ self._data.append(
143
+ ResourceMetadata(time=self._env.now - self._epoch_t, state=workflow_state, waiting=1)
144
+ )
145
+
146
+ def complete_waiting(self, workflow_state: TaskState, waiting_t: float) -> None:
147
+ """Record that a resource stopped waiting.
148
+
149
+ Args:
150
+ workflow_state (TaskState): Workflow state associated with the operation.
151
+ waiting_t (float): Waiting t.
152
+ """
153
+ self._data.append(
154
+ ResourceMetadata(
155
+ time=self._env.now - self._epoch_t,
156
+ state=workflow_state,
157
+ waiting=-1,
158
+ waiting_t=waiting_t,
159
+ )
160
+ )
@@ -0,0 +1,43 @@
1
+ from typing import Any, Generator
2
+ import random
3
+
4
+ from pydantic import FiniteFloat, Field
5
+ from simpy import Environment, Timeout
6
+
7
+ from value_stream.task import EventStatus, Task
8
+
9
+ from .resource_pool import PooledResource
10
+ from .resource import Resource
11
+
12
+
13
+ class Toolchain(Resource, PooledResource):
14
+ """Simulates actions performed on tasks by SDLC tooling"""
15
+
16
+ deployment_duration: FiniteFloat = Field(ge=0)
17
+ failure_rate: FiniteFloat = Field(default=0, ge=0, le=1)
18
+
19
+ def do_work(
20
+ self, env: Environment, tasks: list[Task]
21
+ ) -> Generator[Timeout, Any, dict[str, EventStatus]]:
22
+ """Deploy tasks and yield until the deployment completes.
23
+
24
+ Args:
25
+ env (Environment): Simulation environment.
26
+ tasks (list[Task]): Tasks to process.
27
+
28
+ Yields:
29
+ Timeout: Event that completes the deployment.
30
+
31
+ Returns:
32
+ dict[str, EventStatus]: Deployment result and completion status.
33
+ """
34
+ if random.random() < self.failure_rate:
35
+ result = EventStatus.FAILURE
36
+ else:
37
+ result = EventStatus.SUCCESS
38
+
39
+ work = env.timeout(self.deployment_duration, value={"result": result})
40
+
41
+ yield work
42
+
43
+ return work.value
@@ -0,0 +1,46 @@
1
+ # Simulation service specification
2
+
3
+ The implementation design and validation milestones are in `.agent/SIMULATION_SERVICE_EXECPLAN.md`, maintained according to `.agent/PLANS.md`. This specification records the agreed behavior; the ExecPlan resolves version 1 implementation choices.
4
+
5
+ ## Goals
6
+
7
+ - Create an HTTP service that executes `value_stream.simulation.Simulation` and returns its results.
8
+ - Create a Python HTTP client under `src/value_stream/client/web`.
9
+ - Update `value_stream.client.SimulationRunner` to use the client for remote execution and to support a local service instance when no service URL is supplied.
10
+ - Keep the service contract suitable for a later web application that accepts simulation inputs and renders results. The web application is outside version 1.
11
+
12
+ ## Version 1 requirements
13
+
14
+ - Service source files belong under `src/value_stream/service`.
15
+ - Define the HTTP API using OpenAPI 3.1. Specify request, response, and error schemas rather than passing arbitrary Python objects across the wire.
16
+ - Keep the public API language-neutral: ordinary HTTP requests and JSON bodies must be usable by a later browser client or another language without importing Python package classes. Python `SimulationResult` reconstruction is specific to the version 1 Python client.
17
+ - Accept a batch of models with a shared set of tasks in one request. Preserve the association between each submitted model and its result, and return results in a form that the Python client can reconstruct as the existing `SimulationResult` types. Existing `ResultViewer` and `MetadataViewer` behavior must continue to work, including summary, task-event, and resource metadata.
18
+ - Use `DefaultSimulationPolicy` in version 1. Supporting other policies is a future extension.
19
+ - Allow a caller above `SimulationRunner` to supply a service URL; support an environment override. Service discovery is deferred.
20
+ - If neither the caller nor the environment supplies a service URL, `SimulationRunner` starts and uses a local HTTP service bound to loopback. Reuse one local instance across runner objects in the same Python process, and stop it when the last owning runner closes. `SimulationRunner` does not call `Simulation` directly.
21
+ - Submit the shared tasks and batch of models through a job API. Submission returns a job identifier promptly; clients can obtain status and each model's result as it completes through separate API calls. Define job expiration and cancellation behavior in the API contract.
22
+ - Allow concurrent execution of models in a batch. Label each result with its submitted model index so the Python client can return the final `list[SimulationResult]` in submission order, regardless of completion order.
23
+ - If one model fails, continue executing the others. Report the failed model's index and a structured error alongside successful results. A job reaches a terminal state after every model has either succeeded or failed.
24
+ - After a partially failed job reaches its terminal state, `SimulationRunner.execute` raises a dedicated batch error that carries successful `SimulationResult` objects in submission order and model-indexed errors. A fully successful job retains the existing `list[SimulationResult]` return type. The client must not silently omit failed models or put error objects in a list consumed by the existing viewers.
25
+ - Accept an optional seed with each job. Derive an independent, repeatable random stream for each submitted model from the job seed and model index, so scheduling and completion order do not change seeded simulation outcomes. The seed governs randomness during simulation; task/model factory randomness before submission is outside its scope.
26
+ - Accept optional `model_seeds`, containing one nonnegative seed per submitted model, as an alternative to the job-level `seed`. Explicit seeds are used directly and remain stable when models are reordered or submitted as a subset. Supplying both seed forms or a mismatched seed count is invalid. Existing job-level seed derivation remains unchanged.
27
+ - Accept an optional UUID `submission_id`. Identical retries return the original job without rescheduling; reuse with different content returns HTTP 409 `SUBMISSION_CONFLICT`. Reservation is atomic, and retry lookup precedes admission checks. Idempotency lasts until job expiry or server shutdown; it is not persistent exactly-once execution across service restarts.
28
+ - Include unit and integration tests, including a service/client round trip and compatibility checks for the existing viewers.
29
+ - Handle invalid input, execution failures, and client connection failures with a documented error contract.
30
+ - Provide an option to package and run the service as a Docker image.
31
+ - Keep simulation state isolated per model. Put job state behind a replaceable storage interface so a later multi-instance deployment can use shared storage. Version 1 may store jobs in memory; results need not survive service restarts.
32
+ - Add configurable protections against excessive resource use, including admission and execution limits. Select initial defaults during design and document their errors in the API contract.
33
+
34
+ ## Intended use and future scope
35
+
36
+ Version 1 is for individuals demonstrating the value-stream project. A shared, multi-user deployment, authentication, policy extensibility, service discovery, persistent results, and a browser web application are later phases. The version 1 design should leave room for these capabilities; it does not need to expose unused authentication or multi-user behavior.
37
+
38
+ ## Implementation design prerequisites
39
+
40
+ - Write an ExecPlan for this significant feature before coding, following `.agent/PLANS.md` as required by `AGENTS.md`.
41
+ - Select a worker strategy that keeps model execution and random state isolated without blocking HTTP request handling. Verify that seeded results do not depend on concurrent scheduling. Document concurrency limits and cancellation semantics.
42
+ - Define OpenAPI 3.1 schemas for tasks, models, resource pools, summary results, task events, resource metadata, job status, and errors. Preserve fields and types needed by current viewers; account for enums and UUIDs. Decide whether the Python client reuses submitted model objects in reconstructed results or creates equivalent objects.
43
+ - Define job-state retention and expiry, behavior when a job ID is unknown or expired, and what happens to running jobs when the in-memory service stops. Expose per-model progress and errors without losing successful results.
44
+ - Choose configurable defaults for task count, model count, concurrency, run duration, and request/response size. Reject excess load with documented errors.
45
+ - Establish a round-trip test that passes reconstructed results through `ResultViewer` and `MetadataViewer`, and test local service startup and shutdown as well as remote connection failures.
46
+ - Add a CI contract-alignment check that detects new simulation input fields omitted from the wire schemas or codecs. Review the generated OpenAPI contract when the server schema changes, and test the HTTP contract without the Python client.
@@ -0,0 +1 @@
1
+ __all__ = []
@@ -0,0 +1,218 @@
1
+ """HTTP application for version 1 simulation jobs."""
2
+
3
+ from contextlib import asynccontextmanager
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ from typing import Any
8
+ from uuid import UUID
9
+
10
+ from fastapi import FastAPI, Query, Request
11
+ from fastapi.exceptions import RequestValidationError
12
+ from fastapi.responses import JSONResponse
13
+ from starlette.datastructures import Headers
14
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
15
+
16
+ from .job_store import (
17
+ CapacityExceeded,
18
+ InMemoryJobStore,
19
+ JobNotFound,
20
+ JobStorage,
21
+ SubmissionConflict,
22
+ )
23
+ from .scheduler import ModelScheduler
24
+ from .schemas import (
25
+ ErrorEnvelope,
26
+ HealthResponse,
27
+ JobAccepted,
28
+ JobRequest,
29
+ JobStatus,
30
+ OutcomePage,
31
+ )
32
+ from .settings import ServiceSettings
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ def _error(
38
+ status: int, code: str, message: str, details: dict[str, object] | None = None
39
+ ) -> JSONResponse:
40
+ body = ErrorEnvelope(code=code, message=message, details=details)
41
+ return JSONResponse(status_code=status, content=body.model_dump(mode="json"))
42
+
43
+
44
+ class BodyLimitMiddleware:
45
+ """Buffer at most the configured request size before passing it downstream."""
46
+
47
+ def __init__(self, app: ASGIApp, max_body_bytes: int) -> None:
48
+ self.app = app
49
+ self.max_body_bytes = max_body_bytes
50
+
51
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
52
+ if scope["type"] != "http" or (
53
+ scope["method"] != "POST" or scope["path"] != "/v1/simulation-jobs"
54
+ ):
55
+ await self.app(scope, receive, send)
56
+ return
57
+
58
+ length = Headers(scope=scope).get("content-length")
59
+ if length is not None:
60
+ try:
61
+ declared_size = int(length)
62
+ except ValueError:
63
+ await _error(422, "INVALID_INPUT", "invalid content-length header")(
64
+ scope, receive, send
65
+ )
66
+ return
67
+ if declared_size > self.max_body_bytes:
68
+ await _error(413, "BODY_TOO_LARGE", "request body exceeds configured limit")(
69
+ scope, receive, send
70
+ )
71
+ return
72
+
73
+ buffered: list[Message] = []
74
+ size = 0
75
+ while True:
76
+ message = await receive()
77
+ if message["type"] == "http.disconnect":
78
+ return
79
+ chunk = message.get("body", b"")
80
+ size += len(chunk)
81
+ if size > self.max_body_bytes:
82
+ await _error(413, "BODY_TOO_LARGE", "request body exceeds configured limit")(
83
+ scope, receive, send
84
+ )
85
+ return
86
+ buffered.append(message)
87
+ if not message.get("more_body", False):
88
+ break
89
+
90
+ cursor = 0
91
+
92
+ async def replay_receive() -> Message:
93
+ nonlocal cursor
94
+ if cursor < len(buffered):
95
+ message = buffered[cursor]
96
+ cursor += 1
97
+ return message
98
+ return await receive()
99
+
100
+ await self.app(scope, replay_receive, send)
101
+
102
+
103
+ def create_app(
104
+ settings: ServiceSettings | None = None,
105
+ store: JobStorage | None = None,
106
+ ) -> FastAPI:
107
+ settings = settings or ServiceSettings.from_environment()
108
+ store = store or InMemoryJobStore(settings)
109
+ scheduler = ModelScheduler(store, settings)
110
+
111
+ @asynccontextmanager
112
+ async def lifespan(_app: FastAPI):
113
+ yield
114
+ scheduler.close()
115
+
116
+ application = FastAPI(
117
+ title="Value Stream Simulation Service",
118
+ version="1.0.0",
119
+ lifespan=lifespan,
120
+ )
121
+ application.add_middleware(BodyLimitMiddleware, max_body_bytes=settings.max_body_bytes)
122
+ application.state.store = store
123
+ application.state.scheduler = scheduler
124
+ application.state.settings = settings
125
+
126
+ @application.exception_handler(RequestValidationError)
127
+ async def invalid_input(_request: Request, exc: RequestValidationError):
128
+ issues = [{"location": list(item["loc"]), "message": item["msg"]} for item in exc.errors()]
129
+ return _error(422, "INVALID_INPUT", "request validation failed", {"issues": issues})
130
+
131
+ @application.exception_handler(Exception)
132
+ async def unexpected_error(_request: Request, exc: Exception):
133
+ logger.exception("unexpected service error", exc_info=exc)
134
+ return _error(500, "INTERNAL_ERROR", "unexpected service error")
135
+
136
+ errors: dict[int | str, dict[str, Any]] = {
137
+ 404: {"model": ErrorEnvelope},
138
+ 409: {"model": ErrorEnvelope},
139
+ 413: {"model": ErrorEnvelope},
140
+ 422: {"model": ErrorEnvelope},
141
+ 429: {"model": ErrorEnvelope},
142
+ 500: {"model": ErrorEnvelope},
143
+ }
144
+
145
+ @application.get("/health", response_model=HealthResponse)
146
+ def health():
147
+ return HealthResponse()
148
+
149
+ @application.post(
150
+ "/v1/simulation-jobs",
151
+ response_model=JobAccepted,
152
+ status_code=202,
153
+ responses=errors,
154
+ )
155
+ def submit(request: JobRequest):
156
+ if len(request.tasks) > settings.max_tasks or len(request.models) > settings.max_models:
157
+ return _error(422, "LIMIT_EXCEEDED", "task or model count exceeds configured limit")
158
+ try:
159
+ fingerprint = hashlib.sha256(
160
+ json.dumps(
161
+ request.model_dump(mode="json", exclude={"submission_id"}),
162
+ sort_keys=True,
163
+ separators=(",", ":"),
164
+ allow_nan=False,
165
+ ).encode()
166
+ ).hexdigest()
167
+ job_id, created = store.reserve(len(request.models), request.submission_id, fingerprint)
168
+ except SubmissionConflict as exc:
169
+ return _error(409, "SUBMISSION_CONFLICT", str(exc))
170
+ except CapacityExceeded:
171
+ return _error(429, "JOB_CAPACITY", "job capacity is full")
172
+ try:
173
+ if created:
174
+ scheduler.submit(job_id, request)
175
+ except Exception:
176
+ store.cancel(job_id)
177
+ raise
178
+ return JobAccepted(
179
+ job_id=job_id,
180
+ status=store.status(job_id).status,
181
+ status_url=f"/v1/simulation-jobs/{job_id}",
182
+ )
183
+
184
+ @application.get("/v1/simulation-jobs/{job_id}", response_model=JobStatus, responses=errors)
185
+ def status(job_id: UUID):
186
+ try:
187
+ return store.status(job_id)
188
+ except JobNotFound:
189
+ return _error(404, "JOB_NOT_FOUND", "job is unknown or expired")
190
+
191
+ @application.get(
192
+ "/v1/simulation-jobs/{job_id}/outcomes",
193
+ response_model=OutcomePage,
194
+ responses=errors,
195
+ )
196
+ def outcomes(job_id: UUID, after: int = Query(default=0, ge=0)):
197
+ try:
198
+ return store.page(job_id, after)
199
+ except JobNotFound:
200
+ return _error(404, "JOB_NOT_FOUND", "job is unknown or expired")
201
+
202
+ @application.delete(
203
+ "/v1/simulation-jobs/{job_id}",
204
+ response_model=JobStatus,
205
+ status_code=202,
206
+ responses=errors,
207
+ )
208
+ def cancel(job_id: UUID):
209
+ try:
210
+ scheduler.cancel(job_id)
211
+ return store.status(job_id)
212
+ except JobNotFound:
213
+ return _error(404, "JOB_NOT_FOUND", "job is unknown or expired")
214
+
215
+ return application
216
+
217
+
218
+ app = create_app()