rocket-engine 0.0.2b1__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 (241) hide show
  1. rocket_engine/__init__.py +8 -0
  2. rocket_engine/__main__.py +12 -0
  3. rocket_engine/adapters/README.md +54 -0
  4. rocket_engine/adapters/__init__.py +7 -0
  5. rocket_engine/adapters/api.py +39 -0
  6. rocket_engine/adapters/base.py +46 -0
  7. rocket_engine/adapters/cace/__init__.py +163 -0
  8. rocket_engine/adapters/circulax/README.md +10 -0
  9. rocket_engine/adapters/circulax/__init__.py +112 -0
  10. rocket_engine/adapters/circulax/_driver.py +190 -0
  11. rocket_engine/adapters/circulax/adapter.py +538 -0
  12. rocket_engine/adapters/circulax/results.py +291 -0
  13. rocket_engine/adapters/cli/README.md +34 -0
  14. rocket_engine/adapters/cli/__init__.py +305 -0
  15. rocket_engine/adapters/connectivity/__init__.py +196 -0
  16. rocket_engine/adapters/connectivity/results.py +214 -0
  17. rocket_engine/adapters/cosim/README.md +9 -0
  18. rocket_engine/adapters/cosim/__init__.py +1390 -0
  19. rocket_engine/adapters/cosim/results.py +214 -0
  20. rocket_engine/adapters/devsim/__init__.py +171 -0
  21. rocket_engine/adapters/digital_flow/README.md +9 -0
  22. rocket_engine/adapters/digital_flow/__init__.py +1121 -0
  23. rocket_engine/adapters/digital_flow/results.py +227 -0
  24. rocket_engine/adapters/digital_flow/state.py +462 -0
  25. rocket_engine/adapters/drc/README.klayout.md +7 -0
  26. rocket_engine/adapters/drc/README.md +7 -0
  27. rocket_engine/adapters/drc/__init__.py +1 -0
  28. rocket_engine/adapters/drc/klayout.py +952 -0
  29. rocket_engine/adapters/drc/magic.py +287 -0
  30. rocket_engine/adapters/drc/results.py +184 -0
  31. rocket_engine/adapters/drc/results_kl.py +188 -0
  32. rocket_engine/adapters/elmer/__init__.py +163 -0
  33. rocket_engine/adapters/envelope.py +120 -0
  34. rocket_engine/adapters/equivalence/__init__.py +156 -0
  35. rocket_engine/adapters/errors.py +181 -0
  36. rocket_engine/adapters/extract_magic/README.md +15 -0
  37. rocket_engine/adapters/extract_magic/__init__.py +425 -0
  38. rocket_engine/adapters/extract_magic/results.py +209 -0
  39. rocket_engine/adapters/formal_sby/README.md +7 -0
  40. rocket_engine/adapters/formal_sby/__init__.py +773 -0
  41. rocket_engine/adapters/formal_sby/formal_edge.py +195 -0
  42. rocket_engine/adapters/formal_sby/results.py +185 -0
  43. rocket_engine/adapters/gdsfactory/__init__.py +194 -0
  44. rocket_engine/adapters/gdsfactory/_driver.py +459 -0
  45. rocket_engine/adapters/gdsfactory/adapter.py +609 -0
  46. rocket_engine/adapters/gdsfactory/results.py +181 -0
  47. rocket_engine/adapters/hdl_sim/README.md +7 -0
  48. rocket_engine/adapters/hdl_sim/__init__.py +369 -0
  49. rocket_engine/adapters/hdl_sim/ghdl.py +155 -0
  50. rocket_engine/adapters/hdl_sim/results.py +178 -0
  51. rocket_engine/adapters/hdl_sim/verilator.py +167 -0
  52. rocket_engine/adapters/http/README.md +15 -0
  53. rocket_engine/adapters/http/__init__.py +458 -0
  54. rocket_engine/adapters/http/events.py +63 -0
  55. rocket_engine/adapters/ipc/README.md +9 -0
  56. rocket_engine/adapters/ipc/__init__.py +111 -0
  57. rocket_engine/adapters/lvs_netgen/README.md +20 -0
  58. rocket_engine/adapters/lvs_netgen/__init__.py +889 -0
  59. rocket_engine/adapters/lvs_netgen/results.py +168 -0
  60. rocket_engine/adapters/mcp/README.md +19 -0
  61. rocket_engine/adapters/mcp/__init__.py +85 -0
  62. rocket_engine/adapters/mcp/api-schema.json +6107 -0
  63. rocket_engine/adapters/mcp/pull.py +246 -0
  64. rocket_engine/adapters/mcp/server.py +794 -0
  65. rocket_engine/adapters/meep/__init__.py +167 -0
  66. rocket_engine/adapters/meow/README.md +7 -0
  67. rocket_engine/adapters/meow/__init__.py +190 -0
  68. rocket_engine/adapters/meow/_driver.py +227 -0
  69. rocket_engine/adapters/meow/_envelope.py +108 -0
  70. rocket_engine/adapters/meow/adapter.py +491 -0
  71. rocket_engine/adapters/meow/results.py +218 -0
  72. rocket_engine/adapters/mutation/__init__.py +192 -0
  73. rocket_engine/adapters/ngspice/README.md +28 -0
  74. rocket_engine/adapters/ngspice/__init__.py +627 -0
  75. rocket_engine/adapters/ngspice/results.py +215 -0
  76. rocket_engine/adapters/postlayout/README.md +28 -0
  77. rocket_engine/adapters/postlayout/__init__.py +17 -0
  78. rocket_engine/adapters/postlayout/adapter.py +1062 -0
  79. rocket_engine/adapters/postlayout/results.py +330 -0
  80. rocket_engine/adapters/postlayout/translate.py +71 -0
  81. rocket_engine/adapters/profile.py +220 -0
  82. rocket_engine/adapters/sax/README.md +23 -0
  83. rocket_engine/adapters/sax/__init__.py +516 -0
  84. rocket_engine/adapters/sax/_driver.py +148 -0
  85. rocket_engine/adapters/sax/results.py +291 -0
  86. rocket_engine/adapters/stdio_console.py +1151 -0
  87. rocket_engine/adapters/syntax/README.md +5 -0
  88. rocket_engine/adapters/syntax/__init__.py +366 -0
  89. rocket_engine/adapters/syntax/results.py +181 -0
  90. rocket_engine/adapters/synth_yosys/README.md +7 -0
  91. rocket_engine/adapters/synth_yosys/__init__.py +655 -0
  92. rocket_engine/adapters/synth_yosys/amaranth.py +120 -0
  93. rocket_engine/adapters/synth_yosys/ghdl.py +128 -0
  94. rocket_engine/adapters/synth_yosys/results.py +173 -0
  95. rocket_engine/adapters/timing_sta/README.md +9 -0
  96. rocket_engine/adapters/timing_sta/__init__.py +726 -0
  97. rocket_engine/adapters/view_sync/__init__.py +185 -0
  98. rocket_engine/adapters/view_sync/results.py +189 -0
  99. rocket_engine/adapters/vlsir/__init__.py +142 -0
  100. rocket_engine/adapters/xschem/__init__.py +440 -0
  101. rocket_engine/adapters/xschem/deck.py +266 -0
  102. rocket_engine/clients/__init__.py +5 -0
  103. rocket_engine/clients/runtime.py +255 -0
  104. rocket_engine/composition.py +2279 -0
  105. rocket_engine/execution/README.md +30 -0
  106. rocket_engine/execution/__init__.py +150 -0
  107. rocket_engine/execution/apig/__init__.py +1 -0
  108. rocket_engine/execution/apig/pool.py +463 -0
  109. rocket_engine/execution/apig/submit.py +124 -0
  110. rocket_engine/execution/block_runs_scope.py +479 -0
  111. rocket_engine/execution/coalesce.py +414 -0
  112. rocket_engine/execution/console/__init__.py +1 -0
  113. rocket_engine/execution/console/apply_session.py +318 -0
  114. rocket_engine/execution/console/lifecycle.py +265 -0
  115. rocket_engine/execution/dispatch.py +205 -0
  116. rocket_engine/execution/errors.py +31 -0
  117. rocket_engine/execution/ports.py +797 -0
  118. rocket_engine/execution/pyexec/__init__.py +1 -0
  119. rocket_engine/execution/pyexec/apply_context.py +547 -0
  120. rocket_engine/execution/pyexec/approvals.py +173 -0
  121. rocket_engine/execution/pyexec/runner.py +404 -0
  122. rocket_engine/execution/pyexec/support.py +464 -0
  123. rocket_engine/execution/python_exec.py +897 -0
  124. rocket_engine/execution/queue.py +85 -0
  125. rocket_engine/execution/replay_safe.py +601 -0
  126. rocket_engine/execution/routing/__init__.py +1 -0
  127. rocket_engine/execution/routing/calls.py +364 -0
  128. rocket_engine/execution/routing/namespace.py +785 -0
  129. rocket_engine/execution/routing/ports.py +102 -0
  130. rocket_engine/execution/routing/tools.py +346 -0
  131. rocket_engine/execution/runner.py +311 -0
  132. rocket_engine/execution/runtime.py +1149 -0
  133. rocket_engine/execution/service.py +279 -0
  134. rocket_engine/execution/worker.py +464 -0
  135. rocket_engine/interfaces/README.md +26 -0
  136. rocket_engine/interfaces/__init__.py +58 -0
  137. rocket_engine/interfaces/apig_assets.py +407 -0
  138. rocket_engine/interfaces/celllink.py +461 -0
  139. rocket_engine/interfaces/documents.py +92 -0
  140. rocket_engine/interfaces/envelopes.py +131 -0
  141. rocket_engine/interfaces/errors.py +16 -0
  142. rocket_engine/interfaces/layout.py +410 -0
  143. rocket_engine/interfaces/layout_schema.yaml +278 -0
  144. rocket_engine/interfaces/paths.py +223 -0
  145. rocket_engine/interfaces/pdks.py +550 -0
  146. rocket_engine/interfaces/ports.py +72 -0
  147. rocket_engine/interfaces/references.py +115 -0
  148. rocket_engine/interfaces/refs.py +85 -0
  149. rocket_engine/interfaces/runparams.py +304 -0
  150. rocket_engine/interfaces/setup.py +552 -0
  151. rocket_engine/interfaces/setup_catalogue.yaml +243 -0
  152. rocket_engine/interfaces/setup_probes.py +66 -0
  153. rocket_engine/interfaces/vocabulary.py +58 -0
  154. rocket_engine/interfaces/yaml_dump.py +31 -0
  155. rocket_engine/library/README.md +22 -0
  156. rocket_engine/library/__init__.py +13 -0
  157. rocket_engine/library/apig_catalog.py +371 -0
  158. rocket_engine/library/catalog.py +185 -0
  159. rocket_engine/library/errors.py +16 -0
  160. rocket_engine/mutate/__init__.py +776 -0
  161. rocket_engine/mutate/operators.py +700 -0
  162. rocket_engine/ops/__init__.py +1 -0
  163. rocket_engine/ops/deferred.py +279 -0
  164. rocket_engine/ops/editors.py +404 -0
  165. rocket_engine/ops/evidence.py +194 -0
  166. rocket_engine/ops/gates.py +262 -0
  167. rocket_engine/ops/ports.py +76 -0
  168. rocket_engine/ops/queries.py +417 -0
  169. rocket_engine/ops/runs.py +553 -0
  170. rocket_engine/ops/submit.py +614 -0
  171. rocket_engine/project/README.md +27 -0
  172. rocket_engine/project/__init__.py +6 -0
  173. rocket_engine/project/canvas/__init__.py +55 -0
  174. rocket_engine/project/canvas/compile.py +109 -0
  175. rocket_engine/project/canvas/integrate.py +48 -0
  176. rocket_engine/project/canvas/schema.py +241 -0
  177. rocket_engine/project/canvas/templates.py +46 -0
  178. rocket_engine/project/canvas/validate.py +603 -0
  179. rocket_engine/project/edit/__init__.py +5 -0
  180. rocket_engine/project/edit/cas.py +87 -0
  181. rocket_engine/project/edit/transactions.py +282 -0
  182. rocket_engine/project/errors.py +16 -0
  183. rocket_engine/project/frontend13.py +685 -0
  184. rocket_engine/project/geometry/__init__.py +23 -0
  185. rocket_engine/project/geometry/ops.py +451 -0
  186. rocket_engine/project/geometry/snap.py +103 -0
  187. rocket_engine/project/ip/__init__.py +1 -0
  188. rocket_engine/project/ip/blocks.py +195 -0
  189. rocket_engine/project/ip/entry.schema.json +78 -0
  190. rocket_engine/project/ip/lifecycle.py +402 -0
  191. rocket_engine/project/ip/verbs.py +123 -0
  192. rocket_engine/project/layers/__init__.py +6 -0
  193. rocket_engine/project/layers/bindings.py +115 -0
  194. rocket_engine/project/layers/catalog.py +228 -0
  195. rocket_engine/project/layers/presets.py +43 -0
  196. rocket_engine/project/ledger/__init__.py +1 -0
  197. rocket_engine/project/ledger/docs.py +167 -0
  198. rocket_engine/project/ledger/entities.py +176 -0
  199. rocket_engine/project/ledger/events.py +29 -0
  200. rocket_engine/project/ledger/runs.py +257 -0
  201. rocket_engine/project/ledger/shared.py +174 -0
  202. rocket_engine/project/ledger/snapshots.py +385 -0
  203. rocket_engine/project/ledger/wires.py +552 -0
  204. rocket_engine/project/process/__init__.py +14 -0
  205. rocket_engine/project/process/model.py +193 -0
  206. rocket_engine/project/process/recipes.py +121 -0
  207. rocket_engine/project/schematic/__init__.py +1 -0
  208. rocket_engine/project/schematic/hierarchy.py +312 -0
  209. rocket_engine/project/schematic/instances.py +393 -0
  210. rocket_engine/project/schematic/sheets.py +198 -0
  211. rocket_engine/project/service.py +2941 -0
  212. rocket_engine/project/symbols/__init__.py +1 -0
  213. rocket_engine/project/symbols/cells.py +324 -0
  214. rocket_engine/project/symbols/linkage.py +356 -0
  215. rocket_engine/project/symbols/symbols.py +352 -0
  216. rocket_engine/project/templates/__init__.py +303 -0
  217. rocket_engine/project/templates/errors.py +25 -0
  218. rocket_engine/project/testbench/__init__.py +49 -0
  219. rocket_engine/project/testbench/blocks.py +619 -0
  220. rocket_engine/project/testbench/templates.py +134 -0
  221. rocket_engine/stores/README.md +12 -0
  222. rocket_engine/stores/__init__.py +6 -0
  223. rocket_engine/stores/ecs.py +1508 -0
  224. rocket_engine/stores/layers.py +1023 -0
  225. rocket_engine/stores/settings.py +1188 -0
  226. rocket_engine/stores/wireplace.py +1324 -0
  227. rocket_engine/workflows/README.md +18 -0
  228. rocket_engine/workflows/__init__.py +53 -0
  229. rocket_engine/workflows/capability_plan.py +102 -0
  230. rocket_engine/workflows/completion.py +228 -0
  231. rocket_engine/workflows/connectgraph.py +734 -0
  232. rocket_engine/workflows/connectgraph_emit.py +336 -0
  233. rocket_engine/workflows/gates.py +144 -0
  234. rocket_engine/workflows/photonic_wire.py +76 -0
  235. rocket_engine/workflows/stages.py +324 -0
  236. rocket_engine/workflows/viewsync.py +399 -0
  237. rocket_engine-0.0.2b1.dist-info/METADATA +200 -0
  238. rocket_engine-0.0.2b1.dist-info/RECORD +241 -0
  239. rocket_engine-0.0.2b1.dist-info/WHEEL +4 -0
  240. rocket_engine-0.0.2b1.dist-info/entry_points.txt +2 -0
  241. rocket_engine-0.0.2b1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,2279 @@
1
+ """Composition root (B9): the Engine wires core modules for adapters.
2
+
3
+ Adapters construct against this, never against services directly. The
4
+ root owns no domain logic: it routes cataloged names (see
5
+ `interfaces.vocabulary`) to live module state. Operations queue through
6
+ the project service with revision gating and envelope-id idempotency;
7
+ CRUD mutations (CRUD-API/I31) and rewind (REWIND) execute immediately
8
+ through the same admission machinery with no queue and no solver;
9
+ every accepted mutation advances the revision and appends an
10
+ actor-attributed event to the durable ledger (LEDGER-DURABLE/I30),
11
+ and every run appends its completion with a verdict link. Only wired
12
+ queries read state, the rest honestly report unwired.
13
+ Submit-immediate execution (API-GENERALIZE): `submit(op)` admits and
14
+ runs inline, returning the ack shape plus the verdict payload under
15
+ the one run id; `run_job` stays for explicit/deferred/batch pumping
16
+ plus idempotent result fetch — over `run()` directly or over
17
+ submit-only transports via `submit(run_job)`. Replays return the
18
+ stored completion without re-executing, and a fresh Engine reports
19
+ crash survivors once (boot report, never executed) on its first
20
+ mutating call; every replay surface stays gated behind an explicit
21
+ resume decision (REPLAY-SAFE).
22
+ With `constraints` (metric -> {min, max, unit[, expr, control]}),
23
+ run_spice targets come from the project, never the caller:
24
+ caller-supplied min/max — or a caller expr where the project defines
25
+ one — is refused.
26
+ `run()` executes registered solvers synchronously. Each operation
27
+ maps to named solvers; `deck.tool` selects one, defaulting to the
28
+ first registered (run_drc: magic, then klayout; run_optical: sax,
29
+ then circulax; run_em: meow; run_extraction: magic; run_lvs:
30
+ netgen; run_spice: ngspice;
31
+ run_post_layout: ngspice; run_syntax: slang; run_hdl_test: icarus;
32
+ run_synth: yosys; run_connectivity: connectgraph; run_erc: connectgraph;
33
+ run_formal: sby; run_view_sync: connectgraph;
34
+ generate_views: gdsfactory; run_cosim: cosim; run_timing: opensta;
35
+ run_digital_flow: librelane; run_xschem: xschem; run_antenna: klayout).
36
+ Unregistered operations and unknown deck tools raise instead of
37
+ faking a verdict.
38
+ """
39
+
40
+ import json
41
+ import uuid
42
+ from pathlib import Path
43
+
44
+ from .adapters.circulax.adapter import CirculaxAdapter
45
+ from .adapters.connectivity import ConnectivityAdapter
46
+ from .adapters.cosim import CosimAdapter
47
+ from .adapters.digital_flow import LibrelaneFlowAdapter
48
+ from .adapters.drc.magic import MagicDrcAdapter
49
+ from .adapters.drc.klayout import AntennaKlayoutAdapter, KlayoutDrcAdapter
50
+ from .adapters.extract_magic import MagicExtractAdapter
51
+ from .adapters.formal_sby import FormalSbyAdapter
52
+ from .adapters.gdsfactory.adapter import GdsfactoryViewsAdapter
53
+ from .adapters.hdl_sim import HdlSimAdapter
54
+ from .adapters.lvs_netgen import NetgenLvsAdapter
55
+ from .adapters.meow.adapter import MeowAdapter
56
+ from .adapters.ngspice import NgspiceAdapter
57
+ from .adapters.postlayout.adapter import PostLayoutAdapter
58
+ from .adapters.sax import SaxAdapter
59
+ from .adapters.syntax import SyntaxAdapter
60
+ from .adapters.synth_yosys import YosysSynthAdapter
61
+ from .adapters.timing_sta import OpenStaTimingAdapter
62
+ from .adapters.view_sync import ViewSyncAdapter
63
+ from .adapters.xschem import XschemAdapter
64
+ from .execution.console.lifecycle import ConsoleLifecycle
65
+ from .execution.apig.pool import (
66
+ DEFAULT_MAX_WORKERS,
67
+ ApigPool,
68
+ request_for_run,
69
+ stored_completion_from_events,
70
+ )
71
+
72
+ #: Batch drain fan-out for independent solver items (BATCH-PARALLEL).
73
+ DEFAULT_BATCH_WORKERS = DEFAULT_MAX_WORKERS
74
+ from .execution.apig.submit import merge_ack_completion
75
+ from .execution.block_runs_scope import (
76
+ BlockScopeError,
77
+ build_scope_record,
78
+ check_operation_scope,
79
+ narrow_job_for_block,
80
+ parse_target,
81
+ resolve_block,
82
+ snapshot_closure,
83
+ stamp_verdict,
84
+ )
85
+ from .execution.coalesce import Coalescer, content_key
86
+ from .execution.dispatch import OpDispatcher
87
+ from .execution.errors import ExecutionError
88
+ from .execution.python_exec import PythonExecService
89
+ from .execution.replay_safe import ReplaySafe, content_key_for
90
+ from .execution.service import ExecutionService
91
+ from .interfaces import apig_assets
92
+ from .interfaces.celllink import resolve_run as resolve_celllink_run
93
+ from .interfaces.errors import ContractError
94
+ from .stores.layers import (
95
+ PresetStore,
96
+ get_stackup,
97
+ viewer_rows,
98
+ viewer_rows_for_preset,
99
+ )
100
+ from .interfaces.runparams import RunParamsError, validate_run_request
101
+ from .interfaces.setup import report_capabilities
102
+ from .interfaces.vocabulary import OPERATIONS, QUERIES
103
+ from .interfaces.yaml_dump import dump_yaml_text
104
+ from .library.apig_catalog import (
105
+ ApigCatalog,
106
+ gdsfactory_provider,
107
+ static_provider,
108
+ )
109
+ from .library.catalog import Catalog
110
+ from .library.errors import LibraryError
111
+ from .execution.ports import BOOT_PROFILE, PortBus
112
+ from .execution.runtime import EngineRuntime
113
+ from .project.errors import ProjectError
114
+ from .project.service import ProjectService
115
+ from .workflows.completion import design_completion_report, plan_checks_report
116
+ from .ops.deferred import _DeferredMixin
117
+ from .ops.editors import _EditorMixin
118
+ from .ops.submit import _SubmitMixin
119
+ from .ops.queries import _QueryMixin
120
+ from .ops.runs import _RunMixin, refuse_smuggled_budget
121
+ from .ops.gates import _GateMixin, check_submit_gates
122
+ from .ops.evidence import (
123
+ confine_spice_arguments as _confine_spice_arguments,
124
+ )
125
+ from .ops.evidence import (
126
+ evidence_source_state as _evidence_source_state,
127
+ )
128
+ from .ops.evidence import (
129
+ maybe_record_evidence as _maybe_record_evidence,
130
+ )
131
+ from .ops.evidence import (
132
+ record_run_evidence as _record_run_evidence,
133
+ )
134
+
135
+ _WIRED_QUERIES = frozenset(
136
+ {
137
+ "get_project_snapshot",
138
+ "get_capabilities",
139
+ "get_events_since",
140
+ "list_projects",
141
+ "list_docs",
142
+ "read_doc",
143
+ }
144
+ )
145
+
146
+ # Wired query -> ProjectService method serving it. Engine.submit keeps
147
+ # its explicit dispatch (argument checks differ per query); this map
148
+ # is the docs/help seam — scripts/api_spec.py and the MCP server read
149
+ # handler docstrings through it, so a rewired query updates here.
150
+ QUERY_METHODS = {
151
+ "get_project_snapshot": "snapshot",
152
+ "get_events_since": "events_since",
153
+ "list_projects": "list_projects",
154
+ "list_docs": "list_docs",
155
+ "read_doc": "read_doc",
156
+ "get_component": "get_component",
157
+ "components_of": "components_of",
158
+ "entities_with": "entities_with",
159
+ "get_world_snapshot": "world_snapshot",
160
+ "read_settings": "read_settings",
161
+ "get_resolved_config": "resolved_config",
162
+ "read_entity_revision": "entity_revision",
163
+ "list_sheets": "entities_with",
164
+ "get_sheet": "get_component",
165
+ "get_instance": "get_component",
166
+ "instances_on_sheet": "entities_with",
167
+ "list_symbols": "entities_with",
168
+ "get_symbol": "get_component",
169
+ "get_cell": "get_component",
170
+ "list_layers": "read_doc",
171
+ "check_capability": "capability_report",
172
+ "ip_search": "entities_with",
173
+ "ip_get": "get_component",
174
+ "connection_graph": "graph_report",
175
+ "net_occurrences": "graph_report",
176
+ "xprobe": "probe_report",
177
+ "resolve_pin": "probe_report",
178
+ "plot_export": "plot_report",
179
+ "erc_check": "erc_report",
180
+ "write_netlist": "netlist_report",
181
+ "update_netlist": "netlist_report",
182
+ "list_testbenches": "entities_with",
183
+ "describe_testbench": "testbench_report",
184
+ }
185
+
186
+ # Wired query -> Engine method serving it. Same docs/help seam as
187
+ # QUERY_METHODS, for the queries the composition root answers
188
+ # itself (solver + store introspection the project ledger cannot
189
+ # serve). scripts/api_spec.py and the MCP server read Engine
190
+ # handler docstrings through this map.
191
+ QUERY_ENGINE_METHODS = {
192
+ "get_capabilities": "capabilities",
193
+ "get_python_console": "python_console",
194
+ "api_search": "api_search",
195
+ "api_info": "api_info",
196
+ "api_help": "api_help",
197
+ "get_job_status": "job_status",
198
+ "get_check_results": "check_results",
199
+ "get_logs": "run_logs",
200
+ "get_findings": "findings",
201
+ "list_job_kinds": "job_kinds",
202
+ "get_python_console": "python_console",
203
+ "list_cells": "catalog_cells",
204
+ "serve_asset": "serve_asset",
205
+ "list_assets": "list_assets",
206
+ "list_artifacts": "list_artifacts",
207
+ "get_layer_stackup": "layer_stackup",
208
+ "get_layer_rows": "layer_rows",
209
+ "list_layer_presets": "layer_presets",
210
+ "save_layer_preset": "layer_preset_save",
211
+ "delete_layer_preset": "layer_preset_delete",
212
+ "apply_layer_preset": "layer_preset_apply",
213
+ "list_survivors": "boot_report",
214
+ "plan_checks": "plan_checks",
215
+ "get_design_completion": "design_completion",
216
+ }
217
+
218
+ # Job-result queries served from execution state (plus the captured run log).
219
+ _JOB_QUERIES = frozenset(
220
+ {"get_job_status", "get_check_results", "get_logs", "get_findings",
221
+ "list_job_kinds"}
222
+ )
223
+
224
+ # Catalogue + raw-asset queries served from engine state (the cell
225
+ # index and the PDK store); read-only, never replay the backlog.
226
+ _APIG_QUERIES = frozenset({"list_cells", "serve_asset", "list_assets"})
227
+
228
+ # Replay-safety query (REPLAY-SAFE): the boot survivor report, served
229
+ # from ledger state. Report-only — never executes, only records
230
+ # idempotent interruption marks for newly found outages.
231
+ _REPLAY_QUERIES = frozenset({"list_survivors"})
232
+
233
+ # Layer-serving queries (LAYER-SERVING): stackup/rows/presets served
234
+ # from the PDK store plus Engine-memory user presets (PresetStore).
235
+ _LAYER_QUERIES = frozenset(
236
+ {
237
+ "get_layer_stackup",
238
+ "get_layer_rows",
239
+ "list_layer_presets",
240
+ "save_layer_preset",
241
+ "delete_layer_preset",
242
+ "apply_layer_preset",
243
+ }
244
+ )
245
+
246
+ # Completion queries (I27-GATE): stage plans plus the fully-verified
247
+ # gate, served from the solver registry and the latest check verdicts.
248
+ _COMPLETION_QUERIES = frozenset({"plan_checks", "get_design_completion"})
249
+
250
+ # A get_logs page never dumps more than this many lines at once.
251
+ _LOG_PAGE_MAX = 200
252
+
253
+ # Engine-served operations: submit-branch job control with no solver.
254
+ # The api_spec seam reads handler docstrings through this map, like
255
+ # QUERY_ENGINE_METHODS for queries — a rewired op updates here, and
256
+ # scripts/api_spec.py plus the MCP server follow with no edits.
257
+ ENGINE_OP_METHODS = {
258
+ "resume_run": "resume_survivor",
259
+ "discard_run": "discard_survivor",
260
+ "resume_all_retryable": "resume_all_retryable",
261
+ "cancel_job": "cancel_job",
262
+ "python_open": "open_console",
263
+ "python_reset": "reset_console",
264
+ }
265
+
266
+ # CRUD mutations + rewind execute immediately inside submit()
267
+ # (run_job precedent): revision gate + idempotency + event logging,
268
+ # no queue, no solver. Engine.run delegates them back to submit so
269
+ # the CLI `run` path shares the one implementation. Wire ops
270
+ # (WIRE-PLACE) ride the same immediate path once the ENGINE-ECS
271
+ # store lands; without it they report unsupported.
272
+ _CRUD_OPERATIONS = frozenset(
273
+ {
274
+ "create_project",
275
+ "write_doc",
276
+ "delete_doc",
277
+ "apply_mutations",
278
+ "rewind",
279
+ "create_entity",
280
+ "set_component",
281
+ "attach_component",
282
+ "detach_component",
283
+ "delete_entity",
284
+ "write_settings",
285
+ "place_wire",
286
+ "place_junction",
287
+ "connect_endpoint",
288
+ "move_wire",
289
+ "split_wire",
290
+ "delete_wire",
291
+ "delete_junction",
292
+ "materialize_nets",
293
+ "apply_transaction",
294
+ "create_shape",
295
+ "create_sheet",
296
+ "place_instance",
297
+ "bind_block",
298
+ "create_symbol",
299
+ "create_cell",
300
+ "map_pins",
301
+ "ip_register", "ip_instantiate", "ip_promote",
302
+ "bind_layers",
303
+ "annotate",
304
+ "create_testbench",
305
+ "regenerate_testbench",
306
+ }
307
+ )
308
+
309
+ # Wave-3 reads needing typed Engine dispatch (argument defaults
310
+ # and assembly the generic component seams do not provide). The
311
+ # docs seam stays QUERY_METHODS; this set only routes submit to
312
+ # the _EditorMixin handler.
313
+ _EDITOR_READS = frozenset(
314
+ {
315
+ "read_entity_revision",
316
+ "list_sheets",
317
+ "get_sheet",
318
+ "get_instance",
319
+ "instances_on_sheet",
320
+ "list_symbols",
321
+ "get_symbol",
322
+ "get_cell",
323
+ "list_layers",
324
+ "check_capability",
325
+ "ip_search",
326
+ "ip_get",
327
+ "connection_graph",
328
+ "net_occurrences",
329
+ "xprobe",
330
+ "resolve_pin",
331
+ "plot_export",
332
+ "erc_check",
333
+ "write_netlist",
334
+ "update_netlist",
335
+ "list_testbenches",
336
+ "describe_testbench",
337
+ }
338
+ )
339
+
340
+ # Adapter violation key -> finding location key. bbox keeps its legacy
341
+ # lambda-units name; everything else passes through unchanged.
342
+ _LOCATION_KEYS = (
343
+ ("bbox", "bbox_lambda"),
344
+ ("bbox_um", "bbox_um"),
345
+ ("area", "area"),
346
+ ("file", "file"),
347
+ ("line", "line"),
348
+ ("col", "col"),
349
+ ("sim_time", "sim_time"),
350
+ )
351
+
352
+
353
+ class Engine(
354
+ _SubmitMixin,
355
+ _QueryMixin,
356
+ _RunMixin,
357
+ _GateMixin,
358
+ _EditorMixin,
359
+ _DeferredMixin,
360
+ ):
361
+ """Composition root: one bound project plus its solvers and services.
362
+
363
+ Args:
364
+ project_id: Project this engine is bound to; cross-project
365
+ envelopes are denied, never routed.
366
+ actor: Attribution for mutations this engine submits
367
+ (human / rocket-fuel / system); passed through to every
368
+ ledger event verbatim.
369
+ catalog_root: Root the component catalog resolves against.
370
+ engine_root: Engine root for ledger durability; the log
371
+ persists under `projects/<project_id>/` beneath it.
372
+ None keeps the in-memory ledger.
373
+ linked_root: Caller tree for linked projects; rewind
374
+ reports files outside `.rocket/` and never writes
375
+ there. None means a managed project.
376
+ constraints: Optional run_spice metric targets
377
+ ({metric: {min, max, unit[, expr, control]}}); caller
378
+ targets are refused, unknown metrics fail loudly.
379
+ blocks: Optional block store for block-scoped runs (a
380
+ mapping of id to block entity, or a list of block
381
+ mappings); runs without a block target never read it.
382
+ pool_max_depth: Maximum undrained execution backlog;
383
+ admissions past it are refused, never queued.
384
+ project_dir: Project folder python_execute path mode
385
+ resolves inside; path mode refuses loudly without one.
386
+ The same folder also scopes run_spice source
387
+ confinement and the results/engine-runs evidence trail,
388
+ so every transport confines and records identically.
389
+ None keeps the engine in-memory (no confinement, no
390
+ evidence files).
391
+ """
392
+
393
+ def __init__(self, project_id, actor, catalog_root=".",
394
+ engine_root=None, linked_root=None, constraints=None,
395
+ blocks=None, pool_max_depth=64, project_dir=None):
396
+ self._project_id = project_id
397
+ self._actor = actor
398
+ self._project_dir = project_dir
399
+ self._blocks = blocks if blocks is not None else {}
400
+ self._project = ProjectService(
401
+ project_id, actor, engine_root=engine_root,
402
+ linked_root=linked_root,
403
+ )
404
+ self._execution = ExecutionService(project_id)
405
+ self._coalescer = Coalescer()
406
+ self._layer_presets = PresetStore()
407
+ self._catalog = Catalog(catalog_root)
408
+ self._solvers = {
409
+ "run_drc": {"magic": MagicDrcAdapter(), "klayout": KlayoutDrcAdapter()},
410
+ "run_hdl_test": {"icarus": HdlSimAdapter()},
411
+ "run_optical": {"sax": SaxAdapter(), "circulax": CirculaxAdapter()},
412
+ "run_em": {"meow": MeowAdapter()},
413
+ "run_extraction": {"magic": MagicExtractAdapter()},
414
+ "run_lvs": {"netgen": NetgenLvsAdapter()},
415
+ "run_spice": {"ngspice": NgspiceAdapter()},
416
+ "run_post_layout": {"ngspice": PostLayoutAdapter()},
417
+ "run_syntax": {"slang": SyntaxAdapter()},
418
+ "run_synth": {"yosys": YosysSynthAdapter()},
419
+ "run_connectivity": {"connectgraph": ConnectivityAdapter()},
420
+ "run_erc": {"connectgraph": ConnectivityAdapter()},
421
+ "run_formal": {"sby": FormalSbyAdapter()},
422
+ "run_view_sync": {"connectgraph": ViewSyncAdapter()},
423
+ "generate_views": {"gdsfactory": GdsfactoryViewsAdapter()},
424
+ "run_cosim": {"cosim": CosimAdapter()},
425
+ "run_digital_flow": {"librelane": LibrelaneFlowAdapter()},
426
+ "run_timing": {"opensta": OpenStaTimingAdapter()},
427
+ "run_xschem": {"xschem": XschemAdapter()},
428
+ "run_antenna": {"klayout": AntennaKlayoutAdapter()},
429
+ }
430
+ self._constraints = dict(constraints) if constraints is not None else None
431
+ self._logs = {}
432
+ self._datasets = {}
433
+ self._spectra = {}
434
+ self._latest = {}
435
+ self._run_jobs = {}
436
+ self._pool = ApigPool(
437
+ self._project, self._pool_execute, max_depth=pool_max_depth,
438
+ max_workers=DEFAULT_BATCH_WORKERS,
439
+ )
440
+ self._replay = ReplaySafe(self._project, self._replay_execute)
441
+ self._cells = ApigCatalog(
442
+ catalog_root, providers=[gdsfactory_provider()]
443
+ )
444
+ self._replayed = False
445
+ self._pyexec = PythonExecService(
446
+ project_id, actor,
447
+ execution=self._execution,
448
+ project_root=project_dir,
449
+ dispatcher=OpDispatcher(
450
+ self, project_id=project_id, actor=actor),
451
+ )
452
+ self._consoles = ConsoleLifecycle()
453
+ self._deferred_receipts = {}
454
+ self._deferred = EngineRuntime(
455
+ project_id, runner=self._deferred_runner,
456
+ auto_dispatch=False,
457
+ )
458
+ # Step 7A: one shared Engine per process with many
459
+ # attached ports. Boot profile is stdio alone; the
460
+ # runtime toggle attaches MCP / loopback-HTTP live.
461
+ self._port_bus = PortBus()
462
+ for boot_port in BOOT_PROFILE:
463
+ self._port_bus.attach(boot_port, push=True)
464
+ self._port_cursor = 0
465
+
466
+ def snapshot(self):
467
+ """Read the current project snapshot.
468
+
469
+ Returns:
470
+ Snapshot mapping for the bound project.
471
+ """
472
+ return self._project.snapshot()
473
+
474
+ def events_since(self, cursor, require_contiguous_from=None):
475
+ """Read ledger events after a cursor.
476
+
477
+ Args:
478
+ cursor: Opaque position from a previous read.
479
+ require_contiguous_from: Assert contiguous history from
480
+ this seq; unmet demands fail instead of gap-hiding.
481
+
482
+ Returns:
483
+ Events appended after the cursor, oldest first.
484
+
485
+ Raises:
486
+ ProjectError: resync_required when the contiguity
487
+ demand cannot be met — re-read the snapshot plus
488
+ the full retained log and reset the cursor.
489
+ """
490
+ return self._project.events_since(
491
+ cursor, require_contiguous_from=require_contiguous_from
492
+ )
493
+
494
+ def sync_ports(self):
495
+ """Fan newly appended ledger events out to the ports.
496
+
497
+ Pulls the project ledger since the last synced cursor
498
+ and ingests the batch into the shared port bus. Every
499
+ successful Engine.submit mutation path syncs
500
+ automatically; direct ProjectService or batch callers
501
+ sync explicitly through this call.
502
+
503
+ Returns:
504
+ Sync record with the ingested count, per-port
505
+ enqueue counts, and the bus cursor.
506
+
507
+ Caution: Sync is pull-and-fan-out, never a merge — a
508
+ quarantined port converges only through
509
+ port_resync, never through this call.
510
+ """
511
+ events = self._project.events_since(self._port_cursor)
512
+ counts = self._port_bus.ingest(events)
513
+ if events:
514
+ self._port_cursor = max(
515
+ event["seq"] for event in events)
516
+ return {"ingested": len(events), "per_port": counts,
517
+ "cursor": self._port_bus.cursor}
518
+
519
+ def attach_port(self, name, push=True, max_depth=None):
520
+ """Attach one port to the shared world.
521
+
522
+ Args:
523
+ name: Port name (stdio, mcp, ide, loopback-http).
524
+ push: True for async push delivery; False leaves
525
+ the port pull-only over get_events_since.
526
+ max_depth: Per-port queue ceiling; the bus default
527
+ when omitted.
528
+
529
+ Returns:
530
+ Attached port descriptor.
531
+
532
+ Raises:
533
+ ExecutionError: invalid_command for a bad name,
534
+ port_exists for an attached name.
535
+ """
536
+ return self._port_bus.attach(name, push=push,
537
+ max_depth=max_depth)
538
+
539
+ def detach_port(self, name):
540
+ """Detach one port; its backlog is dropped with it.
541
+
542
+ Args:
543
+ name: Attached port name to remove.
544
+
545
+ Returns:
546
+ Detached port descriptor.
547
+
548
+ Raises:
549
+ ExecutionError: unknown_port for a detached name.
550
+ """
551
+ return self._port_bus.detach(name)
552
+
553
+ def port_state(self, name):
554
+ """Read one port's engine-side bookkeeping.
555
+
556
+ Args:
557
+ name: Attached port name.
558
+
559
+ Returns:
560
+ Descriptor with name, push, enabled, state,
561
+ resync_required, backlog, cursor, acked, and
562
+ max_depth.
563
+
564
+ Raises:
565
+ ExecutionError: unknown_port for a detached name.
566
+ """
567
+ return self._port_bus.port_state(name)
568
+
569
+ def port_pending(self, name):
570
+ """Read one port's queued ledger events.
571
+
572
+ Args:
573
+ name: Attached port name.
574
+
575
+ Returns:
576
+ Queued event copies, oldest first.
577
+
578
+ Raises:
579
+ ExecutionError: unknown_port for a detached name.
580
+ """
581
+ return self._port_bus.pending(name)
582
+
583
+ def port_ack(self, name, seq):
584
+ """Acknowledge delivery through one ledger position.
585
+
586
+ Args:
587
+ name: Attached port name.
588
+ seq: Ledger position acknowledged.
589
+
590
+ Returns:
591
+ Ack record with port, acked position, and backlog.
592
+
593
+ Raises:
594
+ ExecutionError: unknown_port for a detached name,
595
+ invalid_command for a non-integer position.
596
+ """
597
+ return self._port_bus.ack(name, seq)
598
+
599
+ def port_drain(self, name, handler, limit=None,
600
+ guarded=True):
601
+ """Deliver one port's backlog through a handler.
602
+
603
+ Every position routes through the port's bound
604
+ position guard, so redelivery applies once while
605
+ every delivery still advances the ack.
606
+
607
+ Args:
608
+ name: Attached port name.
609
+ handler: Callable taking one event mapping.
610
+ limit: Maximum events to deliver; the whole
611
+ backlog when omitted.
612
+ guarded: True applies each position once through
613
+ the port's bound guard.
614
+
615
+ Returns:
616
+ Drain record with delivered and applied counts,
617
+ the acked position, and handler errors.
618
+
619
+ Raises:
620
+ ExecutionError: unknown_port for a detached name.
621
+ """
622
+ return self._port_bus.drain(name, handler, limit=limit,
623
+ guarded=guarded)
624
+
625
+ def port_evaluate(self):
626
+ """Run the staged no-ack ladder over every port.
627
+
628
+ Past 10s a silent port warns, past 15s its backlog is
629
+ dropped and it quarantines, past 20s the engine-side
630
+ resync directive fires. The engine flags, never kills.
631
+
632
+ Returns:
633
+ Directive mappings in attach order.
634
+ """
635
+ return self._port_bus.port_evaluate()
636
+
637
+ def port_resync(self, name, head=None):
638
+ """Converge one port onto the current ledger head.
639
+
640
+ The client re-runs its idempotent onboarding ritual
641
+ from this position; flags clear and delivery resumes.
642
+
643
+ Args:
644
+ name: Attached port name.
645
+ head: Position to converge on; the bus head when
646
+ omitted.
647
+
648
+ Returns:
649
+ Resync receipt with port, cursor, and state.
650
+
651
+ Raises:
652
+ ExecutionError: unknown_port for a detached name,
653
+ invalid_command for a non-integer head.
654
+ """
655
+ return self._port_bus.port_resync(name, head=head)
656
+
657
+ def set_port_enabled(self, name, enabled, local=False):
658
+ """Enable or disable one port live (local-only toggle).
659
+
660
+ Disabling quarantines like a dead ship: the backlog is
661
+ dropped and the resync flag is set.
662
+
663
+ Args:
664
+ name: Attached port name.
665
+ enabled: True for live delivery, False to park
666
+ the port.
667
+ local: Caller asserts a local (loopback/stdio)
668
+ caller; anything else is refused loudly per
669
+ LOCAL-SINGLE-USER.
670
+
671
+ Returns:
672
+ Updated port descriptor.
673
+
674
+ Raises:
675
+ ExecutionError: unknown_port for a detached name,
676
+ remote_toggle_denied for a non-local caller.
677
+ """
678
+ return self._port_bus.set_enabled(name, enabled,
679
+ local=local)
680
+
681
+ def set_port_profile(self, names, local=False):
682
+ """Swap the live port profile (local-only toggle).
683
+
684
+ Missing names attach as push ports; every other port
685
+ disables like a dead ship. The boot profile is stdio
686
+ alone.
687
+
688
+ Args:
689
+ names: Port names forming the live profile.
690
+ local: Caller asserts a local caller; anything
691
+ else is refused loudly per LOCAL-SINGLE-USER.
692
+
693
+ Returns:
694
+ Profile mapping with the enabled names sorted.
695
+
696
+ Raises:
697
+ ExecutionError: remote_toggle_denied for a
698
+ non-local caller, invalid_command for a bad
699
+ profile.
700
+ """
701
+ return self._port_bus.set_profile(names, local=local)
702
+
703
+ def capabilities(self, target=None, engine_root=None):
704
+ """Report per-solver availability, store state, and the provision menu.
705
+
706
+ The engine side of the Vivado-style setup surface: every
707
+ cataloged operation with readiness (ready when some
708
+ registered tool resolves, or when the Engine serves the
709
+ operation directly; blocked with reason + missing items
710
+ + remedy when no tool resolves; unsupported when no
711
+ solver is registered), each tool's availability from its
712
+ own detection probe plus its adapter capabilities(),
713
+ PDK/deck install state from the store, BYOC-gated cloud
714
+ rows, and the checkbox provision catalogue with live
715
+ install state.
716
+
717
+ Args:
718
+ target: Target passed to each adapter's capabilities().
719
+ engine_root: Explicit store root; ROCKETEDA_HOME
720
+ resolution when omitted.
721
+
722
+ Returns:
723
+ Capability mapping with operations, store, pdks,
724
+ decks, cloud, and catalogue sections.
725
+
726
+ Raises:
727
+ SetupError: unknown_solver when a registered
728
+ (operation, tool) has no detection spec — the
729
+ report fails instead of misreporting a solver it
730
+ cannot probe.
731
+
732
+ Caution: Read-only: never creates the store skeleton and
733
+ never attempts network — cloud rows report gated with
734
+ key_state missing until CLOUD-AUTH supplies keys plus
735
+ an explicit opt-in (F12/I32 BYOC).
736
+ """
737
+ return report_capabilities(
738
+ self._solvers,
739
+ target=target,
740
+ engine_root=engine_root,
741
+ served=_CRUD_OPERATIONS | {"run_job", "place_cell", "python_execute"},
742
+ )
743
+
744
+ def submit(self, command):
745
+ """Admit and run an operation inline, or serve a wired query.
746
+
747
+ Solver operations admit through the project service with
748
+ revision gating and envelope-id idempotency, then execute
749
+ immediately: the returned mapping is the admission ack
750
+ shape plus the completion (state, verdict, operation) under
751
+ the one run id. CRUD mutations, rewind, and place_cell
752
+ execute immediately through the same admission machinery
753
+ (no queue, no solver); only wired queries read state, the
754
+ rest honestly report unwired.
755
+
756
+ Args:
757
+ command: Envelope with project_id, operation, arguments,
758
+ and request_id.
759
+
760
+ Returns:
761
+ Merged ack-plus-completion for solver operations (ack
762
+ identity fields plus state, verdict, operation), the
763
+ immediate mutation mapping (new revision + events +
764
+ approval-UI fields) for CRUD/rewind/place_cell
765
+ operations, the snapshot for the wired
766
+ get_project_snapshot query, the capability mapping
767
+ for get_capabilities, the discovery record for
768
+ api_search/api_info/api_help, the event page for
769
+ get_events_since, the project/document listing for
770
+ the CRUD queries, the status/checks/logs/kinds for
771
+ the job-result queries, the stage plan or completion
772
+ report for the completion queries, the cell listing
773
+ or asset bytes for the catalogue/asset queries, the
774
+ evidence listing for list_artifacts, the
775
+ stackup/rows/presets for the layer-serving queries,
776
+ the stored-or-fresh completion for run_job, the
777
+ boot survivor report for list_survivors, the resume
778
+ completion for resume_run, the discard record for
779
+ discard_run, the gated batch for resume_all_retryable,
780
+ the approval handle for python_execute, or the console
781
+ view for get_python_console.
782
+
783
+ Raises:
784
+ ProjectError: cross_project_denied for a foreign
785
+ envelope, unsupported_operation for an unwired or
786
+ unknown name, unknown_block for a block target
787
+ naming no block, invalid_target for a malformed
788
+ target, unsupported_scope for a block target on a
789
+ verb serving no block runs yet, unknown_run_param
790
+ for an unknown run.request field, invalid_run_param
791
+ for a mistyped run.request knob, invalid_run_request
792
+ for a non-mapping run.request, invalid_command
793
+ for caller-supplied run_spice targets,
794
+ revision_conflict for a stale
795
+ expected_revision, invalid_command for a malformed
796
+ get_events_since cursor, a malformed mutation, a
797
+ place_cell without a cell, a serve_asset without
798
+ pdk id/revision/path, a rewind that lacks a target
799
+ or targets the live head, a write_settings missing
800
+ kind or settings, an unknown settings kind, or
801
+ settings that break the kind schema, future_format
802
+ for a settings write over a newer on-disk file,
803
+ or a layer query missing its pdk_id/name, a
804
+ completion query without a flow or with an unknown
805
+ flow, a resume_run or discard_run without a run_id,
806
+ a component verb missing entity, component, or
807
+ data, or a world snapshot with a non-bool
808
+ flag, unknown_entity or unknown_component for
809
+ a missing entity or component, component_exists
810
+ for a duplicate attach, backfill_readonly for a
811
+ write over a read-only backfilled id,
812
+ resync_required when a get_events_since contiguity
813
+ demand cannot be met, invalid_project
814
+ for a bad project id,
815
+ project_exists for a duplicate create,
816
+ unknown_document for a missing document,
817
+ unknown_revision for a rewind target with no
818
+ snapshot, invalid_path or path_escape for a bad
819
+ document path, request_conflict for a reused
820
+ envelope id with new text, unknown_cell,
821
+ ambiguous_cell, unknown_param, incompatible_port,
822
+ or instance_exists for a bad placement,
823
+ invalid_reference for a bad asset address,
824
+ unknown_request for an id never admitted.
825
+ ExecutionError: pool_full when the execution backlog
826
+ is saturated — drain (run_job) before submitting
827
+ more; nothing was admitted. coalesced_run_lost
828
+ when a joined run finished with no completion
829
+ stored (its owner died mid-drain). Resume refusals
830
+ carry unknown_survivor, already_completed,
831
+ already_decided, or poison_quarantined with a
832
+ user-facing message (full context is logged
833
+ dev-side).
834
+
835
+ Caution: Bound to one project: cross-project envelopes are
836
+ denied, never routed — and a stale expected_revision
837
+ fails instead of merging. The first mutating call per
838
+ Engine also builds the crash-survivor boot report
839
+ (report-only — nothing executes without an explicit
840
+ resume decision); queries never do.
841
+ """
842
+ envelope = command.get("project_id")
843
+ if envelope != self._project_id:
844
+ raise ProjectError(
845
+ "cross_project_denied",
846
+ f"Bound to {self._project_id}, got {envelope}.",
847
+ )
848
+ name = command.get("operation")
849
+ arguments = dict(command.get("arguments") or {})
850
+ arguments = self._apply_targets(name, arguments)
851
+ arguments = self._apply_run_request(arguments)
852
+ if name == "run_spice":
853
+ arguments = self.confine_spice_arguments(
854
+ arguments, self._project_dir
855
+ )
856
+ if name == "get_capabilities":
857
+ return self.capabilities(
858
+ target=arguments.get("target"),
859
+ engine_root=arguments.get("engine_root"),
860
+ )
861
+ if name == "get_python_console":
862
+ return self.python_console()
863
+ if name == "api_search":
864
+ return self.api_search(query=arguments.get("query"))
865
+ if name == "api_info":
866
+ if "name" not in arguments:
867
+ raise ProjectError(
868
+ "invalid_command", "api_info needs a name argument."
869
+ )
870
+ return self.api_info(arguments["name"])
871
+ if name == "api_help":
872
+ return self.api_help(name=arguments.get("name"))
873
+ if name == "get_events_since":
874
+ return self._query_events_since(arguments)
875
+ if name == "list_projects":
876
+ return self._project.list_projects()
877
+ if name == "list_docs":
878
+ return self._project.list_docs(prefix=arguments.get("prefix"))
879
+ if name == "read_doc":
880
+ if "path" not in arguments:
881
+ raise ProjectError(
882
+ "invalid_command", "read_doc needs a path argument."
883
+ )
884
+ return self._project.read_doc(arguments["path"])
885
+ if name == "read_settings":
886
+ if "kind" not in arguments:
887
+ raise ProjectError(
888
+ "invalid_command", "read_settings needs a kind argument."
889
+ )
890
+ return self._project.read_settings(arguments["kind"])
891
+ if name == "get_resolved_config":
892
+ return self._project.resolved_config(
893
+ session_id=arguments.get("session_id"),
894
+ run_id=arguments.get("run_id"),
895
+ )
896
+ if name == "get_component":
897
+ if "entity" not in arguments or "component" not in arguments:
898
+ raise ProjectError(
899
+ "invalid_command",
900
+ "get_component needs entity and component arguments.",
901
+ )
902
+ return self._project.get_component(
903
+ arguments["entity"], arguments["component"]
904
+ )
905
+ if name == "components_of":
906
+ if "entity" not in arguments:
907
+ raise ProjectError(
908
+ "invalid_command",
909
+ "components_of needs an entity argument.",
910
+ )
911
+ return self._project.components_of(arguments["entity"])
912
+ if name == "entities_with":
913
+ if "component" not in arguments:
914
+ raise ProjectError(
915
+ "invalid_command",
916
+ "entities_with needs a component argument.",
917
+ )
918
+ return self._project.entities_with(
919
+ arguments["component"],
920
+ predicate=arguments.get("predicate"),
921
+ )
922
+ if name == "get_world_snapshot":
923
+ return self._project.world_snapshot(
924
+ include_backfill=arguments.get("include_backfill", True)
925
+ )
926
+ if name in _EDITOR_READS:
927
+ return self._query_editor(name, arguments)
928
+ if name in _JOB_QUERIES:
929
+ return self._query_job(name, arguments)
930
+ if name == "list_artifacts":
931
+ return self.list_artifacts()
932
+ if name in _APIG_QUERIES:
933
+ return self._query_apig(name, arguments)
934
+ if name in _LAYER_QUERIES:
935
+ return self._query_layer(name, arguments)
936
+ if name in _COMPLETION_QUERIES:
937
+ return self._query_completion(name, arguments)
938
+ if name in _REPLAY_QUERIES:
939
+ return self.boot_report()
940
+ if name in _WIRED_QUERIES:
941
+ return self._project.snapshot()
942
+ if name in QUERIES or name not in OPERATIONS:
943
+ raise ProjectError(
944
+ "unsupported_operation", f"Operation not wired yet: {name}."
945
+ )
946
+ self._replay_once()
947
+ if name == "run_job":
948
+ # Deferred/batch pumping plus idempotent fetch: admission
949
+ # (and its revision gate) already happened at submit time.
950
+ return self.run(command)
951
+ if name == "resume_run":
952
+ if "run_id" not in arguments:
953
+ raise ProjectError(
954
+ "invalid_command",
955
+ "resume_run needs a run_id argument.",
956
+ )
957
+ return self.resume_survivor(
958
+ arguments["run_id"],
959
+ force=bool(arguments.get("force", False)),
960
+ )
961
+ if name == "discard_run":
962
+ if "run_id" not in arguments:
963
+ raise ProjectError(
964
+ "invalid_command",
965
+ "discard_run needs a run_id argument.",
966
+ )
967
+ return self.discard_survivor(arguments["run_id"])
968
+ if name == "resume_all_retryable":
969
+ return self.resume_all_retryable()
970
+ if name == "cancel_job":
971
+ # Job control, not admission: cancel rides submit so every
972
+ # transport reaches it, but it never enters the solver lane
973
+ # (no ledger admit, no revision gate — it names a live run).
974
+ return self.cancel_job(arguments.get("run_id"))
975
+ if name in _CRUD_OPERATIONS:
976
+ out = self._submit_crud(name, arguments, command)
977
+ self._deferred.apply_edit()
978
+ self.sync_ports()
979
+ return out
980
+ if name == "place_cell":
981
+ out = self._submit_place_cell(arguments, command)
982
+ self._deferred.apply_edit()
983
+ self.sync_ports()
984
+ return out
985
+ if name == "python_execute":
986
+ out = self._submit_pyexec(arguments, command)
987
+ self._deferred.apply_edit()
988
+ self.sync_ports()
989
+ return out
990
+ if name in ("python_open", "python_reset"):
991
+ return self._submit_console(name, arguments, command)
992
+ if not self._solvers.get(name):
993
+ raise ProjectError(
994
+ "unsupported_operation", f"No solver registered: {name}."
995
+ )
996
+ refuse_smuggled_budget(arguments)
997
+ check_submit_gates(
998
+ name, arguments, self._project,
999
+ force=command.get("force") is True,
1000
+ )
1001
+ scope_request = self._admit_block_scope(name, arguments)
1002
+ command_text = dump_yaml_text(
1003
+ {"operation": name, "arguments": arguments}
1004
+ )
1005
+ request_id = command.get("request_id") or uuid.uuid4().hex
1006
+ # COALESCE-I33: identical concurrent content on an unchanged
1007
+ # revision executes once. The guard holds the whole admission
1008
+ # section — lookup, ledger admit, register — so two identical
1009
+ # submits cannot both miss; drains and completion waits run
1010
+ # outside it. Force bypasses the lane both ways: a forced run
1011
+ # neither joins a live run nor registers as one.
1012
+ force = command.get("force") is True
1013
+ key = content_key(name, arguments)
1014
+ with self._coalescer.guard:
1015
+ head = self._project.snapshot()["current_revision"]
1016
+ hit = None if force else self._coalescer.lookup(key, head)
1017
+ if hit is not None:
1018
+ ack = self._project.log_coalesced_request(
1019
+ command_text, str(request_id), hit["run_id"],
1020
+ hit["snapshot_id"],
1021
+ )
1022
+ waiter = self._coalescer.attach(hit["run_id"], str(request_id))
1023
+ else:
1024
+ ack = self._pool.submit_request(
1025
+ command_text,
1026
+ str(request_id),
1027
+ expected_revision=arguments.get("expected_revision"),
1028
+ )
1029
+ # SUBMIT-IMMEDIATE: the ack run_id is the execution id. A
1030
+ # replay of a completed run returns the stored completion
1031
+ # without re-executing; otherwise register the record (a
1032
+ # replay returns the same ack, so register it only once)
1033
+ # and drain this run inline.
1034
+ stored = self._stored_completion(ack["run_id"])
1035
+ if stored is not None:
1036
+ return merge_ack_completion(ack, stored)
1037
+ if not self._execution.known(ack["run_id"]):
1038
+ target = {"cell": arguments.get("cell")}
1039
+ if scope_request is not None:
1040
+ target["scope"] = "block"
1041
+ target["block_id"] = scope_request["block_id"]
1042
+ self._execution.dispatch(
1043
+ name, target, ack["snapshot_id"], run_id=ack["run_id"]
1044
+ )
1045
+ self._run_jobs.setdefault(
1046
+ ack["run_id"],
1047
+ {
1048
+ "operation": name,
1049
+ "arguments": arguments,
1050
+ "snapshot_id": ack["snapshot_id"],
1051
+ "request_id": str(request_id),
1052
+ },
1053
+ )
1054
+ if scope_request is not None:
1055
+ pending = self._run_jobs[ack["run_id"]]
1056
+ if "block_scope" not in pending:
1057
+ pending["block_scope"] = build_scope_record(
1058
+ run_id=ack["run_id"],
1059
+ revision=self._project.snapshot()["current_revision"],
1060
+ block_id=scope_request["block_id"],
1061
+ deck=arguments.get("deck"),
1062
+ closure=scope_request["closure"],
1063
+ )
1064
+ waiter = None
1065
+ if not force:
1066
+ self._coalescer.register(
1067
+ key, ack["current_revision"], str(request_id),
1068
+ ack["run_id"], ack["snapshot_id"],
1069
+ )
1070
+ if hit is not None:
1071
+ out = self._await_shared(ack, hit["run_id"], waiter)
1072
+ self.sync_ports()
1073
+ return out
1074
+ try:
1075
+ completion = self._pool.drain_ids([ack["run_id"]])[0]
1076
+ except BaseException:
1077
+ if not force:
1078
+ self._coalescer.discard(ack["run_id"])
1079
+ raise
1080
+ if not force:
1081
+ self._coalescer.complete(ack["run_id"], completion)
1082
+ self._deferred.apply_edit()
1083
+ self._maybe_record_evidence(name, completion, arguments)
1084
+ self.sync_ports()
1085
+ return merge_ack_completion(ack, completion)
1086
+
1087
+
1088
+ def catalog_cells(self, pdk=None):
1089
+ """List versioned component cells, optionally for one PDK.
1090
+
1091
+ The engine introspects cell libraries upstream and serves
1092
+ plain data (cell, PDK, ports, default params), so the
1093
+ frontend never imports gdsfactory.
1094
+
1095
+ Args:
1096
+ pdk: Restrict to one PDK label; all entries when
1097
+ omitted.
1098
+
1099
+ Returns:
1100
+ Listing with catalog_version and cells sorted by
1101
+ (pdk, cell).
1102
+ """
1103
+ return self._cells.list_cells(pdk=pdk)
1104
+
1105
+ def register_cells(self, entries, source="static"):
1106
+ """Seed the component catalogue with static entries.
1107
+
1108
+ Args:
1109
+ entries: Entry mappings with cell, pdk, pdk_revision,
1110
+ ports, and default params.
1111
+ source: Source label stamped on every entry.
1112
+
1113
+ Returns:
1114
+ Mapping with the cell count now served.
1115
+ """
1116
+ self._cells.add_provider(static_provider(entries, source=source))
1117
+ return {"cells": len(self._cells.list_cells()["cells"])}
1118
+
1119
+ def serve_asset(self, pdk_id=None, revision=None, path=None,
1120
+ engine_root=None):
1121
+ """Serve one PDK file's bytes verbatim with hash identity.
1122
+
1123
+ No conversion, no rendering: the sha256 is computed over
1124
+ the raw bytes, base64 is transport encoding only.
1125
+
1126
+ Args:
1127
+ pdk_id: PDK id (either display spelling resolves).
1128
+ revision: PDK revision pin.
1129
+ path: Store-relative file path.
1130
+ engine_root: Explicit store root; ROCKETEDA_HOME
1131
+ resolution when omitted.
1132
+
1133
+ Returns:
1134
+ Mapping with asset_version, pdk_id, display_id,
1135
+ revision, canonical path, size, sha256, and the
1136
+ verbatim bytes base64-encoded.
1137
+
1138
+ Raises:
1139
+ ProjectError: invalid_command when pdk id, revision,
1140
+ or path is missing; invalid_reference for an
1141
+ unknown PDK, a missing file, or an escaping path.
1142
+
1143
+ Caution: No size cap — bulk reads belong to list_assets
1144
+ plus selective serve_asset calls, never a blind loop.
1145
+ """
1146
+ if pdk_id is None or revision is None or path is None:
1147
+ raise ProjectError(
1148
+ "invalid_command",
1149
+ "serve_asset needs pdk_id, revision, and path arguments.",
1150
+ )
1151
+ try:
1152
+ return apig_assets.serve_asset(
1153
+ pdk_id, revision, path, engine_root=engine_root
1154
+ )
1155
+ except ContractError as exc:
1156
+ raise ProjectError(exc.code, str(exc)) from exc
1157
+
1158
+ def list_assets(self, pdk_id=None, revision=None, prefix=None,
1159
+ engine_root=None):
1160
+ """List PDK file metadata with per-file hash identity.
1161
+
1162
+ Args:
1163
+ pdk_id: PDK id (either display spelling resolves).
1164
+ revision: PDK revision pin.
1165
+ prefix: Optional store-relative prefix filter.
1166
+ engine_root: Explicit store root; ROCKETEDA_HOME
1167
+ resolution when omitted.
1168
+
1169
+ Returns:
1170
+ Mapping with asset_version, pdk_id, display_id,
1171
+ revision, sorted file entries (path, size,
1172
+ sha256), symlink entries, and the file count.
1173
+
1174
+ Raises:
1175
+ ProjectError: invalid_command when pdk id or revision
1176
+ is missing; invalid_reference for an unknown PDK
1177
+ or an escaping prefix.
1178
+ """
1179
+ if pdk_id is None or revision is None:
1180
+ raise ProjectError(
1181
+ "invalid_command",
1182
+ "list_assets needs pdk_id and revision arguments.",
1183
+ )
1184
+ try:
1185
+ return apig_assets.list_assets(
1186
+ pdk_id, revision, prefix=prefix, engine_root=engine_root
1187
+ )
1188
+ except ContractError as exc:
1189
+ raise ProjectError(exc.code, str(exc)) from exc
1190
+
1191
+ def list_artifacts(self):
1192
+ """List recorded run-evidence files, newest first.
1193
+
1194
+ The WIRE-DECLARED-OPS wiring for the declared
1195
+ `list_artifacts` query: every transport shares the one
1196
+ `results/engine-runs` evidence trail the engine records
1197
+ itself, so this lists run ids with their operation,
1198
+ verdict, finish time, and evidence path. Unreadable
1199
+ files are skipped, never fatal — the trail is evidence,
1200
+ not a ledger.
1201
+
1202
+ Returns:
1203
+ Mapping with the artifact count plus entries sorted
1204
+ by path (each with run_id, operation, verdict,
1205
+ finished_at, and path). Empty when the engine is
1206
+ unbound or no run has been recorded yet.
1207
+
1208
+ Caution: Read-only: reports recorded evidence, never
1209
+ executes anything and never replays the backlog.
1210
+ """
1211
+ folder = None
1212
+ if self._project_dir is not None:
1213
+ folder = Path(self._project_dir).resolve() / "results" / "engine-runs"
1214
+ entries = []
1215
+ if folder is not None and folder.is_dir():
1216
+ for path in sorted(folder.glob("*.json")):
1217
+ try:
1218
+ record = json.loads(path.read_text(encoding="utf-8"))
1219
+ except (OSError, ValueError):
1220
+ continue
1221
+ if not isinstance(record, dict):
1222
+ continue
1223
+ entries.append(
1224
+ {
1225
+ "run_id": record.get("run_id", path.stem),
1226
+ "operation": record.get("operation", ""),
1227
+ "verdict": record.get("verdict", "UNKNOWN"),
1228
+ "finished_at": record.get("finished_at"),
1229
+ "path": str(path),
1230
+ }
1231
+ )
1232
+ entries.sort(
1233
+ key=lambda e: (e["finished_at"] or "", e["path"]), reverse=True
1234
+ )
1235
+ return {"artifacts": entries, "count": len(entries)}
1236
+
1237
+
1238
+ def boot_report(self):
1239
+ """Report crash survivors without executing anything (boot report).
1240
+
1241
+ Survivors are ledger admissions without a run event —
1242
+ typically a kill between admit and drain. Each carries its
1243
+ taxonomy kind (retryable, ambiguous, poison), quarantine
1244
+ state, and per-run Resume/Discard actions.
1245
+
1246
+ Returns:
1247
+ Report with the session-ended message, the unfinished
1248
+ count, survivors in admit order, quarantined run
1249
+ ids, and unresolved entries.
1250
+
1251
+ Caution: Report-only by construction: nothing executes
1252
+ here — every replay surface needs an explicit resume
1253
+ decision (resume_run, discard_run, resume_all_retryable).
1254
+ """
1255
+ return self._replay.boot()
1256
+
1257
+ def resume_survivor(self, run_id, force=False):
1258
+ """Explicitly resume one crash survivor through its solver.
1259
+
1260
+ Args:
1261
+ run_id: Survivor to resume.
1262
+ force: Required for quarantined (poison) runs; force
1263
+ bypasses quarantine only — the run still executes
1264
+ for real.
1265
+
1266
+ Returns:
1267
+ Executor completion for the run.
1268
+
1269
+ Raises:
1270
+ ExecutionError: unknown_survivor for an id with no
1271
+ unfinished run, already_completed for a finished
1272
+ run, already_decided for a run handled this
1273
+ session, poison_quarantined for a quarantined run
1274
+ resumed without force.
1275
+
1276
+ Caution: This executes the run — the one replay surface
1277
+ that does, and only on this explicit call.
1278
+ """
1279
+ return self._replay.resume(run_id, force=force)
1280
+
1281
+ def discard_survivor(self, run_id):
1282
+ """Explicitly discard one crash survivor, never executing it.
1283
+
1284
+ Args:
1285
+ run_id: Survivor to discard.
1286
+
1287
+ Returns:
1288
+ Discard record with run_id, discarded state, and the
1289
+ run's operation.
1290
+
1291
+ Raises:
1292
+ ExecutionError: unknown_survivor for an id with no
1293
+ unfinished run, already_completed for a finished
1294
+ run, already_decided for a run handled this
1295
+ session.
1296
+ """
1297
+ return self._replay.discard(run_id)
1298
+
1299
+ def resume_all_retryable(self):
1300
+ """Resume every RETRYABLE survivor, oldest first (gated).
1301
+
1302
+ Ambiguous runs need individual review and poison runs need
1303
+ per-run force, so both are skipped loudly — never swept up.
1304
+
1305
+ Returns:
1306
+ Batch with resumed run ids, their completions, and
1307
+ skipped entries (each with run_id, typed code, and
1308
+ user-facing message).
1309
+
1310
+ Raises:
1311
+ ExecutionError: Propagates the first executor failure
1312
+ unchanged — earlier resumes stand, later ones never
1313
+ fire.
1314
+
1315
+ Caution: This executes runs — only the RETRYABLE,
1316
+ non-quarantined survivors, and only on this call.
1317
+ """
1318
+ return self._replay.resume_all_retryable()
1319
+
1320
+ def resolve_python_approval(self, approval_id, decision, actor=None):
1321
+ """Resolve a python_execute approval via the human UI route.
1322
+
1323
+ Agent calls request approval; they never grant it — this
1324
+ method is the trusted UI route, and no MCP tool maps to it.
1325
+
1326
+ Args:
1327
+ approval_id: Approval bound by the submit call.
1328
+ decision: `approved` to admit the queued run, `denied`
1329
+ to lapse the approval with no run.
1330
+ actor: Human actor the decision is attributed to; the
1331
+ engine actor when omitted.
1332
+
1333
+ Returns:
1334
+ Resolution with approval_id, approval_state,
1335
+ execution_id, and the run_id once admitted.
1336
+
1337
+ Raises:
1338
+ ExecutionError: approval_invalid/approval_denied/
1339
+ approval_expired/approval_forbidden per the
1340
+ approval ledger.
1341
+
1342
+ Caution: Human route only — MCP agents must never call
1343
+ this; there is deliberately no MCP tool for it.
1344
+ """
1345
+ return self._pyexec.resolve_approval(
1346
+ approval_id, decision, via="ui",
1347
+ actor=actor if actor is not None else self._actor,
1348
+ )
1349
+
1350
+ def python_console(self):
1351
+ """Read the python_execute console view.
1352
+
1353
+ Returns:
1354
+ Console view with project_id, generation, env_digest,
1355
+ history cursor, and pending approvals.
1356
+
1357
+ Caution: Read-only: reports approval queue state, never
1358
+ resolves anything.
1359
+ """
1360
+ return self._pyexec.console_view()
1361
+
1362
+ def open_console(self, workspace_root=None):
1363
+ """Open a console, reusing the live one for its workspace.
1364
+
1365
+ Consoles are Engine-memory and workspace-bound: one live
1366
+ handle per workspace root, unbound when omitted. Opening
1367
+ never touches the project ledger, so no expected_revision
1368
+ rides along; the registry itself is the idempotency.
1369
+
1370
+ Args:
1371
+ workspace_root: Panel workspace binding; one live
1372
+ handle per root, unbound when omitted.
1373
+
1374
+ Returns:
1375
+ Opening with console_id, project_id, generation,
1376
+ and the workspace_root binding.
1377
+ """
1378
+ return self._consoles.open_console(
1379
+ self._project_id, self._actor,
1380
+ workspace_root=workspace_root)
1381
+
1382
+ def reset_console(self, console_id=None):
1383
+ """Retire a console generation; kill outstanding approvals.
1384
+
1385
+ Reset is a lifecycle event, never a re-run: the generation
1386
+ retires, pending approvals die through the session, and
1387
+ live workers stay cancel territory. Unknown ids fail
1388
+ loudly; nothing is admitted on failure.
1389
+
1390
+ Args:
1391
+ console_id: Console from `python_open`.
1392
+
1393
+ Returns:
1394
+ Mapping with the console_id and the fresh generation.
1395
+
1396
+ Raises:
1397
+ ProjectError: invalid_command without a console_id.
1398
+ ExecutionError: unknown_console for an id never
1399
+ opened or already closed.
1400
+ """
1401
+ if console_id is None:
1402
+ raise ProjectError(
1403
+ "invalid_command",
1404
+ "python_reset needs a console_id argument.",
1405
+ )
1406
+ return self._consoles.reset_console(console_id)
1407
+
1408
+ def api_search(self, query=None):
1409
+ """Search cataloged operations and queries by name or summary.
1410
+
1411
+ The discovery entrypoint: substring match over every
1412
+ cataloged name plus its handler summary, so callers find the
1413
+ verb without reading the whole reference.
1414
+
1415
+ Args:
1416
+ query: Substring matched against names and handler
1417
+ summaries (case-insensitive); every cataloged name
1418
+ when omitted.
1419
+
1420
+ Returns:
1421
+ Search mapping with the query and matches sorted by
1422
+ name (each with name, kind, wired, and summary).
1423
+ """
1424
+ needle = query.lower() if query else None
1425
+ matches = []
1426
+ for name in sorted(OPERATIONS | QUERIES):
1427
+ kind, wired, summary, _handlers = self._describe_op(name)
1428
+ if (
1429
+ needle is not None
1430
+ and needle not in name.lower()
1431
+ and needle not in summary.lower()
1432
+ ):
1433
+ continue
1434
+ matches.append({
1435
+ "name": name, "kind": kind,
1436
+ "wired": wired, "summary": summary,
1437
+ })
1438
+ return {"query": query, "matches": matches}
1439
+
1440
+ def api_info(self, name):
1441
+ """Describe one cataloged operation or query.
1442
+
1443
+ Args:
1444
+ name: Cataloged operation or query name.
1445
+
1446
+ Returns:
1447
+ Record with name, kind, wired, summary, and handlers
1448
+ in generated-spec form.
1449
+
1450
+ Raises:
1451
+ ProjectError: invalid_command for a missing name,
1452
+ unsupported_operation for an uncataloged name.
1453
+ """
1454
+ if name is None:
1455
+ raise ProjectError(
1456
+ "invalid_command", "api_info needs a name argument."
1457
+ )
1458
+ if name not in OPERATIONS and name not in QUERIES:
1459
+ raise ProjectError(
1460
+ "unsupported_operation",
1461
+ f"Operation not wired yet: {name}.",
1462
+ )
1463
+ kind, wired, summary, handlers = self._describe_op(name)
1464
+ return {
1465
+ "name": name, "kind": kind, "wired": wired,
1466
+ "summary": summary, "handlers": handlers,
1467
+ }
1468
+
1469
+ def api_help(self, name=None):
1470
+ """Explain one cataloged name, or outline the whole catalog.
1471
+
1472
+ Args:
1473
+ name: Cataloged operation or query name; outlines
1474
+ every cataloged name when omitted.
1475
+
1476
+ Returns:
1477
+ Help mapping with name, kind, wired, summary, and a
1478
+ text block (handler plus where the full reference
1479
+ section lives).
1480
+
1481
+ Raises:
1482
+ ProjectError: unsupported_operation for an uncataloged
1483
+ name.
1484
+ """
1485
+ if name is None:
1486
+ lines = [
1487
+ "Cataloged operations and queries "
1488
+ f"({len(OPERATIONS)} operations, {len(QUERIES)} queries).",
1489
+ "Name one with api_info for its handler record.",
1490
+ "",
1491
+ ]
1492
+ for entry in self.api_search()["matches"]:
1493
+ lines.append(f"- `{entry['name']}`: {entry['summary']}")
1494
+ text = "\n".join(lines) + "\n"
1495
+ return {
1496
+ "name": None, "kind": "query", "wired": True,
1497
+ "summary": "Outline of every cataloged operation and query.",
1498
+ "text": text,
1499
+ }
1500
+ record = self.api_info(name)
1501
+ handlers = ", ".join(
1502
+ f"`{handler}`" for handler in record["handlers"]
1503
+ ) or "no handler wired yet"
1504
+ text = (
1505
+ f"{record['summary']}\n\nHandler: {handlers}.\n"
1506
+ f"Full reference: docs/engine-api.md ### {name}."
1507
+ )
1508
+ return {
1509
+ "name": name, "kind": record["kind"],
1510
+ "wired": record["wired"], "summary": record["summary"],
1511
+ "text": text,
1512
+ }
1513
+
1514
+ def cancel_job(self, run_id=None):
1515
+ """Cancel a queued or running job through the job surface.
1516
+
1517
+ python_execute cells route to the cell runner (a live worker
1518
+ tree is killed promptly); solver-lane runs route to the
1519
+ execution record plus the pool backlog, so a queued batch
1520
+ item never executes after cancel. Finished runs keep their
1521
+ terminal state, truthfully.
1522
+
1523
+ Args:
1524
+ run_id: Admitted execution id to cancel.
1525
+
1526
+ Returns:
1527
+ Mapping with run_id and state.
1528
+
1529
+ Raises:
1530
+ ProjectError: invalid_command without a run_id.
1531
+ ExecutionError: unknown_run for an id never admitted.
1532
+ """
1533
+ if run_id is None:
1534
+ raise ProjectError(
1535
+ "invalid_command", "cancel_job needs a run_id argument."
1536
+ )
1537
+ try:
1538
+ return self._pyexec.cancel(run_id)
1539
+ except ExecutionError as exc:
1540
+ if exc.code != "unknown_run" or not self._execution.known(
1541
+ run_id
1542
+ ):
1543
+ raise
1544
+ cancelled = self._execution.cancel(run_id)
1545
+ self._pool.discard(run_id)
1546
+ return cancelled
1547
+
1548
+
1549
+ def job_status(self, run_id=None):
1550
+ """Read one run's state, verdict, and evidence links.
1551
+
1552
+ Args:
1553
+ run_id: Run to inspect (the submit-ack id, shared
1554
+ with the execution record).
1555
+
1556
+ Returns:
1557
+ Run state mapping with state, verdict, and log links.
1558
+
1559
+ Raises:
1560
+ ExecutionError: unknown_run for an unregistered id.
1561
+ """
1562
+ return self._execution.status(run_id)
1563
+
1564
+ def check_results(self, operation=None):
1565
+ """Read the latest check verdict per operation.
1566
+
1567
+ Args:
1568
+ operation: Restrict to one operation; all latest
1569
+ verdicts when omitted.
1570
+
1571
+ Returns:
1572
+ Mapping of operation to run state plus the current
1573
+ project revision.
1574
+ """
1575
+ checks = {}
1576
+ for op, run_id in self._latest.items():
1577
+ if operation and op != operation:
1578
+ continue
1579
+ status = self._execution.status(run_id)
1580
+ verdict = status.get("verdict") or {}
1581
+ checks[op] = {
1582
+ "run_id": run_id,
1583
+ "state": status["state"],
1584
+ "status": verdict.get("status"),
1585
+ "verdict": verdict,
1586
+ }
1587
+ return {
1588
+ "checks": checks,
1589
+ "current_revision": self._project.snapshot()["current_revision"],
1590
+ }
1591
+
1592
+ def plan_checks(self, flow):
1593
+ """Plan one flow's checks against the registered solvers.
1594
+
1595
+ The stage plan behind the check-plan display: every required
1596
+ stage with its readiness (ready when all tool entrypoints are
1597
+ registered, otherwise required-but-blocked with the missing
1598
+ operations named), so a blocked M14 cites run_thermal instead
1599
+ of vanishing.
1600
+
1601
+ Args:
1602
+ flow: Flow inventory to plan (digital/analog/photonic/mixed).
1603
+
1604
+ Returns:
1605
+ Plan mapping with the flow, per-stage readiness plus
1606
+ missing operations, blocked/ready id lists, the sorted
1607
+ missing operations, and the current revision.
1608
+
1609
+ Raises:
1610
+ ProjectError: invalid_command for a missing or unknown flow.
1611
+ """
1612
+ if not flow:
1613
+ raise ProjectError(
1614
+ "invalid_command", "plan_checks needs a flow argument."
1615
+ )
1616
+ try:
1617
+ plan = plan_checks_report(flow, set(self._solvers))
1618
+ except ValueError as exc:
1619
+ raise ProjectError("invalid_command", str(exc)) from exc
1620
+ plan["current_revision"] = self._project.snapshot()["current_revision"]
1621
+ return plan
1622
+
1623
+ def design_completion(self, flow):
1624
+ """Judge one flow's completion; refuse while blockers stand.
1625
+
1626
+ Layers the latest check verdicts over the stage plan: any
1627
+ blocked, unverified, or failing stage becomes a named blocker
1628
+ and the fully-verified claim is refused (claim None with a
1629
+ refusal naming the count); only a blocker-free plan claims
1630
+ fully verified with per-operation evidence run ids. Mixed M14
1631
+ plus the vocab-only run_thermal operation always appear while
1632
+ no thermal solver is registered.
1633
+
1634
+ Args:
1635
+ flow: Flow inventory to judge (digital/analog/photonic/mixed).
1636
+
1637
+ Returns:
1638
+ Completion mapping with the fully_verified flag, claim or
1639
+ refusal, named blockers, missing operations, evidence
1640
+ run ids, per-stage statuses, and the current revision.
1641
+
1642
+ Raises:
1643
+ ProjectError: invalid_command for a missing or unknown flow.
1644
+ """
1645
+ if not flow:
1646
+ raise ProjectError(
1647
+ "invalid_command",
1648
+ "get_design_completion needs a flow argument.",
1649
+ )
1650
+ seen = self.check_results()
1651
+ try:
1652
+ return design_completion_report(
1653
+ flow,
1654
+ set(self._solvers),
1655
+ seen["checks"],
1656
+ seen["current_revision"],
1657
+ )
1658
+ except ValueError as exc:
1659
+ raise ProjectError("invalid_command", str(exc)) from exc
1660
+
1661
+ def run_logs(self, run_id=None, query=None, offset=0, limit=40):
1662
+ """Search and page one run's captured native log.
1663
+
1664
+ Args:
1665
+ run_id: Run whose log to read.
1666
+ query: Case-insensitive substring filter; all lines
1667
+ when omitted.
1668
+ offset: First matching line to return (0-based).
1669
+ limit: Maximum lines per page (capped server-side).
1670
+
1671
+ Returns:
1672
+ Page mapping with numbered lines, totals, and a
1673
+ more flag.
1674
+
1675
+ Raises:
1676
+ ExecutionError: unknown_run for an unregistered id.
1677
+ ProjectError: invalid_command for a non-int
1678
+ offset or limit.
1679
+ """
1680
+ return self._logs_page(
1681
+ {"run_id": run_id, "query": query, "offset": offset, "limit": limit}
1682
+ )
1683
+
1684
+ def job_kinds(self):
1685
+ """List registered operations with their deck tools.
1686
+
1687
+ Returns:
1688
+ Mapping of job kinds with operation names and the
1689
+ tool keys each accepts.
1690
+ """
1691
+ return {
1692
+ "job_kinds": [
1693
+ {"operation": operation, "tools": list(solvers)}
1694
+ for operation, solvers in sorted(self._solvers.items())
1695
+ ]
1696
+ }
1697
+
1698
+
1699
+ def layer_stackup(self, pdk_id, revision=None, engine_root=None):
1700
+ """Serve one PDK's read-only layer stackup.
1701
+
1702
+ The viewer/3D authority: ordered layers, metal/tech
1703
+ grouping, enabled set, colors, and Z heights parsed from
1704
+ the registered PDK's KLayout tech files (hash-verified
1705
+ through the PDK store).
1706
+
1707
+ Args:
1708
+ pdk_id: Canonical display id (either spelling resolves).
1709
+ revision: Revision pin; the newest registered revision
1710
+ when omitted.
1711
+ engine_root: Explicit store root; resolved when omitted.
1712
+
1713
+ Returns:
1714
+ Stackup record with layers, enabled set, groups, and
1715
+ Z completeness.
1716
+
1717
+ Raises:
1718
+ ProjectError: invalid_command for a missing pdk_id.
1719
+ PdkError: unknown_pdk for a missing store/id/revision
1720
+ or .lyp deck, pdk_drift for a tampered file.
1721
+ LayerServingError: unknown_stackup for a PDK with no
1722
+ stackup mapping.
1723
+ """
1724
+ if not pdk_id:
1725
+ raise ProjectError(
1726
+ "invalid_command", "get_layer_stackup needs a pdk_id argument."
1727
+ )
1728
+ return get_stackup(pdk_id, revision=revision, engine_root=engine_root)
1729
+
1730
+ def layer_rows(self, pdk_id, preset=None, overrides=None, active=None,
1731
+ revision=None, engine_root=None):
1732
+ """Render viewer rows for one PDK's stackup.
1733
+
1734
+ Row visibility defaults to the PDK flags; an explicit
1735
+ preset applies first, then per-layer overrides, then the
1736
+ active layer.
1737
+
1738
+ Args:
1739
+ pdk_id: Canonical display id (either spelling resolves).
1740
+ preset: Built-in or user preset to apply; PDK defaults
1741
+ when omitted.
1742
+ overrides: Sparse {layer name: visible} wins over the
1743
+ preset map.
1744
+ active: Active layer override; the preset active when
1745
+ omitted.
1746
+ revision: Revision pin; the newest registered revision
1747
+ when omitted.
1748
+ engine_root: Explicit store root; resolved when omitted.
1749
+
1750
+ Returns:
1751
+ Page with stackup_ref, preset, flip, active, and
1752
+ file-order rows.
1753
+
1754
+ Raises:
1755
+ ProjectError: invalid_command for a missing pdk_id.
1756
+ PdkError: unknown_pdk for a missing store/id/revision.
1757
+ LayerServingError: unknown_stackup for an unmapped
1758
+ PDK, unknown_preset for a missing preset,
1759
+ invalid_preset for a stale user preset,
1760
+ invalid_visibility for bad overrides/active.
1761
+ """
1762
+ if not pdk_id:
1763
+ raise ProjectError(
1764
+ "invalid_command", "get_layer_rows needs a pdk_id argument."
1765
+ )
1766
+ stackup = get_stackup(pdk_id, revision=revision, engine_root=engine_root)
1767
+ if preset is None:
1768
+ return viewer_rows(stackup, visibility=overrides, active=active)
1769
+ return viewer_rows_for_preset(
1770
+ self._layer_presets, stackup, preset,
1771
+ overrides=overrides, active=active,
1772
+ )
1773
+
1774
+ def layer_presets(self):
1775
+ """List built-in plus user layer preset names.
1776
+
1777
+ Returns:
1778
+ Mapping with preset name records (name + builtin),
1779
+ built-ins first in pinned order.
1780
+ """
1781
+ return {"presets": self._layer_presets.list_presets()}
1782
+
1783
+ def layer_preset_save(self, pdk_id, name, layers, render_layers=None,
1784
+ flip=False, active=None, overwrite=False,
1785
+ revision=None, engine_root=None):
1786
+ """Save a user layer preset against one PDK's stackup.
1787
+
1788
+ Missing layer names default to hidden; apply() resolves
1789
+ the full map.
1790
+
1791
+ Args:
1792
+ pdk_id: Canonical display id (either spelling resolves).
1793
+ name: Preset name (built-in names are reserved).
1794
+ layers: Sparse {layer name: visible} map.
1795
+ render_layers: {toggle: bool} viewer aids; the default
1796
+ set when omitted.
1797
+ flip: View-from-bottom flag.
1798
+ active: Active layer name, or None.
1799
+ overwrite: Replace an existing user preset of the same
1800
+ name instead of conflicting.
1801
+ revision: Revision pin; the newest registered revision
1802
+ when omitted.
1803
+ engine_root: Explicit store root; resolved when omitted.
1804
+
1805
+ Returns:
1806
+ Stored preset record (builtin False).
1807
+
1808
+ Raises:
1809
+ ProjectError: invalid_command for a missing pdk_id.
1810
+ PdkError: unknown_pdk for a missing store/id/revision.
1811
+ LayerServingError: unknown_stackup for an unmapped
1812
+ PDK, invalid_preset for a bad name/shape,
1813
+ preset_conflict for a built-in name or an
1814
+ existing preset without overwrite.
1815
+
1816
+ Caution: View state, not project state: presets live in
1817
+ Engine memory (no revision advance, no ledger event)
1818
+ and are lost on restart until a durable store lands.
1819
+ """
1820
+ if not pdk_id:
1821
+ raise ProjectError(
1822
+ "invalid_command", "save_layer_preset needs a pdk_id argument."
1823
+ )
1824
+ stackup = get_stackup(pdk_id, revision=revision, engine_root=engine_root)
1825
+ return self._layer_presets.save(
1826
+ stackup, name, layers, render_layers=render_layers,
1827
+ flip=flip, active=active, overwrite=overwrite,
1828
+ )
1829
+
1830
+ def layer_preset_delete(self, name):
1831
+ """Delete one user layer preset (built-ins are undeletable).
1832
+
1833
+ Args:
1834
+ name: Preset name to delete.
1835
+
1836
+ Returns:
1837
+ Deletion record with the deleted name.
1838
+
1839
+ Raises:
1840
+ ProjectError: invalid_command for a missing name.
1841
+ LayerServingError: preset_conflict for a built-in
1842
+ name; unknown_preset for a missing user preset.
1843
+
1844
+ Caution: In-memory only: deletes the Engine-memory preset,
1845
+ no revision advance, no ledger event.
1846
+ """
1847
+ if not name:
1848
+ raise ProjectError(
1849
+ "invalid_command", "delete_layer_preset needs a name argument."
1850
+ )
1851
+ return self._layer_presets.delete(name)
1852
+
1853
+ def layer_preset_apply(self, pdk_id, name, revision=None, engine_root=None):
1854
+ """Explicitly apply one layer preset to a Stackup.
1855
+
1856
+ Args:
1857
+ pdk_id: Canonical display id (either spelling resolves).
1858
+ name: Built-in or user preset name.
1859
+ revision: Revision pin; the newest registered revision
1860
+ when omitted.
1861
+ engine_root: Explicit store root; resolved when omitted.
1862
+
1863
+ Returns:
1864
+ Full preset record with the complete layers map,
1865
+ renderLayers, flip, and active.
1866
+
1867
+ Raises:
1868
+ ProjectError: invalid_command for a missing pdk_id
1869
+ or name.
1870
+ PdkError: unknown_pdk for a missing store/id/revision.
1871
+ LayerServingError: unknown_stackup for an unmapped
1872
+ PDK, unknown_preset for a missing preset,
1873
+ invalid_preset for a stale user preset.
1874
+ """
1875
+ if not pdk_id:
1876
+ raise ProjectError(
1877
+ "invalid_command", "apply_layer_preset needs a pdk_id argument."
1878
+ )
1879
+ if not name:
1880
+ raise ProjectError(
1881
+ "invalid_command", "apply_layer_preset needs a name argument."
1882
+ )
1883
+ stackup = get_stackup(pdk_id, revision=revision, engine_root=engine_root)
1884
+ return self._layer_presets.apply(stackup, name)
1885
+
1886
+
1887
+ def log_text(self, run_id):
1888
+ """Return the captured native log for a run.
1889
+
1890
+ Args:
1891
+ run_id: Run whose native log to read.
1892
+
1893
+ Returns:
1894
+ Captured log text, or "" when the run logged nothing.
1895
+ """
1896
+ return self._logs.get(run_id, "")
1897
+
1898
+ def datasets(self, run_id):
1899
+ """List the waveform files a run wrote (e.g. ngspice rawfiles).
1900
+
1901
+ Args:
1902
+ run_id: Run whose waveform files to list.
1903
+
1904
+ Returns:
1905
+ List of {analysis, path} mappings; empty when the solver
1906
+ wrote none.
1907
+
1908
+ Caution: Paths point into the solver workdir, which the next
1909
+ run there overwrites; move them to keep them.
1910
+ """
1911
+ return list(self._datasets.get(run_id, []))
1912
+
1913
+ def spectra(self, run_id):
1914
+ """Return the transmission spectra an optical run computed.
1915
+
1916
+ Args:
1917
+ run_id: Run whose spectra to read.
1918
+
1919
+ Returns:
1920
+ List of {from, to, wavelength_nm[], transmission_db[]}
1921
+ mappings, one per target port pair; empty for other runs.
1922
+ """
1923
+ return list(self._spectra.get(run_id, []))
1924
+
1925
+ @staticmethod
1926
+ def confine_spice_arguments(arguments, project_dir):
1927
+ """Confine run_spice arguments (TRANSPORT-PARITY seam).
1928
+
1929
+ Args:
1930
+ arguments: Submitted run_spice arguments.
1931
+ project_dir: Project folder, or None when unbound.
1932
+
1933
+ Returns:
1934
+ Confined arguments (unchanged when unbound).
1935
+
1936
+ Raises:
1937
+ AdapterError: Invalid source or caller workdir.
1938
+ """
1939
+ return _confine_spice_arguments(arguments, project_dir)
1940
+
1941
+ def record_run_evidence(self, outcome, arguments, project_dir=None):
1942
+ """Persist one finished run (TRANSPORT-PARITY seam).
1943
+
1944
+ Args:
1945
+ outcome: Completion mapping with run_id, operation,
1946
+ and verdict.
1947
+ arguments: Submitted arguments for the source list.
1948
+ project_dir: Project folder; engine default when
1949
+ omitted.
1950
+
1951
+ Returns:
1952
+ Evidence path string, or None when unbound.
1953
+ """
1954
+ root = project_dir if project_dir is not None else self._project_dir
1955
+ return _record_run_evidence(
1956
+ outcome, arguments, root, self.log_text)
1957
+
1958
+ @staticmethod
1959
+ def _evidence_source_state(root):
1960
+ """Probe (commit, dirty) of the project tree.
1961
+
1962
+ Args:
1963
+ root: Project folder to probe.
1964
+
1965
+ Returns:
1966
+ (commit, dirty) pair, (None, None) outside git.
1967
+ """
1968
+ return _evidence_source_state(root)
1969
+
1970
+ def _maybe_record_evidence(self, operation, completion, arguments):
1971
+ """Record one completion when project-bound (seam).
1972
+
1973
+ Args:
1974
+ operation: Operation hint when the completion
1975
+ omits it.
1976
+ completion: Completion mapping, batch mapping,
1977
+ or ack.
1978
+ arguments: Submitted arguments for the source
1979
+ list.
1980
+
1981
+ Returns:
1982
+ Evidence path string, or None when skipped.
1983
+ """
1984
+ return _maybe_record_evidence(
1985
+ self._project_dir, self._run_jobs, self.log_text,
1986
+ operation, completion, arguments,
1987
+ )
1988
+
1989
+ def register_solver(self, operation, adapter, tool=None):
1990
+ """Register a solver adapter under an operation name.
1991
+
1992
+ Args:
1993
+ operation: Cataloged operation the adapter serves.
1994
+ adapter: ToolAdapter owning the preflight/execute/parse
1995
+ round trip.
1996
+ tool: Deck-tool key selecting this adapter; defaults to
1997
+ the adapter class name.
1998
+
1999
+ Returns:
2000
+ Mapping with the operation and tool key registered.
2001
+ """
2002
+ name = tool if tool is not None else type(adapter).__name__
2003
+ self._solvers.setdefault(operation, {})[name] = adapter
2004
+ return {"operation": operation, "tool": name}
2005
+
2006
+ def solvers_for(self, operation):
2007
+ """Return the tool-keyed solvers serving one operation.
2008
+
2009
+ Introspection for docs and MCP help: per-operation prose
2010
+ derives from the serving adapters' docstrings, so this map
2011
+ is the seam — never a second source. Deck-tool selection
2012
+ reads the keys; the default is the first registered.
2013
+
2014
+ Args:
2015
+ operation: Cataloged operation name.
2016
+
2017
+ Returns:
2018
+ Mapping of deck-tool key to serving adapter; empty
2019
+ when no solver is registered.
2020
+ """
2021
+ return dict(self._solvers.get(operation) or {})
2022
+
2023
+ def findings(self, run_id, limit=50, cursor=None):
2024
+ """Read a page of findings for a completed run.
2025
+
2026
+ In-memory verdicts answer first; across a reboot the
2027
+ completion event answers instead.
2028
+
2029
+ Args:
2030
+ run_id: Run whose findings to read.
2031
+ limit: Maximum findings in this page.
2032
+ cursor: Opaque position from a previous page, if any.
2033
+
2034
+ Returns:
2035
+ Finding page for the run.
2036
+
2037
+ Raises:
2038
+ ExecutionError: unknown_run for an id with no
2039
+ completion event.
2040
+ """
2041
+ try:
2042
+ return self._execution.findings(run_id, limit=limit, cursor=cursor)
2043
+ except ExecutionError:
2044
+ return self._ledger_findings(run_id, limit=limit, cursor=cursor)
2045
+
2046
+ def resolve_celllinks(self, blocks, known_cells, variant=None):
2047
+ """Resolve run/export cell-link records for block instances.
2048
+
2049
+ CELL-LINK seam: resolves (cell, pinMap, variant) per block
2050
+ through interfaces.celllink, emitting only non-empty links.
2051
+ Editing stays free — unknown cells fail here, at run time.
2052
+
2053
+ Args:
2054
+ blocks: Block mappings, each with an id and an optional
2055
+ CellLink component.
2056
+ known_cells: Available cell names the run may target.
2057
+ variant: Variant name applied to every block; None and
2058
+ REFERENCE resolve each stored base link.
2059
+
2060
+ Returns:
2061
+ Emission records in input order, each with block, cell,
2062
+ pinMap, and variant keys.
2063
+
2064
+ Raises:
2065
+ ContractError: invalid_block for a malformed block
2066
+ entry, unknown_variant for an undefined variant.
2067
+ CellLinkError: unknown_cell naming the block and cell
2068
+ when a resolved cell is unavailable to the run.
2069
+ """
2070
+ return resolve_celllink_run(blocks, known_cells, variant=variant)
2071
+
2072
+ def _run_queued(self, command):
2073
+ """Execute admitted requests by id: deferred singles, fetch, batches.
2074
+
2075
+ Submit already ran inline, so this is the explicit path:
2076
+ deferred execution for admissions drained elsewhere
2077
+ (crash survivors, staged backlog), idempotent fetch for
2078
+ finished runs, and ordered batch pumping.
2079
+
2080
+ Request keys:
2081
+ run_id: Admitted run to execute or fetch.
2082
+ request_id: Envelope id admitted by an earlier submit
2083
+ (resolves to its run when run_id is absent).
2084
+ run_ids: Batch form — admitted runs to pump oldest
2085
+ listed first.
2086
+ force: Batch form only — bypass drain-time verdict
2087
+ gates (hard deps still refuse).
2088
+
2089
+ Args:
2090
+ command: Envelope whose arguments carry run_id,
2091
+ request_id, or run_ids.
2092
+
2093
+ Returns:
2094
+ Completion mapping with run_id, state, verdict, and
2095
+ operation for a single id (stored, never
2096
+ re-executed, when the run already finished); batch
2097
+ mapping with run_ids, completions, refused, per-item
2098
+ states, and worst-state-wins summary for the batch
2099
+ form (every listed id lands in exactly one of
2100
+ completions/refused).
2101
+
2102
+ Raises:
2103
+ ProjectError: invalid_command when the arguments lack a
2104
+ run id or the batch list is malformed,
2105
+ unknown_request for an id never admitted,
2106
+ unsupported_operation when the admitted
2107
+ operation has no registered solver.
2108
+ """
2109
+ arguments = dict(command.get("arguments") or {})
2110
+ if arguments.get("run_ids") is not None:
2111
+ return self._run_batch(
2112
+ arguments.get("run_ids"),
2113
+ force=arguments.get("force") is True,
2114
+ )
2115
+ run_id = arguments.get("run_id")
2116
+ if run_id is not None:
2117
+ # Idempotent fetch: a finished run returns its stored
2118
+ # completion instead of executing again.
2119
+ stored = self._stored_completion(run_id)
2120
+ if stored is not None:
2121
+ return stored
2122
+ pending = self._run_jobs.get(run_id)
2123
+ if pending is None:
2124
+ # Reboot path: the admitting request survives in
2125
+ # the ledger even though this process never saw it.
2126
+ queued_request = request_for_run(
2127
+ self._project.events_since(0), run_id
2128
+ )
2129
+ if queued_request is None:
2130
+ raise ProjectError(
2131
+ "unknown_request", f"No queued request: {run_id}."
2132
+ ) from None
2133
+ stored_command, ack = self._project.command_for(
2134
+ queued_request
2135
+ )
2136
+ pending = {
2137
+ "operation": stored_command.get("operation"),
2138
+ "arguments": dict(stored_command.get("arguments") or {}),
2139
+ "snapshot_id": ack.get("snapshot_id", ""),
2140
+ "request_id": str(queued_request),
2141
+ }
2142
+ self._run_jobs[run_id] = pending
2143
+ stored_operation = pending["operation"]
2144
+ run_arguments = dict(pending["arguments"])
2145
+ snapshot_id = pending["snapshot_id"]
2146
+ request_id = pending["request_id"]
2147
+ else:
2148
+ request_id = arguments.get("request_id")
2149
+ if request_id is None:
2150
+ raise ProjectError(
2151
+ "invalid_command",
2152
+ "run_job needs a run_id, request_id, or run_ids argument.",
2153
+ )
2154
+ stored_command, ack = self._project.command_for(request_id)
2155
+ stored_operation = stored_command.get("operation")
2156
+ run_arguments = dict(stored_command.get("arguments") or {})
2157
+ snapshot_id = ack.get("snapshot_id", "")
2158
+ request_id = str(request_id)
2159
+ run_id = ack.get("run_id")
2160
+ completion = self._stored_completion(run_id)
2161
+ if completion is not None:
2162
+ return completion
2163
+ if stored_operation == "python_execute":
2164
+ return self._run_pyexec(run_id, request_id)
2165
+ if not self._solvers.get(stored_operation):
2166
+ raise ProjectError(
2167
+ "unsupported_operation", f"No solver registered: {stored_operation}."
2168
+ )
2169
+ if not self._execution.known(run_id):
2170
+ # Reboot path: the ledger admitted this run, but this
2171
+ # process holds no execution record for it yet.
2172
+ self._execution.dispatch(
2173
+ stored_operation,
2174
+ {"cell": run_arguments.get("cell")},
2175
+ snapshot_id,
2176
+ run_id=run_id,
2177
+ )
2178
+ # Submit-time admission data stays out of the solver job.
2179
+ # Targets were already filled at submit time; never re-fill
2180
+ # here (filled min/max would read as caller targets).
2181
+ run_arguments.pop("expected_revision", None)
2182
+ return self._run_operation(
2183
+ stored_operation,
2184
+ run_arguments,
2185
+ snapshot_id,
2186
+ request_id,
2187
+ run_id=run_id,
2188
+ contain=True,
2189
+ )
2190
+
2191
+
2192
+ def run(self, command):
2193
+ """Execute an operation synchronously through its solver.
2194
+
2195
+ `deck.tool` selects the registered solver, defaulting to
2196
+ the first registered; solver violations nest into findings
2197
+ and the verdict is judged from findings alone — exit zero
2198
+ is not PASS. The native exit code is recorded on the run,
2199
+ never the verdict.
2200
+ CRUD/rewind mutations delegate to submit's immediate path
2201
+ so the CLI `run` spelling shares the one implementation.
2202
+
2203
+ Args:
2204
+ command: Envelope with project_id, operation, and
2205
+ arguments (deck.tool selects the solver).
2206
+
2207
+ Returns:
2208
+ Completion mapping with run_id, state, verdict, and
2209
+ operation. Measured values ride on the verdict (PASS
2210
+ included), never only on failure. Adapter failures become not_run verdicts,
2211
+ never exceptions. Every judged run appends
2212
+ run_completed (PASS/FAIL) or run_failed (NOT_RUN) to
2213
+ the ledger with the engine's actor and the verdict.
2214
+ CRUD/rewind/place_cell operations return the immediate
2215
+ mutation mapping instead (new revision + events +
2216
+ approval-UI fields).
2217
+
2218
+ Raises:
2219
+ ProjectError: cross_project_denied for a foreign
2220
+ envelope, unsupported_operation for an unregistered
2221
+ operation or unknown deck tool, unknown_block for
2222
+ a block target naming no block, invalid_target for
2223
+ a malformed target, unsupported_scope for a block
2224
+ target on a verb serving no block runs yet,
2225
+ unknown_run_param for an unknown run.request field,
2226
+ invalid_run_param for a mistyped run.request knob,
2227
+ invalid_run_request for a non-mapping run.request,
2228
+ invalid_command when run_job lacks a
2229
+ run_id/request_id argument, for caller-supplied
2230
+ run_spice targets, when a CRUD
2231
+ mutation or place_cell is malformed, or a rewind
2232
+ lacks a target or targets the live head,
2233
+ revision_conflict for a stale CRUD/rewind/
2234
+ place_cell expected_revision,
2235
+ unknown_document for a missing CRUD document,
2236
+ unknown_revision for a rewind target with no
2237
+ snapshot.
2238
+
2239
+ Caution: Runs the solver now and records the verdict —
2240
+ exit zero is not PASS; read the verdict, never the
2241
+ exit path.
2242
+ """
2243
+ envelope = command.get("project_id")
2244
+ if envelope != self._project_id:
2245
+ raise ProjectError(
2246
+ "cross_project_denied",
2247
+ f"Bound to {self._project_id}, got {envelope}.",
2248
+ )
2249
+ self._replay_once()
2250
+ name = command.get("operation")
2251
+ if name == "run_job":
2252
+ completion = self._run_queued(command)
2253
+ self._maybe_record_evidence(name, completion,
2254
+ dict(command.get("arguments") or {}))
2255
+ return completion
2256
+ if name in _CRUD_OPERATIONS or name == "place_cell":
2257
+ return self.submit(command)
2258
+ if name == "python_execute":
2259
+ raise ProjectError(
2260
+ "invalid_command",
2261
+ "python_execute needs submit, human approval, then "
2262
+ "run_job; direct run cannot approve.",
2263
+ )
2264
+ arguments = self._apply_targets(name, dict(command.get("arguments") or {}))
2265
+ arguments = self._apply_run_request(arguments)
2266
+ if name == "run_spice":
2267
+ arguments = self.confine_spice_arguments(
2268
+ arguments, self._project_dir
2269
+ )
2270
+ block_scope = self._admit_block_scope(name, arguments)
2271
+ completion = self._run_operation(
2272
+ name,
2273
+ arguments,
2274
+ command.get("snapshot_id", ""),
2275
+ None,
2276
+ block_scope=block_scope,
2277
+ )
2278
+ self._maybe_record_evidence(name, completion, arguments)
2279
+ return completion