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,458 @@
1
+ """HTTP transport (duplex): JSON requests in, results and events out.
2
+
3
+ Loopback-only trust boundary (ADR-006): the listener binds the
4
+ loopback interface only and refuses any non-loopback bind loudly.
5
+ There is no auth — the loopback interface is the trust boundary —
6
+ and the API is the same caller-blind surface every transport serves.
7
+
8
+ The listener is a small FastAPI app on uvicorn: POST a JSON envelope
9
+ to `/`, GET `/health` for liveness, GET `/events` for the runtime
10
+ lifecycle log as Server-Sent Events (PUSH-EVENTS; needs a bound
11
+ runtime, otherwise it refuses loudly and polling stays the path).
12
+ Hardening: JSON content-type required, 1 MiB body cap,
13
+ loopback-or-absent Origin only, bounded concurrency, per-request
14
+ audit log.
15
+ """
16
+
17
+ import asyncio
18
+ import ipaddress
19
+ import json
20
+ import logging
21
+ import queue
22
+ import threading
23
+ import time
24
+ import urllib.parse
25
+ import uuid
26
+
27
+ from fastapi import FastAPI, Request
28
+ from fastapi.responses import JSONResponse, StreamingResponse
29
+ from starlette.exceptions import HTTPException as StarletteHTTPException
30
+ import uvicorn
31
+
32
+ from .events import EventSource, format_frame
33
+
34
+ from ...clients.runtime import RuntimeClient
35
+ from .. import api
36
+ from ..base import enforce_scope, parse_json
37
+ from ..errors import (
38
+ AdapterError,
39
+ error_payload,
40
+ retry_after_for,
41
+ status_for_code,
42
+ )
43
+
44
+ LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
45
+
46
+ #: Largest accepted POST body; larger reads refuse with 413.
47
+ MAX_BODY_BYTES = 1024 * 1024
48
+
49
+ #: Concurrent in-flight requests; overflow refuses with 503.
50
+ MAX_CONCURRENT = 32
51
+
52
+ #: Backoff advertised with 429/503 refusals.
53
+ RETRY_AFTER_SECONDS = 5
54
+
55
+ _LOG = logging.getLogger("rocket_engine.http")
56
+
57
+
58
+ class HttpTransport:
59
+ """HTTP transport over an engine: requests in, results out.
60
+
61
+ Args:
62
+ engine: Engine the requests submit against.
63
+ project_id: Optional scope; requests for another project
64
+ are denied, never routed.
65
+ """
66
+
67
+ def __init__(self, engine, project_id=None):
68
+ self._engine = engine
69
+ self._project_id = project_id
70
+
71
+ def handle(self, text):
72
+ """Route one JSON request through the engine.
73
+
74
+ Args:
75
+ text: Raw JSON request with project_id, operation,
76
+ arguments, and an optional request_id.
77
+
78
+ Returns:
79
+ Engine admission ack or query result for the request.
80
+
81
+ Raises:
82
+ AdapterError: invalid_command for a missing
83
+ project_id, cross_project_denied for a request
84
+ outside the bound scope.
85
+ """
86
+ request = parse_json(text)
87
+ if request.get("project_id") is None:
88
+ raise AdapterError("invalid_command", "Missing project_id in request.")
89
+ enforce_scope(request.get("project_id"), self._project_id)
90
+ return api.submit(
91
+ self._engine,
92
+ request["project_id"],
93
+ request["operation"],
94
+ request.get("arguments", {}),
95
+ request_id=str(request.get("request_id") or uuid.uuid4().hex),
96
+ )
97
+
98
+ def runtime_client(self):
99
+ """Serve this transport's engine through the shared client.
100
+
101
+ The POST/GET-health path is untouched — this binds the
102
+ same engine's integrated runtime (deferred submit, pump,
103
+ wait, cancel, watch) so HTTP clients share one run
104
+ identity with every other transport.
105
+
106
+ Returns:
107
+ RuntimeClient bound to this transport's engine.
108
+ """
109
+ return RuntimeClient(self._engine)
110
+
111
+
112
+ def check_loopback_host(host):
113
+ """Accept loopback bind hosts; refuse everything else loudly.
114
+
115
+ Args:
116
+ host: Requested bind host.
117
+
118
+ Returns:
119
+ The host unchanged when it is loopback.
120
+
121
+ Raises:
122
+ AdapterError: non_loopback_bind_denied when the host is not
123
+ loopback — the adapter is localhost-only with no auth.
124
+ """
125
+ if host in LOOPBACK_HOSTS:
126
+ return host
127
+ try:
128
+ loopback = ipaddress.ip_address(host).is_loopback
129
+ except ValueError:
130
+ loopback = False
131
+ if loopback:
132
+ return host
133
+ raise AdapterError(
134
+ "non_loopback_bind_denied",
135
+ f"Refused non-loopback HTTP bind {host!r}: the engine HTTP "
136
+ "adapter is localhost-only with no auth (ADR-006) — the trust "
137
+ "boundary is the loopback interface. Bind 127.0.0.1 instead; "
138
+ "remote access needs a separate authenticated adapter.",
139
+ )
140
+
141
+
142
+ def trust_banner(host, port):
143
+ """Startup banner stating the localhost-only trust model.
144
+
145
+ Args:
146
+ host: Bound loopback host.
147
+ port: Bound port.
148
+
149
+ Returns:
150
+ Banner text: localhost-only, no auth, caller-blind API.
151
+ """
152
+ return (
153
+ "rocket-engine HTTP adapter — localhost-only, no auth (ADR-006).\n"
154
+ f"Listening on http://{host}:{port} — the trust boundary is the "
155
+ "loopback interface.\n"
156
+ "Serves the same caller-blind API as every other transport; "
157
+ "remote/multi-user access needs a separate authenticated adapter, "
158
+ "never this one with a flag."
159
+ )
160
+
161
+
162
+ def _is_loopback_origin(origin):
163
+ """Accept a missing Origin or a loopback one; deny the rest.
164
+
165
+ Browsers attach Origin on cross-origin POSTs, so a foreign
166
+ Origin is a CSRF-shaped request and fails closed. Malformed
167
+ origins fail closed too.
168
+
169
+ Args:
170
+ origin: Origin header value, or None when absent.
171
+
172
+ Returns:
173
+ True for absent/malformed-free loopback origins only.
174
+ """
175
+ if not origin:
176
+ return True
177
+ try:
178
+ host = urllib.parse.urlparse(origin).hostname or ""
179
+ except ValueError:
180
+ return False
181
+ if host == "localhost":
182
+ return True
183
+ try:
184
+ return ipaddress.ip_address(host).is_loopback
185
+ except ValueError:
186
+ return False
187
+
188
+
189
+ def _refusal(code, message, headers=None):
190
+ """JSON refusal with the tabled status for one v0 code.
191
+
192
+ Args:
193
+ code: Stable v0 error code.
194
+ message: Human-readable detail.
195
+ headers: Extra headers (Retry-After where owed).
196
+
197
+ Returns:
198
+ JSON response at the code's tabled status.
199
+ """
200
+ return JSONResponse(status_code=status_for_code(code),
201
+ content={"code": code, "message": message},
202
+ headers=headers)
203
+
204
+
205
+ def create_app(transport, runtime=None):
206
+ """Build the loopback API app around one bound transport.
207
+
208
+ Thin routes over the transport: POST `/` submits one JSON
209
+ envelope, GET `/health` answers liveness, GET `/events`
210
+ streams the runtime lifecycle log as Server-Sent Events
211
+ (PUSH-EVENTS addition: replay since `cursor`, then live
212
+ frames). No docs routes — the engine-api reference is the
213
+ contract, not OpenAPI pages.
214
+
215
+ Args:
216
+ transport: HttpTransport the routes submit against.
217
+ runtime: EngineRuntime the event stream replays and
218
+ subscribes; None refuses `/events` loudly (polling
219
+ stays the watch path).
220
+
221
+ Returns:
222
+ Configured FastAPI app (unserved; see create_server).
223
+
224
+ Raises:
225
+ ValueError: never escapes — a bad event cursor raises
226
+ inside the `/events` handler and is answered as an
227
+ invalid_command refusal.
228
+ """
229
+ app = FastAPI(openapi_url=None, docs_url=None, redoc_url=None)
230
+ slots = threading.Semaphore(MAX_CONCURRENT)
231
+
232
+ @app.middleware("http")
233
+ async def _audit(request, call_next):
234
+ response = await call_next(request)
235
+ _LOG.info("%s %s -> %s", request.method,
236
+ request.url.path, response.status_code)
237
+ return response
238
+
239
+ @app.exception_handler(StarletteHTTPException)
240
+ async def _http_error(_request, exc):
241
+ if exc.status_code == 404:
242
+ return _refusal("not_found", "No route for this request.")
243
+ if exc.status_code == 405:
244
+ return _refusal("method_not_allowed",
245
+ "Only POST / and GET /health exist.")
246
+ return _refusal("internal_error", "Internal engine error.")
247
+
248
+ @app.post("/")
249
+ async def _rpc(request: Request):
250
+ if not _is_loopback_origin(request.headers.get("origin")):
251
+ _LOG.warning("POST / -> 403 cross_origin_denied")
252
+ return _refusal("cross_origin_denied",
253
+ "Foreign Origin denied on loopback HTTP.")
254
+ media = request.headers.get("content-type", "").split(";")[0]
255
+ if media.strip().lower() != "application/json":
256
+ return _refusal("unsupported_media_type",
257
+ "POST / needs Content-Type: application/json.")
258
+ try:
259
+ declared = int(request.headers.get("content-length") or 0)
260
+ except ValueError:
261
+ declared = 0
262
+ if declared > MAX_BODY_BYTES:
263
+ return _refusal("body_too_large",
264
+ f"Bodies over {MAX_BODY_BYTES} bytes refused.")
265
+ body = await request.body()
266
+ if len(body) > MAX_BODY_BYTES:
267
+ return _refusal("body_too_large",
268
+ f"Bodies over {MAX_BODY_BYTES} bytes refused.")
269
+ if not slots.acquire(blocking=False):
270
+ _LOG.warning("POST / -> 503 server_busy")
271
+ return _refusal("server_busy", "Server saturated; back off.",
272
+ headers={"Retry-After":
273
+ str(RETRY_AFTER_SECONDS)})
274
+ try:
275
+ result = transport.handle(body.decode("utf-8", "replace"))
276
+ except Exception as exc: # coded errors stay data, never 500s
277
+ payload = error_payload(exc)
278
+ code = payload["code"]
279
+ if code == "internal_error" and getattr(exc, "code", None) is None:
280
+ _LOG.exception("POST / -> 500 uncoded crash")
281
+ else:
282
+ _LOG.info("POST / -> %s %s", status_for_code(code), code)
283
+ headers = {}
284
+ retry = retry_after_for(code, RETRY_AFTER_SECONDS)
285
+ if retry is not None:
286
+ headers["Retry-After"] = retry
287
+ return JSONResponse(status_code=status_for_code(code),
288
+ content=payload, headers=headers or None)
289
+ finally:
290
+ slots.release()
291
+ return JSONResponse(status_code=200, content=result)
292
+
293
+ @app.get("/health")
294
+ async def _health():
295
+ return {"status": "ok"}
296
+
297
+ @app.get("/events")
298
+ async def _events(request: Request):
299
+ if not _is_loopback_origin(request.headers.get("origin")):
300
+ _LOG.warning("GET /events -> 403 cross_origin_denied")
301
+ return _refusal("cross_origin_denied",
302
+ "Foreign Origin denied on loopback HTTP.")
303
+ if runtime is None:
304
+ return _refusal("runner_unavailable",
305
+ "No runtime bound to the HTTP listener: "
306
+ "poll get_events_since instead.")
307
+ raw = request.query_params.get("cursor")
308
+ if raw is None:
309
+ raw = request.headers.get("last-event-id", "0")
310
+ try:
311
+ cursor = int(raw)
312
+ if cursor < 0:
313
+ raise ValueError(raw)
314
+ except (TypeError, ValueError):
315
+ return _refusal("invalid_command",
316
+ f"Bad event cursor: {raw!r}.")
317
+ source = EventSource(runtime)
318
+ try:
319
+ page = source.since(cursor)
320
+ except Exception as exc: # coded errors stay data, never 500s
321
+ payload = error_payload(exc)
322
+ return JSONResponse(status_code=status_for_code(payload["code"]),
323
+ content=payload)
324
+ pending: "queue.Queue[str]" = queue.Queue()
325
+ unsubscribe = source.subscribe(pending.put)
326
+
327
+ async def _stream():
328
+ try:
329
+ for event in page["events"]:
330
+ yield format_frame(event)
331
+ idle = 0
332
+ while True:
333
+ try:
334
+ yield pending.get_nowait()
335
+ idle = 0
336
+ except queue.Empty:
337
+ idle += 1
338
+ if idle >= 300:
339
+ yield ": ping\n\n"
340
+ idle = 0
341
+ await asyncio.sleep(0.05)
342
+ finally:
343
+ unsubscribe()
344
+
345
+ return StreamingResponse(
346
+ _stream(),
347
+ media_type="text/event-stream",
348
+ headers={"Cache-Control": "no-cache",
349
+ "X-Accel-Buffering": "no"},
350
+ )
351
+
352
+ return app
353
+
354
+
355
+ class _Serving:
356
+ """Uvicorn listener with the stdlib server's duck shape.
357
+
358
+ Args:
359
+ app: FastAPI app to serve.
360
+ host: Loopback bind host (already guarded).
361
+ port: Bind port; 0 picks an ephemeral port.
362
+ """
363
+
364
+ def __init__(self, app, host, port):
365
+ self._host = host
366
+ self._port = port
367
+ self._server = uvicorn.Server(uvicorn.Config(
368
+ app, host=host, port=port, log_level="warning"))
369
+
370
+ @property
371
+ def server_address(self):
372
+ """(host, port): the live port once bound, else configured.
373
+
374
+ Returns:
375
+ Host plus the bound port, or the configured port before
376
+ the listener comes up.
377
+ """
378
+ servers = getattr(self._server, "servers", None) or []
379
+ sockets = getattr(servers[0], "sockets", None) if servers else None
380
+ if sockets:
381
+ return (self._host, sockets[0].getsockname()[1])
382
+ return (self._host, self._port)
383
+
384
+ def wait_until_ready(self, timeout=10):
385
+ """Block until the listener accepts, or raise on timeout.
386
+
387
+ Args:
388
+ timeout: Seconds to wait for the bound socket.
389
+
390
+ Raises:
391
+ TimeoutError: the listener never came up in budget.
392
+ """
393
+ deadline = time.time() + timeout
394
+ while not getattr(self._server, "started", False):
395
+ if time.time() > deadline:
396
+ raise TimeoutError("HTTP listener never became ready.")
397
+ time.sleep(0.01)
398
+
399
+ def serve_forever(self):
400
+ """Serve until shutdown (blocking, like serve_forever)."""
401
+ self._server.run()
402
+
403
+ def shutdown(self):
404
+ """Ask the serving loop to exit."""
405
+ self._server.should_exit = True
406
+
407
+ def server_close(self):
408
+ """Release the listener (uvicorn cleans up on exit)."""
409
+
410
+
411
+ def create_server(engine, host="127.0.0.1", port=0, project_id=None,
412
+ runtime=None):
413
+ """Bind a loopback-only HTTP server around an engine.
414
+
415
+ Args:
416
+ engine: Engine the requests submit against.
417
+ host: Bind host; must be loopback (default 127.0.0.1).
418
+ port: Bind port; 0 picks an ephemeral port.
419
+ project_id: Optional scope; requests for another project
420
+ are denied, never routed.
421
+ runtime: EngineRuntime feeding `/events`; None refuses the
422
+ stream loudly (polling stays the watch path).
423
+
424
+ Returns:
425
+ Unserved _Serving listener (serve_forever/shutdown shape).
426
+
427
+ Raises:
428
+ AdapterError: non_loopback_bind_denied for a non-loopback host.
429
+ """
430
+ check_loopback_host(host)
431
+ transport = HttpTransport(engine, project_id=project_id)
432
+ return _Serving(create_app(transport, runtime=runtime), host, port)
433
+
434
+
435
+ def serve_http(engine, host="127.0.0.1", port=8000, project_id=None,
436
+ runtime=None):
437
+ """Serve the caller-blind API over loopback HTTP until interrupted.
438
+
439
+ Args:
440
+ engine: Engine the requests submit against.
441
+ host: Bind host; must be loopback (default 127.0.0.1).
442
+ port: Bind port.
443
+ project_id: Optional scope; requests for another project
444
+ are denied, never routed.
445
+ runtime: EngineRuntime feeding `/events`; None refuses the
446
+ stream loudly (polling stays the watch path).
447
+
448
+ Raises:
449
+ AdapterError: non_loopback_bind_denied for a non-loopback host.
450
+
451
+ Caution: No auth by design (ADR-006) — the loopback interface is
452
+ the only trust boundary. Never expose this listener remotely.
453
+ """
454
+ server = create_server(engine, host=host, port=port,
455
+ project_id=project_id, runtime=runtime)
456
+ bound_host, bound_port = server.server_address[0], server.server_address[1]
457
+ print(trust_banner(bound_host, bound_port), flush=True)
458
+ server.serve_forever()
@@ -0,0 +1,63 @@
1
+ """HTTP SSE frames over the runtime lifecycle log (PUSH-EVENTS).
2
+
3
+ Append-only seam: the POST/GET-health path is untouched — this
4
+ only translates the same lifecycle log the MCP notifier and the
5
+ IPC stream push into Server-Sent Events frames, so browsers and
6
+ HTTP clients can subscribe instead of polling get_events_since.
7
+ """
8
+
9
+ import json
10
+
11
+
12
+ def format_frame(event):
13
+ """Render one lifecycle event as an SSE frame.
14
+
15
+ Args:
16
+ event: Lifecycle event with seq, kind, and run_id.
17
+
18
+ Returns:
19
+ SSE text with the event id, kind, and JSON data,
20
+ terminated by a blank line.
21
+ """
22
+ seq = event.get("seq", 0)
23
+ kind = event.get("kind", "message")
24
+ return f"id: {seq}\nevent: {kind}\ndata: {json.dumps(dict(event))}\n\n"
25
+
26
+
27
+ class EventSource:
28
+ """SSE source over a runtime (mirrors the IPC EventStream).
29
+
30
+ Addition-only seam: wraps the runtime lifecycle log, so HTTP
31
+ clients replay missed events then receive live frames.
32
+
33
+ Args:
34
+ runtime: EngineRuntime emitting lifecycle events.
35
+ """
36
+
37
+ def __init__(self, runtime):
38
+ self._runtime = runtime
39
+
40
+ def since(self, cursor):
41
+ """Replay lifecycle events after a cursor.
42
+
43
+ Args:
44
+ cursor: Opaque position from a previous read; 0
45
+ replays the retained log.
46
+
47
+ Returns:
48
+ Events appended after the cursor, oldest first,
49
+ plus the current cursor.
50
+ """
51
+ return self._runtime.watch(cursor)
52
+
53
+ def subscribe(self, send):
54
+ """Push every lifecycle event to one subscriber as frames.
55
+
56
+ Args:
57
+ send: Called synchronously with each SSE frame text.
58
+
59
+ Returns:
60
+ Unsubscribe callable removing the listener.
61
+ """
62
+ return self._runtime.add_listener(
63
+ lambda event: send(format_frame(event)))
@@ -0,0 +1,9 @@
1
+ # adapters/ipc — JSON command handler for desktop IPC
2
+
3
+ `IpcTransport.handle(text)` accepts one JSON envelope with `project_id`, `operation`, optional `arguments`, and optional `request_id`. It enforces an optional project binding and delegates the call to `Engine.submit`.
4
+
5
+ The current class does not implement the planned length-prefixed framing, persistent Electron child process, reconnect handling, or pushed event stream. Clients can read ordered changes through `get_events_since`. Keep desktop process lifecycle and renderer code outside the engine package.
6
+
7
+ ## Boundaries
8
+
9
+ This adapter owns JSON envelope parsing, request IDs, and project-scope checks. It does not own project logic or solver behavior. Shared shapes live in [`interfaces/`](../../interfaces/README.md); the transport overview is in [`adapters/`](../README.md).
@@ -0,0 +1,111 @@
1
+ """Electron stdio transport (request/response + get_events_since polling, NOT push — see PUSH-EVENTS): envelopes in, replies out.
2
+
3
+ Framing (length-prefix) arrives with the Electron integration; this
4
+ slice speaks bare JSON envelopes with the same command shape.
5
+ """
6
+
7
+ import uuid
8
+
9
+ from ...clients.runtime import RuntimeClient
10
+ from .. import api
11
+ from ..base import enforce_scope, parse_json
12
+ from ..errors import AdapterError
13
+
14
+
15
+ class IpcTransport:
16
+ """Electron stdio transport over an engine: envelopes in, events out.
17
+
18
+ Args:
19
+ engine: Engine the envelopes submit against.
20
+ project_id: Optional scope; frames for another project are
21
+ denied, never routed.
22
+ """
23
+
24
+ def __init__(self, engine, project_id=None):
25
+ self._engine = engine
26
+ self._project_id = project_id
27
+
28
+ def handle(self, text):
29
+ """Route one JSON envelope through the engine.
30
+
31
+ Args:
32
+ text: Raw JSON frame with project_id, operation,
33
+ arguments, and an optional request_id.
34
+
35
+ Returns:
36
+ Engine admission ack or query result for the frame.
37
+
38
+ Raises:
39
+ AdapterError: invalid_command for a missing
40
+ project_id, cross_project_denied for a frame
41
+ outside the bound scope.
42
+ """
43
+ frame = parse_json(text)
44
+ if frame.get("project_id") is None:
45
+ raise AdapterError("invalid_command", "Missing project_id in frame.")
46
+ enforce_scope(frame.get("project_id"), self._project_id)
47
+ return api.submit(
48
+ self._engine,
49
+ frame["project_id"],
50
+ frame["operation"],
51
+ frame.get("arguments", {}),
52
+ request_id=str(frame.get("request_id") or uuid.uuid4().hex),
53
+ )
54
+
55
+ def runtime_client(self):
56
+ """Serve this transport's engine through the shared client.
57
+
58
+ The envelope path is untouched — this binds the same
59
+ engine's integrated runtime (deferred submit, pump,
60
+ wait, cancel, watch) so IPC clients share one run
61
+ identity with every other transport.
62
+
63
+ Returns:
64
+ RuntimeClient bound to this transport's engine.
65
+ """
66
+ return RuntimeClient(self._engine)
67
+
68
+
69
+ class EventStream:
70
+ """Push event stream over a runtime (PUSH-EVENTS addition).
71
+
72
+ Addition-only seam: IpcTransport is untouched — this wraps
73
+ the same lifecycle log the MCP notifier pushes, so framed
74
+ stdio clients can subscribe instead of polling
75
+ get_events_since.
76
+
77
+ Args:
78
+ runtime: EngineRuntime emitting lifecycle events.
79
+ """
80
+
81
+ def __init__(self, runtime):
82
+ self._runtime = runtime
83
+
84
+ def since(self, cursor):
85
+ """Read ordered lifecycle events after a cursor.
86
+
87
+ Args:
88
+ cursor: Opaque position from a previous read; 0
89
+ replays the retained log.
90
+
91
+ Returns:
92
+ Events appended after the cursor, oldest first,
93
+ plus the current cursor.
94
+
95
+ Raises:
96
+ ExecutionError: cursor_expired when the cursor is
97
+ behind the retained floor or past the head.
98
+ """
99
+ return self._runtime.watch(cursor)
100
+
101
+ def subscribe(self, send):
102
+ """Push every lifecycle event to one subscriber.
103
+
104
+ Args:
105
+ send: Called synchronously with each emitted
106
+ event mapping.
107
+
108
+ Returns:
109
+ Unsubscribe callable removing the listener.
110
+ """
111
+ return self._runtime.add_listener(send)
@@ -0,0 +1,20 @@
1
+ # adapters/lvs_netgen — Netgen LVS solver
2
+
3
+ Batch LVS behind the `ToolAdapter` port: preflight refuses missing
4
+ binaries, netlists, and setup files; execute flattens both sides to
5
+ top level (netgen's one-shot `lvs` compares top circuits only) and
6
+ runs `netgen -batch lvs` with DISPLAY scrubbed; parse maps `match
7
+ uniquely` to no violations and `do not match` to named
8
+ `lvs_net_mismatch` / `lvs_device_mismatch` violations.
9
+
10
+ Verified against netgen 1.5.316 + sky130A_setup.tcl on
11
+ `sky130_fd_sc_hd__inv_2`: extracted layout matches the PDK
12
+ schematic; a single-net edit fails with the edited net named.
13
+ Netgen exits 0 on match and mismatch alike, so the verdict comes
14
+ from parsed output, never the exit code.
15
+
16
+ Status: EXTRACT-SKY130 GREEN. See `docs/probe-extract-lvs.md`.
17
+ Status: LVS-ESCAPE GREEN (net-name inventory tripwire). See
18
+ `docs/tickets/LVS-ESCAPE.md`.
19
+ There is deliberately no KLayout LVS solver: `sky130.lvs` fails on
20
+ the same known-good cell (case bug, evidence in the probe note).