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