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,176 @@
1
+ """Entity-record projection and backfill reads."""
2
+
3
+ from ...stores import ecs
4
+ from ..errors import ProjectError
5
+
6
+ from .shared import _MAX_DOC_BYTES
7
+
8
+ class _EntityMixin:
9
+ """Entity projection helpers for ProjectService."""
10
+
11
+ def _entity_records(self):
12
+ """Decode every entity record into world-snapshot entries."""
13
+ records = []
14
+ for rel, _size in self._iter_doc_files():
15
+ entity = ecs.entity_from_doc_path(rel)
16
+ if entity is None:
17
+ continue
18
+ text = self._read_doc_text(rel, rel)
19
+ if text is None:
20
+ raise ProjectError(
21
+ "invalid_document",
22
+ f"Entity record vanished mid-read: {rel}.",
23
+ )
24
+ try:
25
+ decoded = ecs.decode_entity_doc(text, rel)
26
+ except ecs.EcsError as exc:
27
+ raise ProjectError(exc.code, str(exc)) from exc
28
+ records.append(
29
+ {
30
+ "components": decoded["components"],
31
+ "id": decoded["entity"],
32
+ "kind": decoded["kind"],
33
+ }
34
+ )
35
+ return records
36
+
37
+ def _backfill_records(self):
38
+ """Project every non-entity doc as a virtual entity."""
39
+ entries = [
40
+ {"bytes": size, "path": rel}
41
+ for rel, size in self._iter_doc_files()
42
+ ]
43
+ return ecs.backfill_entities(entries)
44
+
45
+ def _find_backfill(self, entity):
46
+ """Return the virtual record for one backfilled id."""
47
+ for record in self._backfill_records():
48
+ if record["id"] == entity:
49
+ return record
50
+ raise ProjectError("unknown_entity", f"No such entity: {entity}.")
51
+
52
+ def _read_backfill_component(self, entity, component):
53
+ """Serve one projected doc component for a backfilled id."""
54
+ record = self._find_backfill(entity)
55
+ if component != ecs.BACKFILL_COMPONENT:
56
+ raise ProjectError(
57
+ "unknown_component",
58
+ f"No component {component!r} on {entity}.",
59
+ )
60
+ data = record["components"][ecs.BACKFILL_COMPONENT]
61
+ return {
62
+ "address": ecs.format_address(entity, component),
63
+ "component": component,
64
+ "current_revision": self._current_revision,
65
+ "data": dict(data),
66
+ "entity": entity,
67
+ "path": data["path"],
68
+ "project_id": self._project_id,
69
+ }
70
+
71
+ def _read_entity_record(self, entity):
72
+ """Return (path, decoded) for one live entity id."""
73
+ path = ecs.entity_doc_path(entity)
74
+ text = self._read_doc_text(path, path)
75
+ if text is None:
76
+ raise ProjectError(
77
+ "unknown_entity", f"No such entity: {entity}."
78
+ )
79
+ try:
80
+ decoded = ecs.decode_entity_doc(text, path)
81
+ except ecs.EcsError as exc:
82
+ raise ProjectError(exc.code, str(exc)) from exc
83
+ if decoded["entity"] != entity:
84
+ raise ProjectError(
85
+ "invalid_document",
86
+ f"Entity record id drift at {path}.",
87
+ )
88
+ return path, decoded
89
+
90
+ def _store_entity_record(
91
+ self, path, entity, kind, components, entity_revision=1
92
+ ):
93
+ """Encode and persist one entity record under its path."""
94
+ text = ecs.encode_entity_doc(
95
+ entity, kind, components, entity_revision=entity_revision
96
+ )
97
+ if len(text.encode("utf-8")) > _MAX_DOC_BYTES:
98
+ raise ProjectError(
99
+ "invalid_command",
100
+ f"Entity record exceeds the size limit: {path}.",
101
+ )
102
+ self._write_doc_text(path, text, path)
103
+
104
+ @staticmethod
105
+ def _entity_revision_of(decoded):
106
+ """Per-entity revision of a decoded record (legacy reads 1)."""
107
+ return decoded.get("entity_revision", 1)
108
+
109
+ def _gate_entity_revision(
110
+ self, entity, decoded, expected_entity_revision
111
+ ):
112
+ """Compare-and-swap one entity write.
113
+
114
+ Args:
115
+ entity: Entity id being written.
116
+ decoded: Decoded live record.
117
+ expected_entity_revision: Revision the writer read, or
118
+ None to skip the entity gate (legacy global gate
119
+ still applies).
120
+
121
+ Returns:
122
+ The live entity revision the write may build on.
123
+
124
+ Raises:
125
+ ProjectError: revision_conflict naming the entity plus
126
+ expected vs actual — safe to retry after re-read.
127
+ """
128
+ current = self._entity_revision_of(decoded)
129
+ if (
130
+ expected_entity_revision is not None
131
+ and expected_entity_revision != current
132
+ ):
133
+ raise ProjectError(
134
+ "revision_conflict",
135
+ f"Entity {entity} at revision {current}, "
136
+ f"writer expected {expected_entity_revision}.",
137
+ )
138
+ return current
139
+
140
+ def _gate_entity_write(self, expected_revision, expected_entity_revision):
141
+ """Apply the global head gate unless entity CAS owns the write.
142
+
143
+ A mutation naming a current entity revision skips the
144
+ global head gate even when it also names a stale head —
145
+ the head moved because of unrelated entities, and the
146
+ entity revision is the strictly more precise gate. Naming
147
+ no entity revision keeps the legacy mandatory-head
148
+ behavior.
149
+ """
150
+ if expected_entity_revision is not None:
151
+ return
152
+ self._gate_revision(expected_revision)
153
+
154
+ def _ecs_response(self, request_id, previous, diff, record, extra):
155
+ """CRUD-shaped mutation mapping for one entity change."""
156
+ response = {
157
+ "project_id": self._project_id,
158
+ "previous_revision": previous,
159
+ "current_revision": self._current_revision,
160
+ "revision_transition": {
161
+ "from": previous,
162
+ "to": self._current_revision,
163
+ },
164
+ "actor": self._actor,
165
+ "diff_summary": diff,
166
+ "rewind_handle": {
167
+ "project_id": self._project_id,
168
+ "revision": previous,
169
+ "request_id": request_id,
170
+ },
171
+ "events": [record],
172
+ "event_cursor": self._cursor(),
173
+ "request_id": request_id,
174
+ }
175
+ response.update(extra)
176
+ return response
@@ -0,0 +1,29 @@
1
+ """Event cursor, retention, and append machinery."""
2
+
3
+
4
+ class _EventMixin:
5
+ """Event cursor and retention for ProjectService."""
6
+
7
+ def _cursor(self):
8
+ return self._events[-1]["seq"] if self._events else 0
9
+
10
+ def _retained_head(self):
11
+ return self._events[0]["seq"] if self._events else 0
12
+
13
+ def _trim(self):
14
+ over = len(self._events) - self._max_retained_events
15
+ if over > 0:
16
+ del self._events[:over]
17
+
18
+ def _append_event(self, event, request_id, data):
19
+ record = {
20
+ "seq": self._cursor() + 1,
21
+ "event": event,
22
+ "request_id": request_id,
23
+ "project_id": self._project_id,
24
+ "actor": self._actor,
25
+ "data": data,
26
+ }
27
+ self._events.append(record)
28
+ self._trim()
29
+ return record
@@ -0,0 +1,257 @@
1
+ """Run admission gates and run-event recording."""
2
+
3
+ from ..errors import ProjectError
4
+
5
+ from .shared import _REPLAY_EVENTS, _RUN_OUTCOMES
6
+
7
+ #: Verdict detail kept on the completion event: the ledger answers
8
+ #: findings() and run_logs() after a reboot, when the in-memory
9
+ #: execution record is gone. Lists cap here; the verdict keeps
10
+ #: the full counts and the event keeps the truncated remainder.
11
+ _DURABLE_FINDINGS_MAX = 100
12
+
13
+ #: Native-log tail kept on the completion event: triage reads the
14
+ #: tail, and the ledger never carries bulk vectors.
15
+ _DURABLE_LOG_CHARS = 4096
16
+
17
+
18
+ class _RunMixin:
19
+ """Run admission helpers for ProjectService."""
20
+
21
+ def append_run_event(self, outcome, run_id, operation, verdict,
22
+ request_id=None, findings=(), log=""):
23
+ """Append a run completion event with its verdict link.
24
+
25
+ Findings and the native-log tail ride the event so the
26
+ ledger answers findings() and run_logs() after a reboot.
27
+ Both cap: the first findings plus the truncated remainder
28
+ count, and the last log characters plus the full line
29
+ count.
30
+
31
+ Args:
32
+ outcome: run_completed for a judged run (PASS or FAIL),
33
+ run_failed when no verdict exists (NOT_RUN).
34
+ run_id: Run the verdict belongs to; findings read back
35
+ through it.
36
+ operation: Operation that ran.
37
+ verdict: Full verdict mapping stored on the event.
38
+ request_id: Queued request this run discharges, if any
39
+ (run_job); direct runs carry None.
40
+ findings: Finding mappings stored first-N with the
41
+ truncated remainder count.
42
+ log: Native log text; the tail is stored with the
43
+ full line count.
44
+
45
+ Returns:
46
+ The appended event record with its seq.
47
+
48
+ Raises:
49
+ ProjectError: invalid_command for an unknown outcome.
50
+
51
+ Caution: Appends to the ordered log (and the cursor) but
52
+ never advances the project revision — running judges,
53
+ only submitting mutates.
54
+ """
55
+ if outcome not in _RUN_OUTCOMES:
56
+ raise ProjectError(
57
+ "invalid_command", f"Unknown run outcome: {outcome}."
58
+ )
59
+ kept = list(findings)[:_DURABLE_FINDINGS_MAX]
60
+ dropped = max(0, len(list(findings)) - len(kept))
61
+ excerpt = log[-_DURABLE_LOG_CHARS:] if log else ""
62
+ with self._ledger_lock:
63
+ self._refresh_durable()
64
+ record = self._append_event(
65
+ outcome,
66
+ request_id,
67
+ {
68
+ "run_id": run_id,
69
+ "operation": operation,
70
+ "verdict": verdict,
71
+ "findings": kept,
72
+ "findings_truncated": dropped,
73
+ "log_excerpt": excerpt,
74
+ "log_lines": len(log.splitlines()) if log else 0,
75
+ },
76
+ )
77
+ self._persist_after_mutation()
78
+ return record
79
+
80
+ def append_replay_event(self, event, request_id, data):
81
+ """Append a replay-lifecycle event for one run.
82
+
83
+ `run_started` marks execution begin (a kill after it reads as
84
+ an interruption at the next boot); `run_interrupted` records
85
+ one admitted->interrupted transition for its content key (two
86
+ quarantines the key); `run_discarded` retires a survivor the
87
+ user chose not to resume.
88
+
89
+ Args:
90
+ event: Replay event name (run_started, run_interrupted,
91
+ or run_discarded).
92
+ request_id: Queued request the run belongs to.
93
+ data: Event data carrying the run_id (plus content_key
94
+ for started/interrupted marks).
95
+
96
+ Returns:
97
+ The appended event record with its seq.
98
+
99
+ Raises:
100
+ ProjectError: invalid_command for an unknown replay
101
+ event or data without a run_id.
102
+
103
+ Caution: Appends to the ordered log (and the cursor) but
104
+ never advances the project revision — replay
105
+ bookkeeping judges nothing and mutates nothing.
106
+ """
107
+ if event not in _REPLAY_EVENTS:
108
+ raise ProjectError(
109
+ "invalid_command", f"Unknown replay event: {event}."
110
+ )
111
+ if not isinstance(data, dict) or data.get("run_id") is None:
112
+ raise ProjectError(
113
+ "invalid_command", "Replay event needs a data.run_id."
114
+ )
115
+ with self._ledger_lock:
116
+ self._refresh_durable()
117
+ record = self._append_event(event, request_id, dict(data))
118
+ self._persist_after_mutation()
119
+ return record
120
+
121
+ def log_coalesced_request(self, command_text, request_id, run_id,
122
+ snapshot_id):
123
+ """Log a coalesced envelope against its shared run (COALESCE-I33).
124
+
125
+ A duplicate submit that joins a live run records its envelope
126
+ here instead of admitting: the request_id maps to the shared
127
+ run_id in the request map (so replays and run_job resolve it)
128
+ and a `request_coalesced` event lands in the log — but the
129
+ revision never advances, no snapshot is captured, and the
130
+ project never mutates. Joining callers are readers of a
131
+ shared verdict, not writers.
132
+
133
+ Args:
134
+ command_text: YAML command mapping with an operation.
135
+ request_id: Joining envelope id; resubmitting the
136
+ identical text returns the identical ack.
137
+ run_id: Shared live run the envelope joins.
138
+ snapshot_id: Shared run's snapshot (one revision pins
139
+ one snapshot).
140
+
141
+ Returns:
142
+ Coalesced ack with the caller's request_id, the shared
143
+ run_id and snapshot_id, queued state, the current
144
+ (unadvanced) revision, the event cursor, and a
145
+ coalesced marker.
146
+
147
+ Raises:
148
+ ProjectError: invalid_yaml for an unparsable command or
149
+ one without an operation, request_conflict for a
150
+ reused id with new text.
151
+
152
+ Caution: Never advances the revision — a coalesced envelope
153
+ must not break the pin later duplicates join under.
154
+ """
155
+ self._refresh_durable()
156
+ replay = self._check_replay(request_id, command_text)
157
+ if replay is not None:
158
+ return replay
159
+ try:
160
+ command = self._yaml.load(command_text)
161
+ except Exception as exc:
162
+ raise ProjectError(
163
+ "invalid_yaml", f"Cannot parse command: {exc}"
164
+ ) from exc
165
+ if not isinstance(command, dict) or "operation" not in command:
166
+ raise ProjectError(
167
+ "invalid_yaml",
168
+ "Command must be a mapping with an operation.",
169
+ )
170
+ ack = {
171
+ "request_id": request_id,
172
+ "project_id": self._project_id,
173
+ "run_id": run_id,
174
+ "snapshot_id": snapshot_id,
175
+ "state": "queued",
176
+ "current_revision": self._current_revision,
177
+ "coalesced": True,
178
+ }
179
+ self._requests[request_id] = (command_text, ack)
180
+ self._append_event(
181
+ "request_coalesced",
182
+ request_id,
183
+ {
184
+ "run_id": run_id,
185
+ "revision": self._current_revision,
186
+ "operation": command.get("operation"),
187
+ },
188
+ )
189
+ ack["event_cursor"] = self._cursor()
190
+ self._persist_after_mutation()
191
+ return ack
192
+
193
+ def command_for(self, request_id):
194
+ """Return the admitted (command, ack) for a queued request.
195
+
196
+ Phase 2 of submit-then-run: unknown ids raise
197
+ `unknown_request` instead of running a stranger's job.
198
+
199
+ Args:
200
+ request_id: Envelope id admitted by an earlier submit.
201
+
202
+ Returns:
203
+ (command, ack) pair stored at admission time.
204
+
205
+ Raises:
206
+ ProjectError: unknown_request for an id never admitted.
207
+ """
208
+ self._refresh_durable()
209
+ try:
210
+ command_text, ack = self._requests[str(request_id)]
211
+ except KeyError:
212
+ raise ProjectError(
213
+ "unknown_request", f"No queued request: {request_id}."
214
+ ) from None
215
+ return self._yaml.load(command_text), ack
216
+
217
+ def _check_replay(self, request_id, command_text):
218
+ """Return the stored response for an idempotent replay, if any.
219
+
220
+ Args:
221
+ request_id: Client envelope id.
222
+ command_text: YAML command text for identity comparison.
223
+
224
+ Returns:
225
+ Stored response when the id was seen with identical
226
+ text, else None.
227
+
228
+ Raises:
229
+ ProjectError: request_conflict for a reused id with
230
+ new text.
231
+ """
232
+ prev = self._requests.get(request_id)
233
+ if prev is None:
234
+ return None
235
+ prev_text, prev_ack = prev
236
+ if prev_text != command_text:
237
+ raise ProjectError(
238
+ "request_conflict",
239
+ f"Request id reused with different input: {request_id}.",
240
+ )
241
+ return prev_ack
242
+
243
+ def _gate_revision(self, expected_revision):
244
+ """Fail stale writers before any effect lands.
245
+
246
+ Args:
247
+ expected_revision: Revision the writer read.
248
+
249
+ Raises:
250
+ ProjectError: revision_conflict for a stale head.
251
+ """
252
+ if expected_revision != self._current_revision:
253
+ raise ProjectError(
254
+ "revision_conflict",
255
+ f"Expected revision {self._current_revision}, "
256
+ f"got {expected_revision}.",
257
+ )
@@ -0,0 +1,174 @@
1
+ """Pure ledger primitives shared by the project service."""
2
+
3
+ import io
4
+ import os
5
+ import re
6
+ import tempfile
7
+
8
+ from ruamel.yaml import YAML
9
+
10
+ from ...interfaces.paths import to_unix
11
+ from ..errors import ProjectError
12
+
13
+ DEFAULT_MAX_RETAINED_EVENTS = 10000
14
+ _MAX_DOC_BYTES = 1_000_000
15
+ _EVENTS_FILENAME = "events.yaml"
16
+ _LEDGER_FILENAME = "ledger.yaml"
17
+ _SNAPSHOTS_DIRNAME = "snapshots"
18
+ _LINKED_CARVEOUT = ".rocket"
19
+ _REVISION_RE = re.compile(r"^(.*?)(\d+)$")
20
+ _PROJECT_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_.-]*$")
21
+ _RUN_OUTCOMES = ("run_completed", "run_failed")
22
+ _REPLAY_EVENTS = ("run_started", "run_interrupted", "run_discarded")
23
+ _RESERVED_DOCS = frozenset({_LEDGER_FILENAME, _EVENTS_FILENAME})
24
+
25
+ # Document prefix owning the five design-truth settings files.
26
+ _SETTINGS_DOC_PREFIX = "settings/"
27
+
28
+
29
+ def _advance_revision(revision):
30
+ """Next revision: trailing digits increment, else `-2` is suffixed."""
31
+ match = _REVISION_RE.match(revision or "")
32
+ if match is None:
33
+ return f"{revision}-2"
34
+ return f"{match.group(1)}{int(match.group(2)) + 1}"
35
+
36
+
37
+ def _dump_yaml(data):
38
+ buf = io.StringIO()
39
+ YAML(typ="safe").dump(data, buf)
40
+ return buf.getvalue()
41
+
42
+
43
+ def _atomic_write_text(path, text):
44
+ """Write through a sibling temp file, then rename into place."""
45
+ fd, tmp = tempfile.mkstemp(
46
+ dir=str(path.parent), prefix=f"{path.name}.", suffix=".tmp"
47
+ )
48
+ try:
49
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
50
+ handle.write(text)
51
+ os.replace(tmp, path)
52
+ except BaseException:
53
+ try:
54
+ os.unlink(tmp)
55
+ except OSError:
56
+ pass
57
+ raise
58
+
59
+
60
+ def _atomic_write_bytes(path, data):
61
+ """Write bytes through a sibling temp file, then rename."""
62
+ fd, tmp = tempfile.mkstemp(
63
+ dir=str(path.parent), prefix=f"{path.name}.", suffix=".tmp"
64
+ )
65
+ try:
66
+ with os.fdopen(fd, "wb") as handle:
67
+ handle.write(data)
68
+ os.replace(tmp, path)
69
+ except BaseException:
70
+ try:
71
+ os.unlink(tmp)
72
+ except OSError:
73
+ pass
74
+ raise
75
+
76
+
77
+ def _canonical_doc_path(path):
78
+ """Canonical unix document path; rejects escapes and addresses.
79
+
80
+ Args:
81
+ path: Caller-supplied document path.
82
+
83
+ Returns:
84
+ Canonical slash-joined path with dot and empty segments
85
+ dropped.
86
+
87
+ Raises:
88
+ ProjectError: invalid_path for a non-text, empty, or
89
+ directory path; path_escape for an absolute path or
90
+ one containing `..`. Every message names the supplied
91
+ path.
92
+ """
93
+ if not isinstance(path, str):
94
+ raise ProjectError(
95
+ "invalid_path", f"Document path must be text, got {path!r}."
96
+ )
97
+ unix = to_unix(path)
98
+ if unix == "" or unix.endswith("/"):
99
+ raise ProjectError(
100
+ "invalid_path", f"Bad document path: {path!r}."
101
+ )
102
+ if unix.startswith("/"):
103
+ raise ProjectError(
104
+ "path_escape", f"Absolute document path: {path!r}."
105
+ )
106
+ parts = [p for p in unix.split("/") if p not in ("", ".")]
107
+ if ".." in parts:
108
+ raise ProjectError(
109
+ "path_escape", f"Document path escapes: {path!r}."
110
+ )
111
+ canonical = "/".join(parts)
112
+ if canonical == "":
113
+ raise ProjectError(
114
+ "invalid_path", f"Bad document path: {path!r}."
115
+ )
116
+ return canonical
117
+
118
+
119
+ def _check_reserved_doc(canonical, original):
120
+ """Reject engine-owned document paths.
121
+
122
+ Args:
123
+ canonical: Canonical document path.
124
+ original: Caller-supplied path for error messages.
125
+
126
+ Raises:
127
+ ProjectError: invalid_path when the path names the ledger,
128
+ the event log, or anything under `runs/` or
129
+ `snapshots/`.
130
+ """
131
+ if (
132
+ canonical in _RESERVED_DOCS
133
+ or canonical == "runs"
134
+ or canonical.startswith("runs/")
135
+ or canonical == _SNAPSHOTS_DIRNAME
136
+ or canonical.startswith(_SNAPSHOTS_DIRNAME + "/")
137
+ ):
138
+ raise ProjectError(
139
+ "invalid_path",
140
+ f"Engine-owned path is not a document: {original!r}.",
141
+ )
142
+
143
+
144
+ def _canonical_prefix(prefix):
145
+ """Canonical list filter; empty means no filtering.
146
+
147
+ Args:
148
+ prefix: Caller-supplied prefix or None.
149
+
150
+ Returns:
151
+ Canonical prefix string ("" matches everything).
152
+
153
+ Raises:
154
+ ProjectError: invalid_path for a non-text prefix;
155
+ path_escape for an absolute prefix or one
156
+ containing `..`.
157
+ """
158
+ if prefix is None:
159
+ return ""
160
+ if not isinstance(prefix, str):
161
+ raise ProjectError(
162
+ "invalid_path", f"Prefix must be text, got {prefix!r}."
163
+ )
164
+ unix = to_unix(prefix)
165
+ if unix.startswith("/"):
166
+ raise ProjectError(
167
+ "path_escape", f"Absolute prefix: {prefix!r}."
168
+ )
169
+ parts = [p for p in unix.split("/") if p not in ("", ".")]
170
+ if ".." in parts:
171
+ raise ProjectError(
172
+ "path_escape", f"Prefix escapes: {prefix!r}."
173
+ )
174
+ return "/".join(parts)