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.
- rocket_engine/__init__.py +8 -0
- rocket_engine/__main__.py +12 -0
- rocket_engine/adapters/README.md +54 -0
- rocket_engine/adapters/__init__.py +7 -0
- rocket_engine/adapters/api.py +39 -0
- rocket_engine/adapters/base.py +46 -0
- rocket_engine/adapters/cace/__init__.py +163 -0
- rocket_engine/adapters/circulax/README.md +10 -0
- rocket_engine/adapters/circulax/__init__.py +112 -0
- rocket_engine/adapters/circulax/_driver.py +190 -0
- rocket_engine/adapters/circulax/adapter.py +538 -0
- rocket_engine/adapters/circulax/results.py +291 -0
- rocket_engine/adapters/cli/README.md +34 -0
- rocket_engine/adapters/cli/__init__.py +305 -0
- rocket_engine/adapters/connectivity/__init__.py +196 -0
- rocket_engine/adapters/connectivity/results.py +214 -0
- rocket_engine/adapters/cosim/README.md +9 -0
- rocket_engine/adapters/cosim/__init__.py +1390 -0
- rocket_engine/adapters/cosim/results.py +214 -0
- rocket_engine/adapters/devsim/__init__.py +171 -0
- rocket_engine/adapters/digital_flow/README.md +9 -0
- rocket_engine/adapters/digital_flow/__init__.py +1121 -0
- rocket_engine/adapters/digital_flow/results.py +227 -0
- rocket_engine/adapters/digital_flow/state.py +462 -0
- rocket_engine/adapters/drc/README.klayout.md +7 -0
- rocket_engine/adapters/drc/README.md +7 -0
- rocket_engine/adapters/drc/__init__.py +1 -0
- rocket_engine/adapters/drc/klayout.py +952 -0
- rocket_engine/adapters/drc/magic.py +287 -0
- rocket_engine/adapters/drc/results.py +184 -0
- rocket_engine/adapters/drc/results_kl.py +188 -0
- rocket_engine/adapters/elmer/__init__.py +163 -0
- rocket_engine/adapters/envelope.py +120 -0
- rocket_engine/adapters/equivalence/__init__.py +156 -0
- rocket_engine/adapters/errors.py +181 -0
- rocket_engine/adapters/extract_magic/README.md +15 -0
- rocket_engine/adapters/extract_magic/__init__.py +425 -0
- rocket_engine/adapters/extract_magic/results.py +209 -0
- rocket_engine/adapters/formal_sby/README.md +7 -0
- rocket_engine/adapters/formal_sby/__init__.py +773 -0
- rocket_engine/adapters/formal_sby/formal_edge.py +195 -0
- rocket_engine/adapters/formal_sby/results.py +185 -0
- rocket_engine/adapters/gdsfactory/__init__.py +194 -0
- rocket_engine/adapters/gdsfactory/_driver.py +459 -0
- rocket_engine/adapters/gdsfactory/adapter.py +609 -0
- rocket_engine/adapters/gdsfactory/results.py +181 -0
- rocket_engine/adapters/hdl_sim/README.md +7 -0
- rocket_engine/adapters/hdl_sim/__init__.py +369 -0
- rocket_engine/adapters/hdl_sim/ghdl.py +155 -0
- rocket_engine/adapters/hdl_sim/results.py +178 -0
- rocket_engine/adapters/hdl_sim/verilator.py +167 -0
- rocket_engine/adapters/http/README.md +15 -0
- rocket_engine/adapters/http/__init__.py +458 -0
- rocket_engine/adapters/http/events.py +63 -0
- rocket_engine/adapters/ipc/README.md +9 -0
- rocket_engine/adapters/ipc/__init__.py +111 -0
- rocket_engine/adapters/lvs_netgen/README.md +20 -0
- rocket_engine/adapters/lvs_netgen/__init__.py +889 -0
- rocket_engine/adapters/lvs_netgen/results.py +168 -0
- rocket_engine/adapters/mcp/README.md +19 -0
- rocket_engine/adapters/mcp/__init__.py +85 -0
- rocket_engine/adapters/mcp/api-schema.json +6107 -0
- rocket_engine/adapters/mcp/pull.py +246 -0
- rocket_engine/adapters/mcp/server.py +794 -0
- rocket_engine/adapters/meep/__init__.py +167 -0
- rocket_engine/adapters/meow/README.md +7 -0
- rocket_engine/adapters/meow/__init__.py +190 -0
- rocket_engine/adapters/meow/_driver.py +227 -0
- rocket_engine/adapters/meow/_envelope.py +108 -0
- rocket_engine/adapters/meow/adapter.py +491 -0
- rocket_engine/adapters/meow/results.py +218 -0
- rocket_engine/adapters/mutation/__init__.py +192 -0
- rocket_engine/adapters/ngspice/README.md +28 -0
- rocket_engine/adapters/ngspice/__init__.py +627 -0
- rocket_engine/adapters/ngspice/results.py +215 -0
- rocket_engine/adapters/postlayout/README.md +28 -0
- rocket_engine/adapters/postlayout/__init__.py +17 -0
- rocket_engine/adapters/postlayout/adapter.py +1062 -0
- rocket_engine/adapters/postlayout/results.py +330 -0
- rocket_engine/adapters/postlayout/translate.py +71 -0
- rocket_engine/adapters/profile.py +220 -0
- rocket_engine/adapters/sax/README.md +23 -0
- rocket_engine/adapters/sax/__init__.py +516 -0
- rocket_engine/adapters/sax/_driver.py +148 -0
- rocket_engine/adapters/sax/results.py +291 -0
- rocket_engine/adapters/stdio_console.py +1151 -0
- rocket_engine/adapters/syntax/README.md +5 -0
- rocket_engine/adapters/syntax/__init__.py +366 -0
- rocket_engine/adapters/syntax/results.py +181 -0
- rocket_engine/adapters/synth_yosys/README.md +7 -0
- rocket_engine/adapters/synth_yosys/__init__.py +655 -0
- rocket_engine/adapters/synth_yosys/amaranth.py +120 -0
- rocket_engine/adapters/synth_yosys/ghdl.py +128 -0
- rocket_engine/adapters/synth_yosys/results.py +173 -0
- rocket_engine/adapters/timing_sta/README.md +9 -0
- rocket_engine/adapters/timing_sta/__init__.py +726 -0
- rocket_engine/adapters/view_sync/__init__.py +185 -0
- rocket_engine/adapters/view_sync/results.py +189 -0
- rocket_engine/adapters/vlsir/__init__.py +142 -0
- rocket_engine/adapters/xschem/__init__.py +440 -0
- rocket_engine/adapters/xschem/deck.py +266 -0
- rocket_engine/clients/__init__.py +5 -0
- rocket_engine/clients/runtime.py +255 -0
- rocket_engine/composition.py +2279 -0
- rocket_engine/execution/README.md +30 -0
- rocket_engine/execution/__init__.py +150 -0
- rocket_engine/execution/apig/__init__.py +1 -0
- rocket_engine/execution/apig/pool.py +463 -0
- rocket_engine/execution/apig/submit.py +124 -0
- rocket_engine/execution/block_runs_scope.py +479 -0
- rocket_engine/execution/coalesce.py +414 -0
- rocket_engine/execution/console/__init__.py +1 -0
- rocket_engine/execution/console/apply_session.py +318 -0
- rocket_engine/execution/console/lifecycle.py +265 -0
- rocket_engine/execution/dispatch.py +205 -0
- rocket_engine/execution/errors.py +31 -0
- rocket_engine/execution/ports.py +797 -0
- rocket_engine/execution/pyexec/__init__.py +1 -0
- rocket_engine/execution/pyexec/apply_context.py +547 -0
- rocket_engine/execution/pyexec/approvals.py +173 -0
- rocket_engine/execution/pyexec/runner.py +404 -0
- rocket_engine/execution/pyexec/support.py +464 -0
- rocket_engine/execution/python_exec.py +897 -0
- rocket_engine/execution/queue.py +85 -0
- rocket_engine/execution/replay_safe.py +601 -0
- rocket_engine/execution/routing/__init__.py +1 -0
- rocket_engine/execution/routing/calls.py +364 -0
- rocket_engine/execution/routing/namespace.py +785 -0
- rocket_engine/execution/routing/ports.py +102 -0
- rocket_engine/execution/routing/tools.py +346 -0
- rocket_engine/execution/runner.py +311 -0
- rocket_engine/execution/runtime.py +1149 -0
- rocket_engine/execution/service.py +279 -0
- rocket_engine/execution/worker.py +464 -0
- rocket_engine/interfaces/README.md +26 -0
- rocket_engine/interfaces/__init__.py +58 -0
- rocket_engine/interfaces/apig_assets.py +407 -0
- rocket_engine/interfaces/celllink.py +461 -0
- rocket_engine/interfaces/documents.py +92 -0
- rocket_engine/interfaces/envelopes.py +131 -0
- rocket_engine/interfaces/errors.py +16 -0
- rocket_engine/interfaces/layout.py +410 -0
- rocket_engine/interfaces/layout_schema.yaml +278 -0
- rocket_engine/interfaces/paths.py +223 -0
- rocket_engine/interfaces/pdks.py +550 -0
- rocket_engine/interfaces/ports.py +72 -0
- rocket_engine/interfaces/references.py +115 -0
- rocket_engine/interfaces/refs.py +85 -0
- rocket_engine/interfaces/runparams.py +304 -0
- rocket_engine/interfaces/setup.py +552 -0
- rocket_engine/interfaces/setup_catalogue.yaml +243 -0
- rocket_engine/interfaces/setup_probes.py +66 -0
- rocket_engine/interfaces/vocabulary.py +58 -0
- rocket_engine/interfaces/yaml_dump.py +31 -0
- rocket_engine/library/README.md +22 -0
- rocket_engine/library/__init__.py +13 -0
- rocket_engine/library/apig_catalog.py +371 -0
- rocket_engine/library/catalog.py +185 -0
- rocket_engine/library/errors.py +16 -0
- rocket_engine/mutate/__init__.py +776 -0
- rocket_engine/mutate/operators.py +700 -0
- rocket_engine/ops/__init__.py +1 -0
- rocket_engine/ops/deferred.py +279 -0
- rocket_engine/ops/editors.py +404 -0
- rocket_engine/ops/evidence.py +194 -0
- rocket_engine/ops/gates.py +262 -0
- rocket_engine/ops/ports.py +76 -0
- rocket_engine/ops/queries.py +417 -0
- rocket_engine/ops/runs.py +553 -0
- rocket_engine/ops/submit.py +614 -0
- rocket_engine/project/README.md +27 -0
- rocket_engine/project/__init__.py +6 -0
- rocket_engine/project/canvas/__init__.py +55 -0
- rocket_engine/project/canvas/compile.py +109 -0
- rocket_engine/project/canvas/integrate.py +48 -0
- rocket_engine/project/canvas/schema.py +241 -0
- rocket_engine/project/canvas/templates.py +46 -0
- rocket_engine/project/canvas/validate.py +603 -0
- rocket_engine/project/edit/__init__.py +5 -0
- rocket_engine/project/edit/cas.py +87 -0
- rocket_engine/project/edit/transactions.py +282 -0
- rocket_engine/project/errors.py +16 -0
- rocket_engine/project/frontend13.py +685 -0
- rocket_engine/project/geometry/__init__.py +23 -0
- rocket_engine/project/geometry/ops.py +451 -0
- rocket_engine/project/geometry/snap.py +103 -0
- rocket_engine/project/ip/__init__.py +1 -0
- rocket_engine/project/ip/blocks.py +195 -0
- rocket_engine/project/ip/entry.schema.json +78 -0
- rocket_engine/project/ip/lifecycle.py +402 -0
- rocket_engine/project/ip/verbs.py +123 -0
- rocket_engine/project/layers/__init__.py +6 -0
- rocket_engine/project/layers/bindings.py +115 -0
- rocket_engine/project/layers/catalog.py +228 -0
- rocket_engine/project/layers/presets.py +43 -0
- rocket_engine/project/ledger/__init__.py +1 -0
- rocket_engine/project/ledger/docs.py +167 -0
- rocket_engine/project/ledger/entities.py +176 -0
- rocket_engine/project/ledger/events.py +29 -0
- rocket_engine/project/ledger/runs.py +257 -0
- rocket_engine/project/ledger/shared.py +174 -0
- rocket_engine/project/ledger/snapshots.py +385 -0
- rocket_engine/project/ledger/wires.py +552 -0
- rocket_engine/project/process/__init__.py +14 -0
- rocket_engine/project/process/model.py +193 -0
- rocket_engine/project/process/recipes.py +121 -0
- rocket_engine/project/schematic/__init__.py +1 -0
- rocket_engine/project/schematic/hierarchy.py +312 -0
- rocket_engine/project/schematic/instances.py +393 -0
- rocket_engine/project/schematic/sheets.py +198 -0
- rocket_engine/project/service.py +2941 -0
- rocket_engine/project/symbols/__init__.py +1 -0
- rocket_engine/project/symbols/cells.py +324 -0
- rocket_engine/project/symbols/linkage.py +356 -0
- rocket_engine/project/symbols/symbols.py +352 -0
- rocket_engine/project/templates/__init__.py +303 -0
- rocket_engine/project/templates/errors.py +25 -0
- rocket_engine/project/testbench/__init__.py +49 -0
- rocket_engine/project/testbench/blocks.py +619 -0
- rocket_engine/project/testbench/templates.py +134 -0
- rocket_engine/stores/README.md +12 -0
- rocket_engine/stores/__init__.py +6 -0
- rocket_engine/stores/ecs.py +1508 -0
- rocket_engine/stores/layers.py +1023 -0
- rocket_engine/stores/settings.py +1188 -0
- rocket_engine/stores/wireplace.py +1324 -0
- rocket_engine/workflows/README.md +18 -0
- rocket_engine/workflows/__init__.py +53 -0
- rocket_engine/workflows/capability_plan.py +102 -0
- rocket_engine/workflows/completion.py +228 -0
- rocket_engine/workflows/connectgraph.py +734 -0
- rocket_engine/workflows/connectgraph_emit.py +336 -0
- rocket_engine/workflows/gates.py +144 -0
- rocket_engine/workflows/photonic_wire.py +76 -0
- rocket_engine/workflows/stages.py +324 -0
- rocket_engine/workflows/viewsync.py +399 -0
- rocket_engine-0.0.2b1.dist-info/METADATA +200 -0
- rocket_engine-0.0.2b1.dist-info/RECORD +241 -0
- rocket_engine-0.0.2b1.dist-info/WHEEL +4 -0
- rocket_engine-0.0.2b1.dist-info/entry_points.txt +2 -0
- 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).
|