flode 0.60.3__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 (73) hide show
  1. flode/__init__.py +88 -0
  2. flode/analysis/__init__.py +37 -0
  3. flode/analysis/frequency_response.py +349 -0
  4. flode/analysis/linearize.py +714 -0
  5. flode/analysis/stability.py +229 -0
  6. flode/blocks/__init__.py +107 -0
  7. flode/blocks/_lti_utils.py +121 -0
  8. flode/blocks/cast.py +66 -0
  9. flode/blocks/continuous.py +474 -0
  10. flode/blocks/discontinuities.py +258 -0
  11. flode/blocks/discrete.py +612 -0
  12. flode/blocks/logic.py +120 -0
  13. flode/blocks/lookup.py +1025 -0
  14. flode/blocks/mathops.py +598 -0
  15. flode/blocks/pythonfunc.py +560 -0
  16. flode/blocks/pythonfunc_rewrite.py +1935 -0
  17. flode/blocks/pythonfunc_source.py +392 -0
  18. flode/blocks/random_source.py +179 -0
  19. flode/blocks/rounding.py +65 -0
  20. flode/blocks/routing.py +582 -0
  21. flode/blocks/sinks.py +309 -0
  22. flode/blocks/sources.py +215 -0
  23. flode/blocks/transport_delay.py +92 -0
  24. flode/blocks/userfunc.py +359 -0
  25. flode/core/__init__.py +5 -0
  26. flode/core/block.py +497 -0
  27. flode/core/decorator.py +1149 -0
  28. flode/core/dtypes.py +1026 -0
  29. flode/core/identifiers.py +157 -0
  30. flode/core/persistence.py +1319 -0
  31. flode/core/simulator.py +1690 -0
  32. flode/exceptions.py +226 -0
  33. flode/libraries/__init__.py +341 -0
  34. flode/libraries/_loader.py +302 -0
  35. flode/libraries/std.flwlib.json +347 -0
  36. flode/server/__init__.py +23 -0
  37. flode/server/app.py +185 -0
  38. flode/server/cli.py +555 -0
  39. flode/server/config.py +516 -0
  40. flode/server/errors.py +397 -0
  41. flode/server/library_registry.py +125 -0
  42. flode/server/migrations/__init__.py +80 -0
  43. flode/server/registry.py +950 -0
  44. flode/server/registry_translations.py +804 -0
  45. flode/server/routes/__init__.py +16 -0
  46. flode/server/routes/blocks.py +447 -0
  47. flode/server/routes/files.py +800 -0
  48. flode/server/routes/libraries.py +129 -0
  49. flode/server/routes/models.py +67 -0
  50. flode/server/routes/simulations.py +355 -0
  51. flode/server/runtime.py +354 -0
  52. flode/server/security/__init__.py +7 -0
  53. flode/server/security/origin.py +122 -0
  54. flode/server/security/paths.py +143 -0
  55. flode/server/settings.py +42 -0
  56. flode/server/static/.app-version +1 -0
  57. flode/server/static/assets/index-BnL2nNOe.css +1 -0
  58. flode/server/static/assets/index-DMWZBAA_.js +100 -0
  59. flode/server/static/assets/index-DMWZBAA_.js.map +1 -0
  60. flode/server/static/favicon.ico +0 -0
  61. flode/server/static/favicon.svg +9 -0
  62. flode/server/static/index.html +15 -0
  63. flode/subsystems/__init__.py +20 -0
  64. flode/subsystems/_mask.py +154 -0
  65. flode/subsystems/control_blocks.py +245 -0
  66. flode/subsystems/ports.py +146 -0
  67. flode/subsystems/subsystem.py +1173 -0
  68. flode-0.60.3.dist-info/METADATA +91 -0
  69. flode-0.60.3.dist-info/RECORD +73 -0
  70. flode-0.60.3.dist-info/WHEEL +5 -0
  71. flode-0.60.3.dist-info/entry_points.txt +2 -0
  72. flode-0.60.3.dist-info/licenses/LICENSE +21 -0
  73. flode-0.60.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,129 @@
1
+ """Block library エンドポイント (ADR-0029)。
2
+
3
+ `/api/v1/libraries` 系は ``.flwlib.json`` で配布されるマスク Subsystem 集合を提供する。
4
+ ``/api/v1/blocks`` (= Block class registry) と責務分離する: blocks は **コード上の**
5
+ Block class 定義、libraries は **ファイル上の** Subsystem テンプレート。
6
+
7
+ エンドポイント:
8
+
9
+ * ``GET /api/v1/libraries`` — 全 library のメタデータ (entry の ``subsystem`` body は
10
+ 含めない; サイズが大きいため)
11
+ * ``GET /api/v1/libraries/{lib}/{entry}`` — 1 entry の subsystem body を含めた詳細
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ from fastapi import APIRouter, HTTPException, Request
19
+
20
+ from ..library_registry import LibraryRegistry, build_library_registry
21
+
22
+ router = APIRouter(prefix="/libraries", tags=["libraries"])
23
+
24
+
25
+ def _registry(request: Request) -> LibraryRegistry:
26
+ """app.state から library registry を取り出す型安全 helper (lifespan 未起動時 lazy build)。
27
+
28
+ .. note::
29
+
30
+ 本関数の lazy build 経路 (``app.state.library_registry is None``) は
31
+ **テスト専用**。本番 lifespan (``create_app`` 内) では必ず
32
+ ``build_library_registry`` を起動時に呼ぶため、このパスに到達したら
33
+ 設定の不整合 (= バグ) を示す。
34
+ """
35
+ reg = getattr(request.app.state, "library_registry", None)
36
+ if reg is None:
37
+ # テスト fixture 等で lifespan が起動していない場合の lazy build
38
+ settings = getattr(request.app.state, "settings", None)
39
+ library_paths = list(getattr(settings, "library_paths", []) or [])
40
+ bundle_builtin = bool(getattr(settings, "bundle_builtin_libraries", True))
41
+ reg = build_library_registry(library_paths, bundle_builtin=bundle_builtin)
42
+ request.app.state.library_registry = reg
43
+ if not isinstance(reg, LibraryRegistry):
44
+ raise HTTPException(status_code=500, detail="library_registry state is corrupted")
45
+ return reg
46
+
47
+
48
+ def _entry_metadata_dict(entry: Any) -> dict[str, Any]:
49
+ """:class:`LibraryEntry` を REST response 用 dict に変換 (subsystem body 抜き)。"""
50
+ return {
51
+ "id": entry.id,
52
+ "display_name": entry.display_name,
53
+ "display_name_i18n": dict(entry.display_name_i18n),
54
+ "description": entry.description,
55
+ "description_i18n": dict(entry.description_i18n),
56
+ "category_suffix": entry.category_suffix,
57
+ }
58
+
59
+
60
+ def _library_metadata_dict(lib: Any) -> dict[str, Any]:
61
+ """:class:`Library` を REST response 用 dict に変換 (entries は metadata だけ)。"""
62
+ return {
63
+ "name": lib.name,
64
+ "display_name": lib.display_name,
65
+ "display_name_i18n": dict(lib.display_name_i18n),
66
+ "description": lib.description,
67
+ "description_i18n": dict(lib.description_i18n),
68
+ "version": lib.version,
69
+ "entries": [_entry_metadata_dict(e) for e in lib.entries],
70
+ }
71
+
72
+
73
+ @router.get("")
74
+ def list_libraries(request: Request) -> dict[str, Any]:
75
+ """全 library のメタデータを返す (entry の subsystem body は含めない、ADR-0029)。
76
+
77
+ Body サイズ削減のため、各 entry の重い ``subsystem`` (内部 blocks/connections) は
78
+ ``GET /api/v1/libraries/{lib}/{entry}`` で個別 fetch する設計。
79
+
80
+ Returns:
81
+ ``{"libraries": [...], "load_errors": [...], "schema_version": "libraries.v1",
82
+ "supported_locales": ["en", "ja"]}``。
83
+ """
84
+ # 遅延 import で循環回避 (registry_translations は SUPPORTED_LOCALES のみ使う)
85
+ from ..registry_translations import SUPPORTED_LOCALES
86
+
87
+ reg = _registry(request)
88
+ return {
89
+ "libraries": [_library_metadata_dict(lib) for lib in reg.libraries.values()],
90
+ "load_errors": [{"path": e.path, "message": e.message} for e in reg.load_errors],
91
+ "schema_version": "libraries.v1",
92
+ "supported_locales": list(SUPPORTED_LOCALES),
93
+ }
94
+
95
+
96
+ @router.get("/{library_name}/{entry_id}")
97
+ def get_library_entry(request: Request, library_name: str, entry_id: str) -> dict[str, Any]:
98
+ """1 entry の subsystem body を含めた詳細を返す (ADR-0029)。
99
+
100
+ Inline 配置時の drop 経路で frontend が呼ぶ。subsystem body は
101
+ ``Subsystem.to_dict()`` の出力と byte-identical (= REST 経由でも byte-identical を維持)。
102
+
103
+ Returns:
104
+ ``{"id", "display_name", ..., "subsystem": {"id", "type", "params"}}``。
105
+
106
+ Raises:
107
+ HTTPException: 404 — library / entry が registry に存在しない場合。
108
+ """
109
+ reg = _registry(request)
110
+ lib = reg.libraries.get(library_name)
111
+ if lib is None:
112
+ raise HTTPException(
113
+ status_code=404,
114
+ detail=f"Library {library_name!r} not found in registry",
115
+ )
116
+ entry = lib.get_entry(entry_id)
117
+ if entry is None:
118
+ raise HTTPException(
119
+ status_code=404,
120
+ detail=(
121
+ f"Entry {entry_id!r} not found in library {library_name!r} "
122
+ f"(available: {[e.id for e in lib.entries]})"
123
+ ),
124
+ )
125
+ return {
126
+ **_entry_metadata_dict(entry),
127
+ # ``subsystem`` は ``Subsystem.to_dict()`` の出力 (= byte-identical)
128
+ "subsystem": entry.subsystem,
129
+ }
@@ -0,0 +1,67 @@
1
+ """モデル静的解析ルート (SPEC-0027: SM-D Stage 0、v0.54.0 新設)。
2
+
3
+ ``POST /api/v1/models/resolve-dtypes`` — 影の型解決 (shadow dtype propagation)。
4
+
5
+ セキュリティ上の要点 (security-reviewer 向け):
6
+
7
+ - **静的解析のみでユーザーコード (PythonFunction) を一切実行しない**。
8
+ エンジン (``flode.core.dtypes``) は PythonFunction を含むモデルでは
9
+ ``_build()`` を呼ばない static mode に縮退する (二経路設計)。
10
+ そのため ``/simulations`` の python_ack (409) ゲートは不要
11
+ - モデルの構築・path 検証は既存 ``_resolve_simulator`` を再利用
12
+ (``resolve_workspace_path`` による path traversal 防御込み、400/403/404)
13
+ - 型解決自体の失敗は **HTTP 200 + diagnostics** (Stage 0 は「解析できなかった」
14
+ ことも測定対象のため、SPEC-0027 Q4)
15
+
16
+ Note: v0.21.0 で削除された legacy models router とは無関係の新設。
17
+ """
18
+
19
+ from typing import Any
20
+
21
+ from fastapi import APIRouter, HTTPException, Request
22
+ from starlette.concurrency import run_in_threadpool
23
+
24
+ from .simulations import _resolve_simulator, _start_validation_detail
25
+
26
+ router = APIRouter(prefix="/models", tags=["models"])
27
+
28
+
29
+ @router.get("/resolve-dtypes")
30
+ async def resolve_dtypes_get_stub() -> dict[str, Any]:
31
+ """GET は不可 (blocks router の 405 stub と同じ流儀)。"""
32
+ raise HTTPException(
33
+ status_code=405,
34
+ detail="Use POST /api/v1/models/resolve-dtypes with a JSON body "
35
+ "({'model': {...}} or {'model_path': '...'}).",
36
+ )
37
+
38
+
39
+ @router.post("/resolve-dtypes")
40
+ async def resolve_dtypes_endpoint(request: Request) -> dict[str, Any]:
41
+ """影の型解決を行い ``dtypes.v1`` payload を返す (SPEC-0027 §5.2)。
42
+
43
+ Request body は ``model_path`` または ``model`` のうち正確に 1 つを含む
44
+ JSON object (``POST /simulations`` と同じ 2 形式)。実行は一切行わず、
45
+ シミュレーション結果にも保存ファイルにも影響しない (Stage 0 = 表示・測定のみ)。
46
+
47
+ モデル構築と型解決は threadpool で行い、大きいモデルでも event loop
48
+ (= 実行中シミュレーションの WS 配信) を止めない (security-reviewer SHOULD-2)。
49
+ """
50
+ try:
51
+ payload = await request.json()
52
+ except ValueError as e:
53
+ raise HTTPException(
54
+ status_code=400,
55
+ detail=_start_validation_detail("Body must be valid JSON"),
56
+ ) from e
57
+ if not isinstance(payload, dict):
58
+ raise HTTPException(
59
+ status_code=400,
60
+ detail=_start_validation_detail("Body must be a JSON object"),
61
+ )
62
+
63
+ def _resolve() -> dict[str, Any]:
64
+ simulator, _display_id = _resolve_simulator(request, payload)
65
+ return simulator.resolve_dtypes().to_payload()
66
+
67
+ return await run_in_threadpool(_resolve)
@@ -0,0 +1,355 @@
1
+ """シミュレーション制御 + WebSocket エンドポイント (ADR-0011 §(1)(2)、ADR-0041 §5)。
2
+
3
+ v0.21.0: legacy ``model_id`` 受付を削除。``model_path`` (= workspace 相対 path)
4
+ または ``model`` (= インライン dict) のいずれかを必須に。
5
+
6
+ ADR-0056: シミュレーション開始エラーは構造化 detail で返す
7
+ (``{detail: {category, template_key, ...}}``)。WS の ``error`` メッセージは廃止し、
8
+ ``failed`` 1 件に構造化フィールドをマージして送る (= runtime 側で実施)。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import dataclasses
15
+ import json
16
+ import logging
17
+ from typing import Any
18
+
19
+ from fastapi import APIRouter, HTTPException, Request, WebSocket, WebSocketDisconnect
20
+
21
+ from ...blocks.pythonfunc import (
22
+ compute_python_digest,
23
+ get_python_block_policy,
24
+ iter_python_functions,
25
+ )
26
+ from ...core.simulator import Simulator
27
+ from ...exceptions import (
28
+ FlodeError,
29
+ ModelLoadError,
30
+ PathTraversalError,
31
+ PythonBlocksDisabledError,
32
+ SimulationStillRunningError,
33
+ )
34
+ from ..errors import CATEGORY_PYTHON_FUNCTION_UNCONFIRMED, build_failure_payload
35
+ from ..runtime import SimulationManager
36
+ from ..security import resolve_workspace_path
37
+
38
+ _logger = logging.getLogger("flode.server.routes.simulations")
39
+
40
+ router = APIRouter(prefix="/simulations", tags=["simulations"])
41
+
42
+ _INLINE_DISPLAY_ID = "<inline>"
43
+ """インライン model 経由のシミュレーションの ``model_id`` 表示値 (ADR-0041 §5)。
44
+
45
+ 複数のインライン実行が共存しても ``simulation_id`` で区別できるため、
46
+ ``model_id`` 表示は固定値で OK。frontend は ``"<inline>"`` をトラッキング
47
+ タブで「未保存」表示等に使う想定。
48
+ """
49
+
50
+
51
+ def _manager(request: Request) -> SimulationManager:
52
+ return request.app.state.simulation_manager # type: ignore[no-any-return]
53
+
54
+
55
+ def _start_validation_detail(message: str) -> dict[str, Any]:
56
+ """ADR-0056 §B-2: start API のシンプルな validation error 用の構造化 detail。
57
+
58
+ 例外が無いケース (= body の形式違反等) で使う。``ModelLoadError`` や
59
+ ``BlockSpecError`` 等の例外がある場合は ``build_failure_payload`` を直接使う。
60
+ """
61
+ return {
62
+ "category": "start_validation",
63
+ "template_key": "error.start_validation",
64
+ "template_args": {"message": message},
65
+ "block_id": None,
66
+ "block_ids": [],
67
+ "block_type": None,
68
+ "block_label": None,
69
+ "t": None,
70
+ "raw_message": message,
71
+ "raw_traceback": None,
72
+ }
73
+
74
+
75
+ def _exception_detail(exc: BaseException) -> dict[str, Any]:
76
+ """例外から start API 用の構造化 detail を組み立てる (ADR-0056 §B-2)。"""
77
+ return build_failure_payload(exc, simulator=None, t=None)
78
+
79
+
80
+ def _python_unconfirmed_detail(simulator: Simulator, digest: str) -> dict[str, Any]:
81
+ """SPEC-0023 soft gate (ADR-0073 §論点 4 (S)): 409 用の構造化 detail。
82
+
83
+ ``template_args.digest`` をクライアントが echo して再送する。この機構は
84
+ 「このモデルは Python を実行する」の告知と同意のための **UX 機構** であり、
85
+ セキュリティ機構ではない (REST を直接叩けば ack を自分で付けられる)。
86
+ """
87
+ entries = list(iter_python_functions(simulator.blocks))
88
+ labels = [qid for qid, _ in entries]
89
+ ids = [pf.id for _, pf in entries if isinstance(pf.id, str)]
90
+ return {
91
+ "category": CATEGORY_PYTHON_FUNCTION_UNCONFIRMED,
92
+ "template_key": "error.python_function_unconfirmed",
93
+ "template_args": {"block_labels": labels, "digest": digest},
94
+ "block_id": ids[0] if ids else None,
95
+ "block_ids": ids,
96
+ "block_type": "flode.blocks.pythonfunc.PythonFunction",
97
+ "block_label": labels[0] if labels else None,
98
+ "t": None,
99
+ "raw_message": (
100
+ "This model contains PythonFunction blocks. Running it executes their Python "
101
+ "code with your privileges. Re-send with python_ack=<digest> to confirm."
102
+ ),
103
+ "raw_traceback": None,
104
+ }
105
+
106
+
107
+ def _enforce_python_gates(simulator: Simulator, payload: dict[str, Any]) -> None:
108
+ """``PythonFunction`` を含むモデルの start 前チェック (SPEC-0023 / ADR-0073 §論点 4)。
109
+
110
+ * hard gate: プロセス policy が禁止なら **403** (構造化 detail
111
+ ``python_function_disabled``)。``_build()`` でも同じ判定が走るので、ここでの
112
+ 早期拒否は「ワーカーを起動せずに即答する」ための重複であり bypass 点ではない
113
+ * soft gate: ``python_ack`` が digest と一致しなければ **409**
114
+
115
+ ``PythonFunction`` を含まないモデルでは何もしない (= ``python_ack`` は無視、
116
+ 既存クライアント完全互換)。
117
+ """
118
+ digest = compute_python_digest(simulator)
119
+ if digest is None:
120
+ return
121
+ policy = get_python_block_policy()
122
+ if not policy.allowed:
123
+ first = next(iter_python_functions(simulator.blocks), None)
124
+ block_id = first[1].id if first is not None else None
125
+ reason = f" ({policy.reason})" if policy.reason else ""
126
+ exc = PythonBlocksDisabledError(
127
+ f"PythonFunction: execution of Python blocks is disabled in this server{reason}. "
128
+ f"Start the server with --allow-python-blocks or set [server] "
129
+ f"allow_python_blocks = true to enable it.",
130
+ block_id=block_id,
131
+ )
132
+ raise HTTPException(status_code=403, detail=_exception_detail(exc))
133
+ ack = payload.get("python_ack")
134
+ if not isinstance(ack, str) or ack != digest:
135
+ raise HTTPException(status_code=409, detail=_python_unconfirmed_detail(simulator, digest))
136
+
137
+
138
+ def _scope_batch_size(request: Request) -> int:
139
+ return int(request.app.state.settings.scope_batch_size)
140
+
141
+
142
+ def _resolve_simulator(request: Request, payload: dict[str, Any]) -> tuple[Simulator, str]:
143
+ """request body から ``Simulator`` を構築する (ADR-0041 §論点 5-A)。
144
+
145
+ v0.21.0: 2 形式のみ。相互排他で 1 つだけ指定:
146
+
147
+ * ``{"model_path": str}`` — workspace 相対 path。``resolve_workspace_path``
148
+ で path traversal 防御を通してから ``Simulator.load``。
149
+ * ``{"model": dict}`` — インライン (= 未保存 editingModel の試行実行)。
150
+ ``Simulator.from_dict`` で構築、fs アクセスなし。
151
+
152
+ Returns:
153
+ ``(simulator, display_id)``。``display_id`` はトラッキング表示用 (=
154
+ path では path 文字列、inline では ``"<inline>"``)。
155
+ """
156
+ # `key in payload` ベース検出 (= falsy 値も「指定あり」として拾う)
157
+ specified_keys = [k for k in ("model_path", "model") if k in payload]
158
+ model_path = payload.get("model_path")
159
+ model_inline = payload.get("model")
160
+ if len(specified_keys) == 0:
161
+ raise HTTPException(
162
+ status_code=400,
163
+ detail=_start_validation_detail(
164
+ "Body must specify exactly one of: 'model_path' "
165
+ "(workspace-relative) or 'model' (inline)."
166
+ ),
167
+ )
168
+ if len(specified_keys) > 1:
169
+ raise HTTPException(
170
+ status_code=400,
171
+ detail=_start_validation_detail(
172
+ "Body must specify exactly one of 'model_path' / 'model' "
173
+ f"(mutually exclusive). Got: {specified_keys}."
174
+ ),
175
+ )
176
+
177
+ if model_path is not None:
178
+ if not isinstance(model_path, str):
179
+ raise HTTPException(
180
+ status_code=400,
181
+ detail=_start_validation_detail("'model_path' must be a string"),
182
+ )
183
+ settings = request.app.state.settings
184
+ workspace_root = settings.workspace_root
185
+ try:
186
+ resolved = resolve_workspace_path(workspace_root, model_path)
187
+ except PathTraversalError as e:
188
+ # _resolve_simulator は models route とも共有のため、監査ログには
189
+ # 実際の endpoint パスを記録する (security-reviewer SHOULD-3)
190
+ _logger.warning("Path traversal rejected in %s: %s", request.url.path, e)
191
+ raise HTTPException(
192
+ status_code=403,
193
+ detail=_start_validation_detail(
194
+ "Path traversal rejected (see server log for details)."
195
+ ),
196
+ ) from e
197
+ if not resolved.exists():
198
+ raise HTTPException(
199
+ status_code=404,
200
+ detail=_start_validation_detail(f"Model file not found: {model_path}"),
201
+ )
202
+ if resolved.is_dir():
203
+ raise HTTPException(
204
+ status_code=400,
205
+ detail=_start_validation_detail(
206
+ f"'model_path' is a directory, not a file: {model_path}"
207
+ ),
208
+ )
209
+ try:
210
+ simulator = Simulator.load(resolved)
211
+ except ModelLoadError as e:
212
+ raise HTTPException(status_code=400, detail=_exception_detail(e)) from e
213
+ return simulator, model_path
214
+
215
+ # model_inline (= dict)
216
+ if not isinstance(model_inline, dict):
217
+ raise HTTPException(
218
+ status_code=400,
219
+ detail=_start_validation_detail("'model' must be a JSON object"),
220
+ )
221
+ try:
222
+ simulator = Simulator.from_dict(model_inline)
223
+ except ModelLoadError as e:
224
+ raise HTTPException(status_code=400, detail=_exception_detail(e)) from e
225
+ return simulator, _INLINE_DISPLAY_ID
226
+
227
+
228
+ @router.post("")
229
+ async def start_simulation(request: Request) -> dict[str, str]:
230
+ """シミュレーションを開始する (ADR-0041 §論点 5-A、2 形式対応)。
231
+
232
+ Request body は ``model_path`` または ``model`` のうち **正確に 1 つ** を
233
+ 含む JSON object。詳細は ``_resolve_simulator`` docstring 参照。
234
+ """
235
+ payload = await request.json()
236
+ if not isinstance(payload, dict):
237
+ raise HTTPException(
238
+ status_code=400,
239
+ detail=_start_validation_detail("Body must be a JSON object"),
240
+ )
241
+
242
+ simulator, display_id = _resolve_simulator(request, payload)
243
+ _enforce_python_gates(simulator, payload)
244
+
245
+ manager = _manager(request)
246
+ loop = asyncio.get_running_loop()
247
+ sim_id = manager.start(
248
+ model_id=display_id,
249
+ simulator=simulator,
250
+ loop=loop,
251
+ scope_batch_size=_scope_batch_size(request),
252
+ )
253
+ return {"simulation_id": sim_id, "model_id": display_id}
254
+
255
+
256
+ @router.get("")
257
+ def list_simulations(request: Request) -> dict[str, Any]:
258
+ manager = _manager(request)
259
+ return {"simulations": [dataclasses.asdict(s) for s in manager.list_simulations()]}
260
+
261
+
262
+ @router.get("/{sim_id}")
263
+ def get_simulation(request: Request, sim_id: str) -> dict[str, Any]:
264
+ manager = _manager(request)
265
+ try:
266
+ state = manager.get_state(sim_id)
267
+ except FlodeError as e:
268
+ raise HTTPException(status_code=404, detail=str(e)) from e
269
+ return dataclasses.asdict(state)
270
+
271
+
272
+ @router.post("/{sim_id}/stop")
273
+ def stop_simulation(request: Request, sim_id: str) -> dict[str, str]:
274
+ manager = _manager(request)
275
+ try:
276
+ manager.stop(sim_id)
277
+ except FlodeError as e:
278
+ raise HTTPException(status_code=404, detail=str(e)) from e
279
+ return {"simulation_id": sim_id, "status": "stop_requested"}
280
+
281
+
282
+ @router.get("/{sim_id}/results")
283
+ def get_results(request: Request, sim_id: str) -> dict[str, Any]:
284
+ manager = _manager(request)
285
+ try:
286
+ return manager.get_results(sim_id)
287
+ except SimulationStillRunningError as e:
288
+ raise HTTPException(status_code=409, detail=str(e)) from e
289
+ except FlodeError as e:
290
+ raise HTTPException(status_code=404, detail=str(e)) from e
291
+
292
+
293
+ @router.websocket("/{sim_id}/stream")
294
+ async def stream(websocket: WebSocket, sim_id: str) -> None:
295
+ """シミュレーション進捗 + Scope データを WebSocket で配信。
296
+
297
+ クライアントは接続後、サーバから JSON メッセージを順次受信する:
298
+ ``progress`` / ``scope_batch`` / ``completed`` / ``stopped`` / ``failed``。
299
+
300
+ ADR-0056: 旧 ``error`` メッセージは廃止 (= 失敗時の構造化フィールドは
301
+ ``failed`` メッセージにマージされる)。
302
+
303
+ クライアントが ``{"type": "stop"}`` を送るとサーバ側で
304
+ ``SimulationManager.stop`` を呼ぶ。
305
+
306
+ Note:
307
+ WS は live 接続向け。途中参加した場合は接続前の ``progress`` /
308
+ ``scope_batch`` 取り損ない、内部 ``asyncio.Queue`` (max 1024) が満杯時は
309
+ 古いメッセージから drop される (ADR-0011 §(2) / Risk #2)。完全な結果は
310
+ ``GET /api/v1/simulations/{id}/results`` で取得すること。
311
+ """
312
+ await websocket.accept()
313
+ manager: SimulationManager = websocket.app.state.simulation_manager
314
+ try:
315
+ # クライアント受信タスク (停止コマンド用) を並走
316
+ async def _receiver() -> None:
317
+ try:
318
+ while True:
319
+ text = await websocket.receive_text()
320
+ try:
321
+ data = json.loads(text)
322
+ except json.JSONDecodeError:
323
+ continue
324
+ if isinstance(data, dict) and data.get("type") == "stop":
325
+ try:
326
+ manager.stop(sim_id)
327
+ except FlodeError:
328
+ pass
329
+ except WebSocketDisconnect:
330
+ return
331
+
332
+ recv_task = asyncio.create_task(_receiver())
333
+ try:
334
+ async for msg in manager.stream(sim_id):
335
+ await websocket.send_json(msg)
336
+ finally:
337
+ recv_task.cancel()
338
+ except FlodeError as e:
339
+ # ADR-0056: 旧 ``error`` メッセージは廃止。WS 確立中のドメイン例外
340
+ # (例: 未知の simulation_id) は構造化 ``failed`` で 1 件返してから閉じる。
341
+ terminal = {"type": "failed", "duration_sec": 0.0}
342
+ terminal.update(build_failure_payload(e, simulator=None, t=None))
343
+ try:
344
+ await websocket.send_json(terminal)
345
+ except RuntimeError:
346
+ pass
347
+ except WebSocketDisconnect:
348
+ pass
349
+ finally:
350
+ # WebSocket がまだ閉じていなければ閉じる (クライアント側の disconnect で
351
+ # すでに閉じている可能性もある)
352
+ try:
353
+ await websocket.close()
354
+ except RuntimeError:
355
+ pass