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,414 @@
1
+ """Request coalescing (COALESCE-I33): identical concurrent runs execute once.
2
+
3
+ Concurrent run-submits with identical normalized content — operation
4
+ plus arguments including the deck hash plus run.request — against
5
+ the SAME project revision execute once: all callers share the run_id
6
+ and the fanned-out result, and every request_id is logged mapping to
7
+ it. Any revision move between requests forces separate executions,
8
+ because a stale verdict must never serve as fresh; ledger law (every
9
+ mutation advances the revision) makes the revision pin the whole
10
+ no-intervening-mutation check, with no second mechanism to drift.
11
+
12
+ Coalescing covers overlapping lifetime (queued/running) only:
13
+ completing a run evicts its entry, so later duplicates execute fresh
14
+ while completed results keep serving through idempotent fetch.
15
+ Forced re-runs bypass the lane entirely — the wiring never looks
16
+ them up and never registers them, so they neither join nor serve.
17
+ Direct `run()` calls (no request_id, no ledger admission) likewise
18
+ never touch the lane.
19
+
20
+ Threading: one Engine shares one Coalescer across submit threads.
21
+ `guard` serializes the lookup-admit-register admission section so
22
+ two identical submits cannot both miss; waiters park on per-entry
23
+ events outside the guard, so a 20-minute owner never blocks
24
+ admission. The guard is a reentrant leaf lock: entry methods take it
25
+ themselves and may be called while the wiring holds it, but only
26
+ short dict operations ever run under it — never event waits,
27
+ drains, or completion callbacks — so admission sections stay brief
28
+ and cannot deadlock.
29
+ """
30
+
31
+ import copy
32
+ import hashlib
33
+ import json
34
+ import threading
35
+ from collections.abc import Mapping
36
+
37
+ try:
38
+ from .errors import ExecutionError
39
+ except ImportError: # standalone verification before install
40
+ from rocket_engine.execution.errors import ExecutionError
41
+
42
+ COALESCE_VERSION = 1
43
+
44
+ # Envelope metadata, never content: two callers submitting identical
45
+ # work with different gate tokens or envelope ids still coalesce.
46
+ _ENVELOPE_KEYS = frozenset({"request_id", "expected_revision"})
47
+
48
+
49
+ def _sort_key(frozen):
50
+ """Ordering key for frozen values: their canonical JSON bytes."""
51
+ return json.dumps(frozen, sort_keys=True, separators=(",", ":"))
52
+
53
+
54
+ def _freeze(value):
55
+ """Freeze any value into JSON-safe canonical parts.
56
+
57
+ Mappings sort by frozen key (keys keep their types, so `1` and
58
+ `"1"` never collide); sequences keep order but keep their kind
59
+ (tuple and list stay distinct); sets sort; bytes hex-encode; and
60
+ anything else pins its type name beside its string, so `str()`
61
+ collisions across types cannot coalesce distinct content.
62
+ """
63
+ if value is None or isinstance(value, (bool, int, float, str)):
64
+ return value
65
+ if isinstance(value, Mapping):
66
+ frozen = [(_freeze(key), _freeze(val)) for key, val in value.items()]
67
+ frozen.sort(key=lambda pair: _sort_key(pair[0]))
68
+ return {"$map": frozen}
69
+ if isinstance(value, (list, tuple)):
70
+ items = [_freeze(item) for item in value]
71
+ if isinstance(value, tuple):
72
+ return {"$tuple": items}
73
+ return items
74
+ if isinstance(value, (set, frozenset)):
75
+ return {"$set": sorted((_freeze(item) for item in value), key=_sort_key)}
76
+ if isinstance(value, (bytes, bytearray)):
77
+ return {"$bytes": bytes(value).hex()}
78
+ return {"$repr": [type(value).__name__, str(value)]}
79
+
80
+
81
+ def _canonical(value):
82
+ """Canonical JSON text for frozen-safe values (see `_freeze`)."""
83
+ return json.dumps(
84
+ _freeze(value), sort_keys=True, separators=(",", ":"),
85
+ ensure_ascii=True,
86
+ )
87
+
88
+
89
+ def deck_hash(deck):
90
+ """Hash a deck argument for content addressing.
91
+
92
+ The hash is key-order stable and content-addressed: equal decks
93
+ hash equally whatever their key order, and any content change
94
+ breaks the hash — so a deck edit between submits forces a
95
+ separate execution.
96
+
97
+ Args:
98
+ deck: Deck argument as submitted (usually a mapping with
99
+ a tool key; any value hashes).
100
+
101
+ Returns:
102
+ Hex sha256 over the canonical encoding of the deck.
103
+ """
104
+ return hashlib.sha256(_canonical(deck).encode("utf-8")).hexdigest()
105
+
106
+
107
+ def normalize_arguments(arguments):
108
+ """Normalize submitted arguments for content-key comparison.
109
+
110
+ Envelope keys (`request_id`, `expected_revision`) are dropped —
111
+ they steer admission, they are not work — and the deck pins to
112
+ its content hash, so deck bytes never ride the key while deck
113
+ edits still break it. The frozen `request` knobs (RUN-PARAMS)
114
+ ride verbatim: callers differing in one knob submit different
115
+ content.
116
+
117
+ Args:
118
+ arguments: Submitted operation arguments.
119
+
120
+ Returns:
121
+ Normalized copy: every argument but the envelope keys,
122
+ with `deck` replaced by its `deck_hash` mapping when
123
+ present. The caller's mapping is never mutated.
124
+
125
+ Raises:
126
+ ExecutionError: invalid_arguments for a non-mapping.
127
+ """
128
+ if not isinstance(arguments, Mapping):
129
+ raise ExecutionError(
130
+ "invalid_arguments",
131
+ f"Arguments must be a mapping, got {arguments!r}.",
132
+ )
133
+ normalized = copy.deepcopy(dict(arguments))
134
+ for key in _ENVELOPE_KEYS:
135
+ normalized.pop(key, None)
136
+ if "deck" in normalized:
137
+ normalized["deck"] = {"deck_hash": deck_hash(normalized["deck"])}
138
+ return normalized
139
+
140
+
141
+ def content_key(operation, arguments):
142
+ """Build the I33 content key for one run-submit.
143
+
144
+ Args:
145
+ operation: Cataloged operation name.
146
+ arguments: Submitted arguments (post-freeze: `request`
147
+ already carries every knob, so an omitted `request`
148
+ and an explicit all-defaults `request` key alike).
149
+
150
+ Returns:
151
+ Key text `i33:<operation>:<digest>` with hex sha256 over
152
+ the canonical encoding of the normalized arguments.
153
+
154
+ Raises:
155
+ ExecutionError: invalid_arguments for a non-mapping.
156
+ """
157
+ digest = hashlib.sha256(
158
+ _canonical([operation, normalize_arguments(arguments)]).encode("utf-8")
159
+ ).hexdigest()
160
+ return f"i33:{operation}:{digest}"
161
+
162
+
163
+ class _Waiter:
164
+ """Parked joiner: an event plus the cell it guards.
165
+
166
+ Args:
167
+ event: Set once the shared run reaches a terminal state.
168
+ cell: Completion mapping on success, None when the run was
169
+ discarded before completing.
170
+ """
171
+
172
+ def __init__(self):
173
+ self.event = threading.Event()
174
+ self.cell = None
175
+
176
+
177
+ class Coalescer:
178
+ """Revision-pinned registry of live shared runs, one per project.
179
+
180
+ Entries join by (content key, revision): a duplicate submit at
181
+ the same head attaches to the owner's run_id, while any revision
182
+ move misses and executes fresh. Completion evicts, so finished
183
+ runs never coalesce. All entry operations are atomic under one
184
+ lock; `await_completion` waits outside it.
185
+ """
186
+
187
+ def __init__(self):
188
+ self._lock = threading.RLock()
189
+ self._by_key = {}
190
+ self._by_run = {}
191
+
192
+ @property
193
+ def guard(self):
194
+ """Admission lock serializing lookup-admit-register.
195
+
196
+ Returns:
197
+ The coalescer's reentrant lock, for the wiring to
198
+ hold across the submit admission section
199
+ (lookup, ledger admit, register) so identical
200
+ concurrent submits cannot both miss. Entry
201
+ methods take it themselves, so calling them
202
+ under the guard is safe. Never held across
203
+ drains or completion waits.
204
+
205
+ Caution: A leaf — never wait on a waiter event while
206
+ holding it, or the owner cannot complete.
207
+ """
208
+ return self._lock
209
+
210
+ def lookup(self, key, revision):
211
+ """Find the live run sharing one key at one revision, purely.
212
+
213
+ Args:
214
+ key: Content key from `content_key`.
215
+ revision: Current project head; only an entry pinned
216
+ at this exact revision hits.
217
+
218
+ Returns:
219
+ Mapping with run_id and snapshot_id for the shared
220
+ run, or None on a miss (unknown key, moved
221
+ revision, or finished run). Never attaches.
222
+ """
223
+ with self._lock:
224
+ entry = self._by_key.get(key)
225
+ if entry is None or entry["revision"] != revision:
226
+ return None
227
+ return {
228
+ "run_id": entry["run_id"],
229
+ "snapshot_id": entry["snapshot_id"],
230
+ }
231
+
232
+ def join(self, key, revision, request_id):
233
+ """Attach one request to the live shared run, if any.
234
+
235
+ Lookup and attach are atomic: a hit guarantees the waiter
236
+ resolves (completion or discard), never a lost wakeup.
237
+
238
+ Args:
239
+ key: Content key from `content_key`.
240
+ revision: Current project head; a moved revision
241
+ misses unconditionally.
242
+ request_id: Joining envelope id, recorded once (a
243
+ replayed envelope re-joins without duplicating).
244
+
245
+ Returns:
246
+ Mapping with run_id, snapshot_id, and the opaque
247
+ waiter for `await_completion`, or None on a miss.
248
+ """
249
+ with self._lock:
250
+ entry = self._by_key.get(key)
251
+ if entry is None or entry["revision"] != revision:
252
+ return None
253
+ if request_id not in entry["request_ids"]:
254
+ entry["request_ids"].append(request_id)
255
+ return {
256
+ "run_id": entry["run_id"],
257
+ "snapshot_id": entry["snapshot_id"],
258
+ "waiter": entry["waiter"],
259
+ }
260
+
261
+ def attach(self, run_id, request_id):
262
+ """Attach one request to a live run by id.
263
+
264
+ The wiring uses this after logging the coalesced request:
265
+ a run evicted between lookup and attach answers None, and
266
+ the caller falls back to the stored completion (the run
267
+ event precedes eviction, so fetch cannot miss).
268
+
269
+ Args:
270
+ run_id: Live shared run id from `lookup`.
271
+ request_id: Joining envelope id, recorded once.
272
+
273
+ Returns:
274
+ Opaque waiter for `await_completion`, or None when
275
+ the run already finished.
276
+ """
277
+ with self._lock:
278
+ entry = self._by_run.get(run_id)
279
+ if entry is None:
280
+ return None
281
+ if request_id not in entry["request_ids"]:
282
+ entry["request_ids"].append(request_id)
283
+ return entry["waiter"]
284
+
285
+ def register(self, key, revision, request_id, run_id, snapshot_id):
286
+ """Register a fresh execution as the join target for its key.
287
+
288
+ A same-slot collision (impossible under the guard) keeps
289
+ the first run; a re-registration at a new revision replaces
290
+ the join target while the displaced run keeps its own entry
291
+ until it completes, so its waiters are never stranded.
292
+
293
+ Args:
294
+ key: Content key from `content_key`.
295
+ revision: Post-admission project head the run pins.
296
+ request_id: Owning envelope id.
297
+ run_id: Fresh execution id.
298
+ snapshot_id: Ack snapshot the run drains under.
299
+
300
+ Returns:
301
+ Run id now serving the key (the existing one on a
302
+ same-slot collision, else the fresh one).
303
+ """
304
+ with self._lock:
305
+ live = self._by_key.get(key)
306
+ if live is not None and live["revision"] == revision:
307
+ # Same slot, second owner: the first run keeps
308
+ # serving; the newcomer drains its own admission
309
+ # unshared, so its envelope stays out of the
310
+ # fan-out mapping.
311
+ return live["run_id"]
312
+ entry = {
313
+ "key": key,
314
+ "revision": revision,
315
+ "request_ids": [request_id],
316
+ "run_id": run_id,
317
+ "snapshot_id": snapshot_id,
318
+ "waiter": _Waiter(),
319
+ }
320
+ self._by_key[key] = entry
321
+ self._by_run[run_id] = entry
322
+ return run_id
323
+
324
+ def complete(self, run_id, completion):
325
+ """Evict a finished run and deliver its completion to waiters.
326
+
327
+ Args:
328
+ run_id: Run that reached a terminal state.
329
+ completion: Completion mapping fanned out to every
330
+ joined waiter (a copy is stored; the caller's
331
+ mapping is never aliased).
332
+
333
+ Returns:
334
+ Fanned-out request ids, owner first; empty when the
335
+ run was never registered or already finished.
336
+ """
337
+ with self._lock:
338
+ entry = self._by_run.pop(run_id, None)
339
+ if entry is None:
340
+ return []
341
+ if self._by_key.get(entry["key"]) is entry:
342
+ del self._by_key[entry["key"]]
343
+ entry["waiter"].cell = copy.deepcopy(completion)
344
+ entry["waiter"].event.set()
345
+ return list(entry["request_ids"])
346
+
347
+ def discard(self, run_id):
348
+ """Evict a run with no completion, waking waiters empty.
349
+
350
+ The wiring calls this only when the owner cannot complete
351
+ (its drain raised despite containment): waiters then fall
352
+ back to the stored completion, or fail loudly when the run
353
+ truly finished with nothing stored.
354
+
355
+ Args:
356
+ run_id: Run to drop without completing.
357
+
358
+ Returns:
359
+ Woken request ids, owner first; empty when the run
360
+ was never registered or already finished.
361
+ """
362
+ with self._lock:
363
+ entry = self._by_run.pop(run_id, None)
364
+ if entry is None:
365
+ return []
366
+ if self._by_key.get(entry["key"]) is entry:
367
+ del self._by_key[entry["key"]]
368
+ entry["waiter"].event.set()
369
+ return list(entry["request_ids"])
370
+
371
+ def await_completion(self, waiter, timeout=None):
372
+ """Wait for a joined run's completion.
373
+
374
+ Args:
375
+ waiter: Opaque waiter from `join` or `attach`.
376
+ timeout: Maximum seconds to wait; waits as long as
377
+ the owner lives when omitted (its terminal path
378
+ always wakes its waiters).
379
+
380
+ Returns:
381
+ Completion mapping, or None when the run was
382
+ discarded before completing.
383
+
384
+ Raises:
385
+ TimeoutError: The wait lapsed before the run finished.
386
+ """
387
+ if not waiter.event.wait(timeout):
388
+ raise TimeoutError("Shared run did not finish in time.")
389
+ return copy.deepcopy(waiter.cell)
390
+
391
+ def requests_for(self, run_id):
392
+ """List the request ids mapped to one live run.
393
+
394
+ Args:
395
+ run_id: Run to inspect.
396
+
397
+ Returns:
398
+ Mapped request ids, owner first; empty once the run
399
+ finishes (its mapping lives on in the ledger).
400
+ """
401
+ with self._lock:
402
+ entry = self._by_run.get(run_id)
403
+ if entry is None:
404
+ return []
405
+ return list(entry["request_ids"])
406
+
407
+ def pending(self):
408
+ """List joinable shared runs, oldest first.
409
+
410
+ Returns:
411
+ Run ids with a live join target.
412
+ """
413
+ with self._lock:
414
+ return [entry["run_id"] for entry in self._by_key.values()]
@@ -0,0 +1 @@
1
+ """Interactive console surface: sessions plus replay routing."""