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,85 @@
1
+ """Bounded deferred FIFO for prompt-ack admission (ASYNC-SUBMIT).
2
+
3
+ The queue owns order only: admission truth (durable request
4
+ mapping, coalescing, capacity) lives in `EngineRuntime`. Entries
5
+ wait here between the prompt acknowledgement and `pump`.
6
+ """
7
+
8
+ import threading
9
+ from collections import deque
10
+
11
+
12
+ class DeferredQueue:
13
+ """Thread-safe FIFO of admitted-but-undispatched run entries.
14
+
15
+ Args:
16
+ max_depth: Maximum entries held; admissions past it are
17
+ refused by the caller, never queued.
18
+ """
19
+
20
+ def __init__(self, max_depth=64):
21
+ self._max_depth = max_depth
22
+ self._lock = threading.Lock()
23
+ self._entries = deque()
24
+
25
+ @property
26
+ def max_depth(self):
27
+ """Bound refused past, never queued past.
28
+
29
+ Returns:
30
+ Maximum entries held.
31
+ """
32
+ return self._max_depth
33
+
34
+ def __len__(self):
35
+ with self._lock:
36
+ return len(self._entries)
37
+
38
+ def put(self, entry):
39
+ """Append one admitted entry at the tail.
40
+
41
+ Args:
42
+ entry: Mapping with at least a run_id key.
43
+
44
+ Returns:
45
+ Depth after the append.
46
+ """
47
+ with self._lock:
48
+ self._entries.append(entry)
49
+ return len(self._entries)
50
+
51
+ def pending(self):
52
+ """List queued run ids, oldest first.
53
+
54
+ Returns:
55
+ Run ids in dispatch order.
56
+ """
57
+ with self._lock:
58
+ return [entry["run_id"] for entry in self._entries]
59
+
60
+ def take(self, run_id):
61
+ """Remove and return one queued entry by run id.
62
+
63
+ Args:
64
+ run_id: Entry to remove.
65
+
66
+ Returns:
67
+ Entry, or None when no queued entry names the id.
68
+ """
69
+ with self._lock:
70
+ for entry in self._entries:
71
+ if entry["run_id"] == run_id:
72
+ self._entries.remove(entry)
73
+ return entry
74
+ return None
75
+
76
+ def take_all(self):
77
+ """Remove and return every queued entry, oldest first.
78
+
79
+ Returns:
80
+ Entries in dispatch order.
81
+ """
82
+ with self._lock:
83
+ entries = list(self._entries)
84
+ self._entries.clear()
85
+ return entries
@@ -0,0 +1,601 @@
1
+ """Report-not-run survivors + explicit resume + poison quarantine (REPLAY-SAFE).
2
+
3
+ Boot replay that auto-executes is a grief loop: a hung deck gets
4
+ pkill'd, the engine reboots, and faithfully re-runs the exact job
5
+ that hung. The safe default when escape and accident are
6
+ indistinguishable is: don't act, report. The ledger holding the jobs
7
+ IS the 0%-drop guarantee; replay is pure convenience, and this module
8
+ keeps every replay surface gated behind an explicit user decision.
9
+
10
+ Survivors are ledger-admitted runs (`job_queued`) with no completion
11
+ event (`run_completed`/`run_failed`) and no discard event
12
+ (`run_discarded`): `boot()` lists them and NEVER executes. Each
13
+ survivor carries one taxonomy kind: RETRYABLE (never started),
14
+ AMBIGUOUS (started, no completion — outage or poison), or POISON (its
15
+ content key was interrupted twice — quarantined, resume only with
16
+ force). Interruption counts live in the ledger as `run_interrupted`
17
+ events keyed by content key, so a rebooted process classifies
18
+ correctly with no in-memory state.
19
+
20
+ Finished runs are NEVER re-executed, and no transcript of completed
21
+ job ids is ever re-run: runs are reproducible from (snapshot + deck
22
+ + params), so resubmit-when-wanted covers everything.
23
+ """
24
+
25
+ import hashlib
26
+ import json
27
+ import logging
28
+
29
+ try:
30
+ from .errors import ExecutionError
31
+ except ImportError: # standalone verification before install
32
+ from rocket_engine.execution.errors import ExecutionError
33
+
34
+ REPLAY_SAFE_VERSION = 1
35
+
36
+ # Twice-interrupted content keys quarantine: the deck that killed you
37
+ # can never auto-run again.
38
+ POISON_THRESHOLD = 2
39
+
40
+ RETRYABLE = "retryable"
41
+ AMBIGUOUS = "ambiguous"
42
+ POISON = "poison"
43
+
44
+ JOB_QUEUED = "job_queued"
45
+ RUN_STARTED = "run_started"
46
+ RUN_INTERRUPTED = "run_interrupted"
47
+ RUN_DISCARDED = "run_discarded"
48
+ _RUN_OUTCOMES = ("run_completed", "run_failed")
49
+
50
+ # Typed refusal codes -> user-facing messages. Friendly, no internals:
51
+ # never a run id, content key, seq, or traceback. Full context goes to
52
+ # the dev-side log at each refusal site instead.
53
+ USER_MESSAGES = {
54
+ "unknown_survivor": (
55
+ "We couldn't find that unfinished run. "
56
+ "It may have already been handled."
57
+ ),
58
+ "already_completed": (
59
+ "That run already finished, so there is nothing to resume."
60
+ ),
61
+ "already_decided": "That run was already handled in this session.",
62
+ "poison_quarantined": (
63
+ "This run is paused for safety after repeated interruptions. "
64
+ "Please review it before retrying."
65
+ ),
66
+ "stale_revision": (
67
+ "The project changed since this run was admitted, so it "
68
+ "cannot resume on the old inputs. Review the change, then "
69
+ "retry with force if the old inputs are still right."
70
+ ),
71
+ "ambiguous_requires_explicit_resume": (
72
+ "This run stopped partway through, so it needs your individual "
73
+ "review before resuming."
74
+ ),
75
+ }
76
+
77
+ logger = logging.getLogger(__name__)
78
+
79
+
80
+ def content_key_for(operation, arguments):
81
+ """Stable identity for one run's normalized content.
82
+
83
+ The key covers the operation plus its arguments (deck hash rides
84
+ inside the arguments), so identical resubmits share one key and
85
+ one interruption count while any argument change starts fresh.
86
+
87
+ Args:
88
+ operation: Cataloged operation name.
89
+ arguments: Operation arguments mapping.
90
+
91
+ Returns:
92
+ Content key (`ck_` plus 16 hex chars), stable over key order.
93
+ """
94
+ payload = json.dumps(
95
+ {"operation": operation, "arguments": arguments or {}},
96
+ sort_keys=True,
97
+ separators=(",", ":"),
98
+ ensure_ascii=True,
99
+ default=str,
100
+ )
101
+ digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
102
+ return f"ck_{digest}"
103
+
104
+
105
+ def interrupt_counts_from_events(events):
106
+ """Admitted->interrupted transitions per content key.
107
+
108
+ Args:
109
+ events: Ledger events with event and data.content_key entries.
110
+
111
+ Returns:
112
+ Mapping of content key to its `run_interrupted` count.
113
+ """
114
+ counts = {}
115
+ for event in events:
116
+ if event.get("event") != RUN_INTERRUPTED:
117
+ continue
118
+ key = (event.get("data") or {}).get("content_key")
119
+ if key:
120
+ counts[key] = counts.get(key, 0) + 1
121
+ return counts
122
+
123
+
124
+ def survivors_from_events(events, resolve=None):
125
+ """Ledger-admitted runs with no completion or discard, oldest first.
126
+
127
+ Kinds: never-started runs are RETRYABLE; started-but-uncompleted
128
+ runs are AMBIGUOUS; runs whose content key reached
129
+ POISON_THRESHOLD interruptions are POISON (quarantined). A missing
130
+ content key never quarantines: without a key there is no count.
131
+
132
+ Args:
133
+ events: Ledger events with seq, event, request_id, and
134
+ data.run_id entries; `job_queued` data may carry
135
+ content_key (post-wiring ledgers).
136
+ resolve: Optional `request_id -> (command, ack)` for
137
+ pre-wiring ledgers whose `job_queued` events lack
138
+ content_key. May raise or return None per id; either
139
+ leaves that survivor keyless, never skipped.
140
+
141
+ Returns:
142
+ Survivor mappings in admit order, each with run_id,
143
+ request_id, operation, content_key (None when
144
+ unresolvable), kind, interrupts, quarantined, and the
145
+ always-offered actions ["resume", "discard"].
146
+ """
147
+ queued = {}
148
+ order = []
149
+ completed = set()
150
+ discarded = set()
151
+ started = set()
152
+ for event in sorted(events, key=lambda e: e.get("seq", 0)):
153
+ name = event.get("event")
154
+ data = event.get("data") or {}
155
+ run_id = data.get("run_id")
156
+ if run_id is None:
157
+ continue
158
+ if name == JOB_QUEUED:
159
+ if run_id not in queued:
160
+ order.append(run_id)
161
+ queued[run_id] = {
162
+ "request_id": event.get("request_id"),
163
+ "operation": data.get("operation"),
164
+ "content_key": data.get("content_key"),
165
+ }
166
+ elif name in _RUN_OUTCOMES:
167
+ completed.add(run_id)
168
+ elif name == RUN_DISCARDED:
169
+ discarded.add(run_id)
170
+ elif name == RUN_STARTED:
171
+ started.add(run_id)
172
+ counts = interrupt_counts_from_events(events)
173
+ survivors = []
174
+ for run_id in order:
175
+ if run_id in completed or run_id in discarded:
176
+ continue
177
+ entry = queued[run_id]
178
+ key = entry["content_key"]
179
+ if key is None and resolve is not None:
180
+ try:
181
+ resolved = resolve(entry["request_id"])
182
+ except Exception:
183
+ resolved = None
184
+ if resolved is not None:
185
+ command, _ack = resolved
186
+ key = content_key_for(
187
+ command.get("operation"),
188
+ command.get("arguments") or {},
189
+ )
190
+ interrupts = counts.get(key, 0) if key else 0
191
+ if key is not None and interrupts >= POISON_THRESHOLD:
192
+ kind = POISON
193
+ elif run_id in started:
194
+ kind = AMBIGUOUS
195
+ else:
196
+ kind = RETRYABLE
197
+ survivors.append({
198
+ "run_id": run_id,
199
+ "request_id": entry["request_id"],
200
+ "operation": entry["operation"],
201
+ "content_key": key,
202
+ "kind": kind,
203
+ "interrupts": interrupts,
204
+ "quarantined": kind == POISON,
205
+ "actions": ["resume", "discard"],
206
+ })
207
+ return survivors
208
+
209
+
210
+ def boot_message(unfinished):
211
+ """Pkill-then-boot message: what happened, what did not run.
212
+
213
+ Args:
214
+ unfinished: Survivor count from the boot report.
215
+
216
+ Returns:
217
+ Session-ended message naming the unfinished count plus the
218
+ nothing-re-ran promise (or the clean-session variant when
219
+ zero), always pointing at per-run Resume/Discard.
220
+ """
221
+ if unfinished <= 0:
222
+ return (
223
+ "Last session ended cleanly. "
224
+ "No unfinished runs \u2014 nothing to resume."
225
+ )
226
+ noun = "run" if unfinished == 1 else "runs"
227
+ return (
228
+ f"Last session ended unexpectedly. {unfinished} {noun} "
229
+ "unfinished \u2014 nothing re-ran automatically. "
230
+ "Review each run to Resume or Discard it."
231
+ )
232
+
233
+
234
+ def user_message_for(code):
235
+ """Friendly user-facing message for one refusal code.
236
+
237
+ Args:
238
+ code: Typed refusal code.
239
+
240
+ Returns:
241
+ User-facing message with no internals; a generic
242
+ review-prompt for unknown codes (never a KeyError).
243
+ """
244
+ return USER_MESSAGES.get(
245
+ code, "This run needs your review before it can continue."
246
+ )
247
+
248
+
249
+ def refuse(code, dev_detail):
250
+ """Log full dev context, then raise the friendly typed refusal.
251
+
252
+ Args:
253
+ code: Typed refusal code (keys USER_MESSAGES).
254
+ dev_detail: Dev-side context (ids, keys, counts) for the
255
+ log only — never surfaced to the user.
256
+
257
+ Raises:
258
+ ExecutionError: Always, carrying the code plus the
259
+ user-facing message.
260
+ """
261
+ logger.warning("replay_safe refusal code=%s detail=%s", code, dev_detail)
262
+ raise ExecutionError(code, user_message_for(code))
263
+
264
+
265
+ def _latest_seq(events, name, run_id):
266
+ """Latest seq of one event name for one run (-1 when absent)."""
267
+ best = -1
268
+ for event in events:
269
+ if event.get("event") != name:
270
+ continue
271
+ if (event.get("data") or {}).get("run_id") != run_id:
272
+ continue
273
+ seq = event.get("seq", 0)
274
+ if seq > best:
275
+ best = seq
276
+ return best
277
+
278
+
279
+ class ReplaySafe:
280
+ """Explicit-decision replay controller over a durable ledger.
281
+
282
+ Boot reports, the user decides, and only then does anything run:
283
+ `boot()` never executes, `discard()` only records, and the
284
+ executor fires solely inside an explicit `resume()` or
285
+ `resume_all_retryable()` call.
286
+
287
+ Args:
288
+ project: Ledger port with `events_since(cursor)`,
289
+ `command_for(request_id)`, and
290
+ `append_replay_event(event, request_id, data)`.
291
+ executor: Callable `(operation, arguments, snapshot_id,
292
+ request_id, run_id)` returning a completion mapping.
293
+ It fires only on explicit resume, never on boot.
294
+
295
+ Caution: One instance per boot: per-run decisions
296
+ (resumed/discarded) are tracked in memory so a second
297
+ action on the same run refuses instead of double-running.
298
+ """
299
+
300
+ def __init__(self, project, executor):
301
+ self._project = project
302
+ self._executor = executor
303
+ self._decided = {}
304
+
305
+ def _resolve_soft(self, request_id):
306
+ """Admitted (command, ack), or None when unresolvable."""
307
+ try:
308
+ return self._project.command_for(request_id)
309
+ except Exception:
310
+ return None
311
+
312
+ def _live_survivors(self, events):
313
+ by_id = {}
314
+ for survivor in survivors_from_events(
315
+ events, self._resolve_soft
316
+ ):
317
+ by_id[survivor["run_id"]] = survivor
318
+ return by_id
319
+
320
+ def _completed_runs(self, events):
321
+ """Run ids with a completion event."""
322
+ return {
323
+ (event.get("data") or {}).get("run_id")
324
+ for event in events
325
+ if event.get("event") in _RUN_OUTCOMES
326
+ } - {None}
327
+
328
+ def _check_decided(self, run_id):
329
+ """Refuse resumed/discarded runs; completed ones stay live."""
330
+ if run_id in self._decided:
331
+ refuse(
332
+ "already_decided",
333
+ f"run_id={run_id} decision={self._decided[run_id]}",
334
+ )
335
+
336
+ def boot(self):
337
+ """Report crash survivors without executing anything.
338
+
339
+ Started-but-uncompleted runs gain one ledger
340
+ `run_interrupted` mark each (idempotent: a mark counts until
341
+ the next `run_started`), so the taxonomy reflects this boot's
342
+ outages before the report is built.
343
+
344
+ Returns:
345
+ Report with version, the pkill-then-boot message, the
346
+ unfinished count, survivors in admit order,
347
+ quarantined run ids, and unresolved entries
348
+ (pre-wiring admissions whose command is gone — each
349
+ with request_id, run_id, and reason).
350
+
351
+ Caution: Report-only by construction: the executor is never
352
+ invoked here, only inside an explicit resume call.
353
+ """
354
+ events = self._project.events_since(0)
355
+ request_run = {}
356
+ for event in events:
357
+ if event.get("event") == JOB_QUEUED:
358
+ request_run[event.get("request_id")] = (
359
+ event.get("data") or {}
360
+ ).get("run_id")
361
+ unresolved = []
362
+
363
+ def _resolve(request_id):
364
+ try:
365
+ return self._project.command_for(request_id)
366
+ except Exception as exc:
367
+ unresolved.append({
368
+ "request_id": request_id,
369
+ "run_id": request_run.get(request_id),
370
+ "reason": f"{type(exc).__name__}: {exc}",
371
+ })
372
+ return None
373
+
374
+ survivors = survivors_from_events(events, _resolve)
375
+ for survivor in survivors:
376
+ key = survivor["content_key"]
377
+ if key is None:
378
+ continue
379
+ run_id = survivor["run_id"]
380
+ if _latest_seq(events, RUN_STARTED, run_id) > _latest_seq(
381
+ events, RUN_INTERRUPTED, run_id
382
+ ):
383
+ self._project.append_replay_event(
384
+ RUN_INTERRUPTED,
385
+ survivor["request_id"],
386
+ {
387
+ "run_id": run_id,
388
+ "content_key": key,
389
+ "operation": survivor["operation"],
390
+ },
391
+ )
392
+ logger.info(
393
+ "replay_safe interrupted run_id=%s key=%s",
394
+ run_id,
395
+ key,
396
+ )
397
+ events = self._project.events_since(0)
398
+ survivors = survivors_from_events(events, _resolve)
399
+ quarantined = [
400
+ survivor["run_id"]
401
+ for survivor in survivors
402
+ if survivor["quarantined"]
403
+ ]
404
+ logger.info(
405
+ "replay_safe boot unfinished=%d quarantined=%d",
406
+ len(survivors),
407
+ len(quarantined),
408
+ )
409
+ return {
410
+ "version": REPLAY_SAFE_VERSION,
411
+ "message": boot_message(len(survivors)),
412
+ "unfinished": len(survivors),
413
+ "survivors": survivors,
414
+ "quarantined": quarantined,
415
+ "unresolved": unresolved,
416
+ }
417
+
418
+ def resume(self, run_id, force=False):
419
+ """Explicitly resume one survivor through the executor.
420
+
421
+ Quarantined (POISON) runs resume only with force; every other
422
+ survivor resumes on the explicit call. A `run_started` mark is
423
+ appended first, so a kill mid-resume classifies AMBIGUOUS (or
424
+ POISON) at the next boot instead of vanishing. A head that
425
+ moved past the admission revision refuses as stale — the
426
+ stored inputs no longer describe the project — and only
427
+ force resumes them.
428
+
429
+ Args:
430
+ run_id: Survivor to resume.
431
+ force: Required for quarantined runs and for runs
432
+ whose admission revision drifted; refused loudly
433
+ without it. Force bypasses quarantine and drift
434
+ only — the run still executes for real.
435
+
436
+ Returns:
437
+ Executor completion for the run.
438
+
439
+ Raises:
440
+ ExecutionError: already_decided for a run handled this
441
+ session, already_completed for a run that finished
442
+ since the report, unknown_survivor for an id with
443
+ no unfinished run, poison_quarantined for a
444
+ quarantined run resumed without force,
445
+ stale_revision for a run admitted before the
446
+ project head moved on.
447
+
448
+ Caution: This executes the run — the one replay surface
449
+ that does, and only on this explicit call.
450
+ """
451
+ events = self._project.events_since(0)
452
+ self._check_decided(run_id)
453
+ if run_id in self._completed_runs(events):
454
+ refuse(
455
+ "already_completed",
456
+ f"run_id={run_id} completed_since_report=True",
457
+ )
458
+ survivor = self._live_survivors(events).get(run_id)
459
+ if survivor is None:
460
+ refuse("unknown_survivor", f"run_id={run_id}")
461
+ if survivor["quarantined"] and not force:
462
+ refuse(
463
+ "poison_quarantined",
464
+ f"run_id={run_id} key={survivor['content_key']} "
465
+ f"interrupts={survivor['interrupts']} force={force}",
466
+ )
467
+ try:
468
+ command, ack = self._project.command_for(
469
+ survivor["request_id"]
470
+ )
471
+ except Exception as exc:
472
+ refuse(
473
+ "unknown_survivor",
474
+ f"run_id={run_id} "
475
+ f"request_id={survivor['request_id']} "
476
+ f"resolve_failed={type(exc).__name__}: {exc}",
477
+ )
478
+ admitted = (ack or {}).get("current_revision")
479
+ if admitted is not None and not force:
480
+ head = self._project.snapshot()["current_revision"]
481
+ if head != admitted:
482
+ refuse(
483
+ "stale_revision",
484
+ f"run_id={run_id} "
485
+ f"admitted_revision={admitted} "
486
+ f"current_revision={head} force=False",
487
+ )
488
+ self._project.append_replay_event(
489
+ RUN_STARTED,
490
+ survivor["request_id"],
491
+ {
492
+ "run_id": run_id,
493
+ "content_key": survivor["content_key"],
494
+ "operation": survivor["operation"],
495
+ },
496
+ )
497
+ completion = self._executor(
498
+ command.get("operation"),
499
+ dict(command.get("arguments") or {}),
500
+ ack.get("snapshot_id", ""),
501
+ survivor["request_id"],
502
+ run_id,
503
+ )
504
+ self._decided[run_id] = "resumed"
505
+ logger.info(
506
+ "replay_safe resumed run_id=%s kind=%s forced=%s",
507
+ run_id,
508
+ survivor["kind"],
509
+ bool(force),
510
+ )
511
+ return completion
512
+
513
+ def discard(self, run_id):
514
+ """Explicitly discard one survivor, never executing it.
515
+
516
+ The run gains a `run_discarded` ledger event and leaves all
517
+ future survivor reports.
518
+
519
+ Args:
520
+ run_id: Survivor to discard.
521
+
522
+ Returns:
523
+ Discard record with run_id, discarded state, and the
524
+ run's operation.
525
+
526
+ Raises:
527
+ ExecutionError: already_decided for a run handled this
528
+ session, already_completed for a finished run,
529
+ unknown_survivor for an id with no unfinished run.
530
+ """
531
+ events = self._project.events_since(0)
532
+ self._check_decided(run_id)
533
+ if run_id in self._completed_runs(events):
534
+ refuse(
535
+ "already_completed",
536
+ f"run_id={run_id} completed_since_report=True",
537
+ )
538
+ survivor = self._live_survivors(events).get(run_id)
539
+ if survivor is None:
540
+ refuse("unknown_survivor", f"run_id={run_id}")
541
+ self._project.append_replay_event(
542
+ RUN_DISCARDED,
543
+ survivor["request_id"],
544
+ {"run_id": run_id, "operation": survivor["operation"]},
545
+ )
546
+ self._decided[run_id] = "discarded"
547
+ logger.info("replay_safe discarded run_id=%s", run_id)
548
+ return {
549
+ "run_id": run_id,
550
+ "state": "discarded",
551
+ "operation": survivor["operation"],
552
+ }
553
+
554
+ def resume_all_retryable(self):
555
+ """Resume every RETRYABLE survivor, oldest first.
556
+
557
+ AMBIGUOUS runs need individual review and POISON runs need
558
+ per-run force, so both are skipped loudly — never swept up.
559
+
560
+ Returns:
561
+ Batch with resumed run ids, their completions, and
562
+ skipped entries (each with run_id, typed code, and
563
+ user-facing message).
564
+
565
+ Raises:
566
+ ExecutionError: Propagates the first executor failure
567
+ unchanged — earlier resumes in the batch stand,
568
+ later ones never fire.
569
+
570
+ Caution: This executes runs — only the RETRYABLE,
571
+ non-quarantined survivors, and only on this call.
572
+ """
573
+ events = self._project.events_since(0)
574
+ resumed = []
575
+ completions = []
576
+ skipped = []
577
+ for survivor in survivors_from_events(events, self._resolve_soft):
578
+ run_id = survivor["run_id"]
579
+ if run_id in self._decided:
580
+ code = "already_decided"
581
+ elif survivor["quarantined"]:
582
+ code = "poison_quarantined"
583
+ elif survivor["kind"] != RETRYABLE:
584
+ code = "ambiguous_requires_explicit_resume"
585
+ else:
586
+ completions.append(self.resume(run_id))
587
+ resumed.append(run_id)
588
+ continue
589
+ skipped.append({
590
+ "run_id": run_id,
591
+ "code": code,
592
+ "message": user_message_for(code),
593
+ })
594
+ logger.info(
595
+ "replay_safe skipped run_id=%s code=%s", run_id, code
596
+ )
597
+ return {
598
+ "resumed": resumed,
599
+ "completions": completions,
600
+ "skipped": skipped,
601
+ }
@@ -0,0 +1 @@
1
+ """Canonical console routing homes: calls, namespace, tools."""