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,2941 @@
|
|
|
1
|
+
"""Durable project ledger (LEDGER-DURABLE/I30) + document CRUD (CRUD-API/I31).
|
|
2
|
+
|
|
3
|
+
Every engine-mediated mutation advances the revision and appends an
|
|
4
|
+
actor-attributed event; stale `expected_revision` fails with
|
|
5
|
+
`revision_conflict` instead of merging. With `engine_root` the
|
|
6
|
+
ledger persists under `ENGINE_ROOT/projects/<id>/` (`events.yaml`
|
|
7
|
+
+ `ledger.yaml`, both schematized in `layout_schema.yaml`) and
|
|
8
|
+
survives restart; without it the service keeps the previous
|
|
9
|
+
in-memory behavior. Retention trims oldest-first past
|
|
10
|
+
`max_retained_events`; readers that demand contiguity from trimmed
|
|
11
|
+
history get `resync_required` and must re-read from the snapshot.
|
|
12
|
+
|
|
13
|
+
Documents (CRUD-API/I31) live beside the ledger: files under the
|
|
14
|
+
project dir for durable projects, an in-memory map otherwise.
|
|
15
|
+
`ledger.yaml`, `events.yaml`, `runs/`, and `snapshots/` are
|
|
16
|
+
engine-owned and rejected as document paths; everything else —
|
|
17
|
+
including the conventional `components/` and `scripts/` prefixes —
|
|
18
|
+
is a plain document addressed by its unix path. Single
|
|
19
|
+
writes/deletes and the atomic `apply_mutations` batch reuse the
|
|
20
|
+
same admission machinery as `submit` (idempotent request ids,
|
|
21
|
+
revision gate, one new revision, one event, atomic persist).
|
|
22
|
+
|
|
23
|
+
Snapshots (REWIND) capture every head: each accepted mutation
|
|
24
|
+
stores the managed bytes for its previous revision (when missing)
|
|
25
|
+
and its new revision under `snapshots/<revision>/` (an in-memory
|
|
26
|
+
map without an engine root). `rewind` restores the target bytes
|
|
27
|
+
under a new head revision with a `project_rewound` event — history
|
|
28
|
+
stays append-only, never rewritten. Linked caller trees stay
|
|
29
|
+
untouched: only engine-tracked bytes restore, and files outside
|
|
30
|
+
`.rocket/` are reported, never written.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
import threading
|
|
35
|
+
import uuid
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
from ruamel.yaml import YAML
|
|
39
|
+
|
|
40
|
+
from ..ops import editors as _editors
|
|
41
|
+
from ..ops.ports import ecs
|
|
42
|
+
from ..ops.ports import settings as settings_store
|
|
43
|
+
from ..ops.ports import wireplace
|
|
44
|
+
from ..interfaces.paths import join_contained
|
|
45
|
+
from ..interfaces.refs import pin_closure
|
|
46
|
+
|
|
47
|
+
from . import frontend13 as _frontend13
|
|
48
|
+
from .edit import cas as _cas
|
|
49
|
+
from .edit import transactions as _transactions
|
|
50
|
+
from .errors import ProjectError
|
|
51
|
+
from .geometry import ops as _geometry
|
|
52
|
+
from .ip.verbs import _IpMixin
|
|
53
|
+
from .layers import bindings as _layer_bindings
|
|
54
|
+
from .layers import catalog as _layer_catalog
|
|
55
|
+
from .process import model as _process
|
|
56
|
+
from .schematic import hierarchy as _hierarchy
|
|
57
|
+
from .schematic import instances as _instances
|
|
58
|
+
from .schematic import sheets as _sheets
|
|
59
|
+
from .symbols import cells as _cells
|
|
60
|
+
from .symbols import linkage as _linkage
|
|
61
|
+
from .symbols import symbols as _symbols
|
|
62
|
+
from .testbench import blocks as _testbench
|
|
63
|
+
from .ledger.docs import _DocMixin
|
|
64
|
+
from .ledger.entities import _EntityMixin
|
|
65
|
+
from .ledger.events import _EventMixin
|
|
66
|
+
from .ledger.runs import _RunMixin
|
|
67
|
+
from .ledger.snapshots import _SnapshotMixin
|
|
68
|
+
from .ledger.wires import _WireEntityStore as _WireEntityStore
|
|
69
|
+
from .ledger.wires import _WireMixin
|
|
70
|
+
|
|
71
|
+
from .ledger.shared import (
|
|
72
|
+
DEFAULT_MAX_RETAINED_EVENTS,
|
|
73
|
+
_EVENTS_FILENAME,
|
|
74
|
+
_LEDGER_FILENAME,
|
|
75
|
+
_MAX_DOC_BYTES,
|
|
76
|
+
_PROJECT_ID_RE,
|
|
77
|
+
_SETTINGS_DOC_PREFIX,
|
|
78
|
+
_advance_revision,
|
|
79
|
+
_atomic_write_text,
|
|
80
|
+
_canonical_doc_path,
|
|
81
|
+
_canonical_prefix,
|
|
82
|
+
_check_reserved_doc,
|
|
83
|
+
_dump_yaml,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
class ProjectService(_DocMixin, _EntityMixin, _EventMixin, _IpMixin, _RunMixin, _SnapshotMixin, _WireMixin):
|
|
87
|
+
"""Revision-gated ledger with idempotent requests and cursored events.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
project_id: Project this ledger is bound to.
|
|
91
|
+
actor: Attribution recorded on every appended event
|
|
92
|
+
(human / rocket-fuel / system by convention; the engine
|
|
93
|
+
passes its own actor through verbatim).
|
|
94
|
+
current_revision: Starting revision for a fresh ledger (one
|
|
95
|
+
with no durable state yet).
|
|
96
|
+
engine_root: Engine root for durability; the ledger lives at
|
|
97
|
+
`projects/<project_id>/` beneath it (managed or linked
|
|
98
|
+
alike — the ledger is engine truth, never caller-tree
|
|
99
|
+
bytes). None keeps the in-memory behavior.
|
|
100
|
+
max_retained_events: Retention bound; only the newest events
|
|
101
|
+
are kept, trimmed oldest-first on every append.
|
|
102
|
+
linked_root: Caller tree for linked projects; rewind
|
|
103
|
+
reports files outside `.rocket/` and never writes to
|
|
104
|
+
this tree. None means a managed project with no
|
|
105
|
+
external report.
|
|
106
|
+
|
|
107
|
+
Caution: A stale expected_revision fails with revision_conflict
|
|
108
|
+
instead of merging — callers must re-read the snapshot and
|
|
109
|
+
retry at the new head. Reads and mutations reload durable
|
|
110
|
+
state first, so sequential writers through separate handles
|
|
111
|
+
stay ordered; truly concurrent writers still need an
|
|
112
|
+
external lock.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, project_id, actor, current_revision="p12",
|
|
116
|
+
engine_root=None, max_retained_events=None,
|
|
117
|
+
linked_root=None):
|
|
118
|
+
if max_retained_events is None:
|
|
119
|
+
max_retained_events = DEFAULT_MAX_RETAINED_EVENTS
|
|
120
|
+
if (
|
|
121
|
+
not isinstance(max_retained_events, int)
|
|
122
|
+
or isinstance(max_retained_events, bool)
|
|
123
|
+
or max_retained_events < 1
|
|
124
|
+
):
|
|
125
|
+
raise ProjectError(
|
|
126
|
+
"invalid_ledger",
|
|
127
|
+
f"Bad max_retained_events: {max_retained_events!r}.",
|
|
128
|
+
)
|
|
129
|
+
self._project_id = project_id
|
|
130
|
+
self._actor = actor
|
|
131
|
+
self._current_revision = current_revision
|
|
132
|
+
self._max_retained_events = max_retained_events
|
|
133
|
+
# BATCH-PARALLEL: concurrent drains append run/replay events
|
|
134
|
+
# from worker threads — refresh/append/persist runs as one
|
|
135
|
+
# critical section so the durable log never interleaves.
|
|
136
|
+
self._ledger_lock = threading.RLock()
|
|
137
|
+
self._requests = {}
|
|
138
|
+
self._events = []
|
|
139
|
+
self._docs = {}
|
|
140
|
+
self._snapshots = {}
|
|
141
|
+
self._yaml = YAML(typ="safe")
|
|
142
|
+
self._dir = None
|
|
143
|
+
self._linked_root = (
|
|
144
|
+
Path(linked_root) if linked_root is not None else None
|
|
145
|
+
)
|
|
146
|
+
if engine_root is not None:
|
|
147
|
+
if not isinstance(project_id, str) or not _PROJECT_ID_RE.match(
|
|
148
|
+
project_id
|
|
149
|
+
):
|
|
150
|
+
raise ProjectError(
|
|
151
|
+
"invalid_project", f"Bad project id: {project_id!r}."
|
|
152
|
+
)
|
|
153
|
+
self._dir = join_contained(engine_root, "projects", project_id)
|
|
154
|
+
self._load()
|
|
155
|
+
|
|
156
|
+
def submit(self, command_text, request_id, expected_revision=None):
|
|
157
|
+
"""Admit one mutation: replay idempotent resubmits, else gate and log.
|
|
158
|
+
|
|
159
|
+
A resubmitted envelope returns its stored ack without
|
|
160
|
+
advancing; a new envelope gates its expected_revision, and
|
|
161
|
+
only an accepted mutation advances the revision and appends
|
|
162
|
+
the job_queued event.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
command_text: YAML command mapping with an operation.
|
|
166
|
+
request_id: Client envelope id; resubmitting the identical
|
|
167
|
+
text returns the identical ack without advancing.
|
|
168
|
+
expected_revision: Revision the writer read; defaults to
|
|
169
|
+
the command's own expected_revision entry.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Admission ack with run_id, snapshot_id, state queued,
|
|
173
|
+
the advanced current_revision, and the event cursor.
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
ProjectError: invalid_yaml for an unparsable command,
|
|
177
|
+
revision_conflict for a stale expected_revision,
|
|
178
|
+
request_conflict for a reused id with new text.
|
|
179
|
+
|
|
180
|
+
Caution: Every accepted mutation advances the revision — a
|
|
181
|
+
second writer at the old head fails instead of merging.
|
|
182
|
+
"""
|
|
183
|
+
try:
|
|
184
|
+
command = self._yaml.load(command_text)
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
raise ProjectError("invalid_yaml", f"Cannot parse command: {exc}") from exc
|
|
187
|
+
if not isinstance(command, dict) or "operation" not in command:
|
|
188
|
+
raise ProjectError("invalid_yaml", "Command must be a mapping with an operation.")
|
|
189
|
+
self._refresh_durable()
|
|
190
|
+
prev = self._requests.get(request_id)
|
|
191
|
+
if prev is not None:
|
|
192
|
+
prev_text, prev_ack = prev
|
|
193
|
+
if prev_text != command_text:
|
|
194
|
+
raise ProjectError(
|
|
195
|
+
"request_conflict",
|
|
196
|
+
f"Request id reused with different input: {request_id}.",
|
|
197
|
+
)
|
|
198
|
+
return prev_ack
|
|
199
|
+
want = expected_revision if expected_revision is not None else command.get("expected_revision")
|
|
200
|
+
if want != self._current_revision:
|
|
201
|
+
raise ProjectError(
|
|
202
|
+
"revision_conflict",
|
|
203
|
+
f"Expected revision {self._current_revision}, got {want}.",
|
|
204
|
+
)
|
|
205
|
+
self._ensure_snapshot(want)
|
|
206
|
+
advanced = _advance_revision(want)
|
|
207
|
+
self._capture_snapshot(advanced)
|
|
208
|
+
digest = hashlib.sha1(
|
|
209
|
+
f"{self._project_id}:{want}:{command_text}".encode("utf-8")
|
|
210
|
+
).hexdigest()[:12]
|
|
211
|
+
self._current_revision = advanced
|
|
212
|
+
ack = {
|
|
213
|
+
"request_id": request_id,
|
|
214
|
+
"project_id": self._project_id,
|
|
215
|
+
"run_id": uuid.uuid4().hex,
|
|
216
|
+
"snapshot_id": f"snapshot_{digest}",
|
|
217
|
+
"state": "queued",
|
|
218
|
+
"current_revision": self._current_revision,
|
|
219
|
+
}
|
|
220
|
+
self._requests[request_id] = (command_text, ack)
|
|
221
|
+
# Lazy: keeps the project import chain free of the execution
|
|
222
|
+
# package (REPLAY-SAFE content keys).
|
|
223
|
+
from ..execution.replay_safe import content_key_for
|
|
224
|
+
key_arguments = {
|
|
225
|
+
key: value
|
|
226
|
+
for key, value in (command.get("arguments") or {}).items()
|
|
227
|
+
if key != "expected_revision"
|
|
228
|
+
}
|
|
229
|
+
self._append_event(
|
|
230
|
+
"job_queued",
|
|
231
|
+
request_id,
|
|
232
|
+
{
|
|
233
|
+
"run_id": ack["run_id"],
|
|
234
|
+
"revision": self._current_revision,
|
|
235
|
+
"operation": command.get("operation"),
|
|
236
|
+
"content_key": content_key_for(
|
|
237
|
+
command.get("operation"), key_arguments
|
|
238
|
+
),
|
|
239
|
+
},
|
|
240
|
+
)
|
|
241
|
+
ack["event_cursor"] = self._cursor()
|
|
242
|
+
self._persist_after_mutation()
|
|
243
|
+
return ack
|
|
244
|
+
|
|
245
|
+
def snapshot(self):
|
|
246
|
+
"""Read the ledger head: project, revision, and event cursor.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Snapshot mapping with project_id, current_revision, and
|
|
250
|
+
event_cursor.
|
|
251
|
+
"""
|
|
252
|
+
self._refresh_durable()
|
|
253
|
+
return {
|
|
254
|
+
"project_id": self._project_id,
|
|
255
|
+
"current_revision": self._current_revision,
|
|
256
|
+
"event_cursor": self._cursor(),
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
def events_since(self, cursor, require_contiguous_from=None):
|
|
260
|
+
"""Read retained events after a cursor, oldest first.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
cursor: Only events with a higher seq are returned.
|
|
264
|
+
require_contiguous_from: Assert the reader holds
|
|
265
|
+
contiguous history from this seq; fails when the
|
|
266
|
+
history aged out of retention or runs ahead of the
|
|
267
|
+
live cursor.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
Retained events after the cursor, seq-ordered.
|
|
271
|
+
|
|
272
|
+
Raises:
|
|
273
|
+
ProjectError: resync_required when the contiguity demand
|
|
274
|
+
cannot be met — the reader must re-read the snapshot
|
|
275
|
+
plus the full retained log and reset its cursor.
|
|
276
|
+
|
|
277
|
+
Caution: The log is retention-bounded, not infinite — a
|
|
278
|
+
reader gone longer than the bound must resync, never
|
|
279
|
+
assume the gap is empty.
|
|
280
|
+
"""
|
|
281
|
+
self._refresh_durable()
|
|
282
|
+
if (
|
|
283
|
+
require_contiguous_from is not None
|
|
284
|
+
and require_contiguous_from > self._cursor()
|
|
285
|
+
):
|
|
286
|
+
raise ProjectError(
|
|
287
|
+
"resync_required", "Retained history is insufficient; resync."
|
|
288
|
+
)
|
|
289
|
+
if (
|
|
290
|
+
require_contiguous_from is not None
|
|
291
|
+
and require_contiguous_from >= 1
|
|
292
|
+
and self._events
|
|
293
|
+
and require_contiguous_from < self._retained_head()
|
|
294
|
+
):
|
|
295
|
+
raise ProjectError(
|
|
296
|
+
"resync_required", "Retained history is insufficient; resync."
|
|
297
|
+
)
|
|
298
|
+
return [e for e in self._events if e["seq"] > cursor]
|
|
299
|
+
|
|
300
|
+
def list_projects(self):
|
|
301
|
+
"""List projects under the engine root, bound always included.
|
|
302
|
+
|
|
303
|
+
Without an engine root the bound in-memory project is the
|
|
304
|
+
whole list; with one, every managed project dir plus the
|
|
305
|
+
bound project is reported with its head revision.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Mapping with the bound project id and the sorted
|
|
309
|
+
project entries (project_id + current_revision).
|
|
310
|
+
|
|
311
|
+
Raises:
|
|
312
|
+
ProjectError: invalid_ledger when a sibling ledger
|
|
313
|
+
head is unreadable or corrupt.
|
|
314
|
+
|
|
315
|
+
Caution: A corrupt sibling ledger fails the whole list
|
|
316
|
+
loudly instead of being skipped.
|
|
317
|
+
"""
|
|
318
|
+
self._refresh_durable()
|
|
319
|
+
if self._dir is None:
|
|
320
|
+
return {
|
|
321
|
+
"project_id": self._project_id,
|
|
322
|
+
"projects": [
|
|
323
|
+
{
|
|
324
|
+
"project_id": self._project_id,
|
|
325
|
+
"current_revision": self._current_revision,
|
|
326
|
+
}
|
|
327
|
+
],
|
|
328
|
+
}
|
|
329
|
+
parent = self._dir.parent
|
|
330
|
+
try:
|
|
331
|
+
names = sorted(
|
|
332
|
+
p.name for p in parent.iterdir() if p.is_dir()
|
|
333
|
+
)
|
|
334
|
+
except FileNotFoundError:
|
|
335
|
+
names = []
|
|
336
|
+
seen = {n for n in names if _PROJECT_ID_RE.match(n)}
|
|
337
|
+
seen.add(self._project_id)
|
|
338
|
+
entries = []
|
|
339
|
+
for pid in sorted(seen):
|
|
340
|
+
if pid == self._project_id:
|
|
341
|
+
entries.append(
|
|
342
|
+
{
|
|
343
|
+
"project_id": pid,
|
|
344
|
+
"current_revision": self._current_revision,
|
|
345
|
+
}
|
|
346
|
+
)
|
|
347
|
+
continue
|
|
348
|
+
ledger_path = parent / pid / _LEDGER_FILENAME
|
|
349
|
+
try:
|
|
350
|
+
text = ledger_path.read_text(encoding="utf-8")
|
|
351
|
+
except FileNotFoundError:
|
|
352
|
+
entries.append(
|
|
353
|
+
{"project_id": pid, "current_revision": "p12"}
|
|
354
|
+
)
|
|
355
|
+
continue
|
|
356
|
+
except OSError as exc:
|
|
357
|
+
raise ProjectError(
|
|
358
|
+
"invalid_ledger",
|
|
359
|
+
f"Cannot read ledger for {pid}: {exc}.",
|
|
360
|
+
) from exc
|
|
361
|
+
try:
|
|
362
|
+
data = self._yaml.load(text)
|
|
363
|
+
except Exception as exc:
|
|
364
|
+
raise ProjectError(
|
|
365
|
+
"invalid_ledger",
|
|
366
|
+
f"Cannot parse ledger for {pid}: {exc}.",
|
|
367
|
+
) from exc
|
|
368
|
+
revision = (
|
|
369
|
+
data.get("current_revision")
|
|
370
|
+
if isinstance(data, dict)
|
|
371
|
+
else None
|
|
372
|
+
)
|
|
373
|
+
if not isinstance(revision, str) or not revision:
|
|
374
|
+
raise ProjectError(
|
|
375
|
+
"invalid_ledger",
|
|
376
|
+
f"Ledger head carries no revision: {pid}.",
|
|
377
|
+
)
|
|
378
|
+
entries.append(
|
|
379
|
+
{"project_id": pid, "current_revision": revision}
|
|
380
|
+
)
|
|
381
|
+
return {"project_id": self._project_id, "projects": entries}
|
|
382
|
+
|
|
383
|
+
def create_project(self, new_project_id, request_id, command_text):
|
|
384
|
+
"""Create one managed project at revision p12.
|
|
385
|
+
|
|
386
|
+
The new ledger starts clean (no events); the response still
|
|
387
|
+
carries the approval-UI fields with a None prior revision.
|
|
388
|
+
Idempotent replays return the stored response instead of
|
|
389
|
+
failing as duplicates.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
new_project_id: Slug for the project to create.
|
|
393
|
+
request_id: Client envelope id for idempotent replay.
|
|
394
|
+
command_text: YAML command text for identity comparison.
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
Creation mapping with the new project id, p12 head,
|
|
398
|
+
actor, diff summary, revision transition, rewind
|
|
399
|
+
handle, empty events, and request id.
|
|
400
|
+
|
|
401
|
+
Raises:
|
|
402
|
+
ProjectError: invalid_command without an engine root;
|
|
403
|
+
invalid_project for a non-slug id; project_exists
|
|
404
|
+
for a duplicate; request_conflict for a reused id
|
|
405
|
+
with new text; invalid_ledger when the project
|
|
406
|
+
dir cannot be written.
|
|
407
|
+
|
|
408
|
+
Caution: Creating the bound project itself is a duplicate
|
|
409
|
+
once its dir exists — create-then-list, never assume.
|
|
410
|
+
"""
|
|
411
|
+
self._refresh_durable()
|
|
412
|
+
replay = self._check_replay(request_id, command_text)
|
|
413
|
+
if replay is not None:
|
|
414
|
+
return replay
|
|
415
|
+
if self._dir is None:
|
|
416
|
+
raise ProjectError(
|
|
417
|
+
"invalid_command", "create_project needs engine_root."
|
|
418
|
+
)
|
|
419
|
+
if not isinstance(new_project_id, str) or not _PROJECT_ID_RE.match(
|
|
420
|
+
new_project_id
|
|
421
|
+
):
|
|
422
|
+
raise ProjectError(
|
|
423
|
+
"invalid_project", f"Bad project id: {new_project_id!r}."
|
|
424
|
+
)
|
|
425
|
+
new_dir = self._dir.parent / new_project_id
|
|
426
|
+
if new_dir.exists():
|
|
427
|
+
raise ProjectError(
|
|
428
|
+
"project_exists",
|
|
429
|
+
f"Project already exists: {new_project_id}.",
|
|
430
|
+
)
|
|
431
|
+
try:
|
|
432
|
+
new_dir.mkdir(parents=True, exist_ok=False)
|
|
433
|
+
(new_dir / "runs").mkdir(parents=True, exist_ok=True)
|
|
434
|
+
except OSError as exc:
|
|
435
|
+
raise ProjectError(
|
|
436
|
+
"invalid_ledger",
|
|
437
|
+
f"Cannot create project {new_project_id}: {exc}.",
|
|
438
|
+
) from exc
|
|
439
|
+
try:
|
|
440
|
+
_atomic_write_text(new_dir / _EVENTS_FILENAME, _dump_yaml([]))
|
|
441
|
+
_atomic_write_text(
|
|
442
|
+
new_dir / _LEDGER_FILENAME,
|
|
443
|
+
_dump_yaml(
|
|
444
|
+
{
|
|
445
|
+
"project_id": new_project_id,
|
|
446
|
+
"current_revision": "p12",
|
|
447
|
+
"requests": {},
|
|
448
|
+
}
|
|
449
|
+
),
|
|
450
|
+
)
|
|
451
|
+
except OSError as exc:
|
|
452
|
+
raise ProjectError(
|
|
453
|
+
"invalid_ledger",
|
|
454
|
+
f"Cannot init project {new_project_id}: {exc}.",
|
|
455
|
+
) from exc
|
|
456
|
+
response = {
|
|
457
|
+
"project_id": new_project_id,
|
|
458
|
+
"current_revision": "p12",
|
|
459
|
+
"previous_revision": None,
|
|
460
|
+
"revision_transition": {"from": None, "to": "p12"},
|
|
461
|
+
"actor": self._actor,
|
|
462
|
+
"diff_summary": f"created project {new_project_id}",
|
|
463
|
+
"rewind_handle": {
|
|
464
|
+
"project_id": new_project_id,
|
|
465
|
+
"revision": None,
|
|
466
|
+
"request_id": request_id,
|
|
467
|
+
},
|
|
468
|
+
"events": [],
|
|
469
|
+
"event_cursor": 0,
|
|
470
|
+
"request_id": request_id,
|
|
471
|
+
}
|
|
472
|
+
self._requests[request_id] = (command_text, response)
|
|
473
|
+
self._persist_after_mutation()
|
|
474
|
+
return response
|
|
475
|
+
|
|
476
|
+
def list_docs(self, prefix=None):
|
|
477
|
+
"""List documents, optionally filtered by a path prefix.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
prefix: Optional unix path prefix; empty matches
|
|
481
|
+
everything.
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
Mapping with project id, head revision, event cursor,
|
|
485
|
+
and sorted document entries (path + bytes).
|
|
486
|
+
|
|
487
|
+
Raises:
|
|
488
|
+
ProjectError: invalid_path for a non-text prefix;
|
|
489
|
+
path_escape for an escaping prefix.
|
|
490
|
+
"""
|
|
491
|
+
self._refresh_durable()
|
|
492
|
+
want = _canonical_prefix(prefix)
|
|
493
|
+
documents = []
|
|
494
|
+
for path, size in self._iter_doc_files():
|
|
495
|
+
if want and not (
|
|
496
|
+
path == want or path.startswith(want + "/")
|
|
497
|
+
):
|
|
498
|
+
continue
|
|
499
|
+
documents.append({"path": path, "bytes": size})
|
|
500
|
+
return {
|
|
501
|
+
"project_id": self._project_id,
|
|
502
|
+
"current_revision": self._current_revision,
|
|
503
|
+
"event_cursor": self._cursor(),
|
|
504
|
+
"documents": documents,
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
def read_doc(self, path):
|
|
508
|
+
"""Read one document as text at the current head.
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
path: Document path to read.
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
Mapping with project id, head revision, canonical
|
|
515
|
+
path, content text, and byte size.
|
|
516
|
+
|
|
517
|
+
Raises:
|
|
518
|
+
ProjectError: invalid_path or path_escape for a bad
|
|
519
|
+
path; unknown_document for a missing document;
|
|
520
|
+
invalid_command when the path names a directory;
|
|
521
|
+
invalid_document when durable bytes are not text.
|
|
522
|
+
"""
|
|
523
|
+
self._refresh_durable()
|
|
524
|
+
canonical = _canonical_doc_path(path)
|
|
525
|
+
_check_reserved_doc(canonical, path)
|
|
526
|
+
content = self._read_doc_text(canonical, path)
|
|
527
|
+
if content is None:
|
|
528
|
+
raise ProjectError(
|
|
529
|
+
"unknown_document", f"No such document: {path}."
|
|
530
|
+
)
|
|
531
|
+
return {
|
|
532
|
+
"project_id": self._project_id,
|
|
533
|
+
"current_revision": self._current_revision,
|
|
534
|
+
"path": canonical,
|
|
535
|
+
"content": content,
|
|
536
|
+
"bytes": len(content.encode("utf-8")),
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
def read_settings(self, kind):
|
|
540
|
+
"""Read one settings kind at the current head.
|
|
541
|
+
|
|
542
|
+
Absent files read as defaults (absent-default-is-clean):
|
|
543
|
+
a fresh project needs no seeding write. Stored files
|
|
544
|
+
migrate in memory to the known head; future files fail
|
|
545
|
+
instead of serving truth this engine cannot validate.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
kind: Settings kind (chip.manifest, design.rules,
|
|
549
|
+
net.classes, text.vars, lib.tables).
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
Mapping with project id, head revision, kind, stored
|
|
553
|
+
flag, version, and the settings mapping.
|
|
554
|
+
|
|
555
|
+
Raises:
|
|
556
|
+
ProjectError: invalid_command for an unknown kind or
|
|
557
|
+
stored settings that break the kind schema;
|
|
558
|
+
invalid_document for stored settings that do not
|
|
559
|
+
parse; future_format for a file newer than the
|
|
560
|
+
head; missing_migration when no link covers the
|
|
561
|
+
stored version.
|
|
562
|
+
"""
|
|
563
|
+
self._refresh_durable()
|
|
564
|
+
if kind not in settings_store.CURRENT_VERSIONS:
|
|
565
|
+
raise ProjectError(
|
|
566
|
+
"invalid_command", f"Unknown settings kind: {kind}."
|
|
567
|
+
)
|
|
568
|
+
canonical = _canonical_doc_path(f"{_SETTINGS_DOC_PREFIX}{kind}.yaml")
|
|
569
|
+
text = self._read_doc_text(canonical, canonical)
|
|
570
|
+
if text is None:
|
|
571
|
+
return {
|
|
572
|
+
"project_id": self._project_id,
|
|
573
|
+
"current_revision": self._current_revision,
|
|
574
|
+
"kind": kind,
|
|
575
|
+
"stored": False,
|
|
576
|
+
"version": settings_store.CURRENT_VERSIONS[kind],
|
|
577
|
+
"settings": settings_store.default_settings(kind),
|
|
578
|
+
}
|
|
579
|
+
try:
|
|
580
|
+
data = self._yaml.load(text)
|
|
581
|
+
except Exception as exc:
|
|
582
|
+
raise ProjectError(
|
|
583
|
+
"invalid_document",
|
|
584
|
+
f"Stored {kind} settings do not parse: {exc}.",
|
|
585
|
+
) from exc
|
|
586
|
+
if not isinstance(data, dict):
|
|
587
|
+
raise ProjectError(
|
|
588
|
+
"invalid_document",
|
|
589
|
+
f"Stored {kind} settings must be a mapping.",
|
|
590
|
+
)
|
|
591
|
+
try:
|
|
592
|
+
migrated = settings_store.migrate(kind, data)
|
|
593
|
+
except settings_store.SettingsError as exc:
|
|
594
|
+
if exc.code in ("future_format", "missing_migration"):
|
|
595
|
+
raise ProjectError(exc.code, str(exc)) from exc
|
|
596
|
+
raise ProjectError(
|
|
597
|
+
"invalid_command",
|
|
598
|
+
f"Stored {kind} settings are invalid: {exc}.",
|
|
599
|
+
) from exc
|
|
600
|
+
return {
|
|
601
|
+
"project_id": self._project_id,
|
|
602
|
+
"current_revision": self._current_revision,
|
|
603
|
+
"kind": kind,
|
|
604
|
+
"stored": True,
|
|
605
|
+
"version": migrated["meta"]["version"],
|
|
606
|
+
"settings": migrated,
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
def resolved_config(self, session_id=None, run_id=None):
|
|
610
|
+
"""Bundle the resolved config for one session/run fetch.
|
|
611
|
+
|
|
612
|
+
All five kinds in one call — clients render from this
|
|
613
|
+
snapshot instead of per-event round-trips. Absent files
|
|
614
|
+
resolve to defaults; library refs stay unresolved here
|
|
615
|
+
(callers resolve lib.tables refs against their own roots).
|
|
616
|
+
|
|
617
|
+
Args:
|
|
618
|
+
session_id: Client session this snapshot serves.
|
|
619
|
+
run_id: Run this snapshot was resolved for, if any.
|
|
620
|
+
|
|
621
|
+
Returns:
|
|
622
|
+
Snapshot mapping with project id, head revision,
|
|
623
|
+
session/run ids, per-kind versions, all five settings
|
|
624
|
+
mappings, and library refs.
|
|
625
|
+
|
|
626
|
+
Raises:
|
|
627
|
+
ProjectError: future_format when any stored file is
|
|
628
|
+
newer than the head; missing_migration when no
|
|
629
|
+
link covers a stored version; invalid_document
|
|
630
|
+
for stored settings that do not parse.
|
|
631
|
+
"""
|
|
632
|
+
self._refresh_durable()
|
|
633
|
+
by_kind = {}
|
|
634
|
+
for kind in settings_store.CURRENT_VERSIONS:
|
|
635
|
+
read = self.read_settings(kind)
|
|
636
|
+
by_kind[read["kind"]] = read["settings"]
|
|
637
|
+
snapshot = settings_store.snapshot_settings(
|
|
638
|
+
by_kind, session_id=session_id, run_id=run_id
|
|
639
|
+
)
|
|
640
|
+
snapshot["project_id"] = self._project_id
|
|
641
|
+
snapshot["current_revision"] = self._current_revision
|
|
642
|
+
return snapshot
|
|
643
|
+
|
|
644
|
+
def get_component(self, entity, component):
|
|
645
|
+
"""Read one entity component payload at the current head.
|
|
646
|
+
|
|
647
|
+
Entities project over plain documents: the record lives at
|
|
648
|
+
entities/<entity>.json and reads through the same bytes
|
|
649
|
+
read_doc serves. Backfilled doc entities serve their
|
|
650
|
+
projected doc component here, so every world-snapshot
|
|
651
|
+
entry stays addressable.
|
|
652
|
+
|
|
653
|
+
Args:
|
|
654
|
+
entity: Entity id holding the component.
|
|
655
|
+
component: Component name to read.
|
|
656
|
+
|
|
657
|
+
Returns:
|
|
658
|
+
Mapping with project id, head revision, entity,
|
|
659
|
+
component, canonical address, storage path, the
|
|
660
|
+
component data, and the entity revision.
|
|
661
|
+
|
|
662
|
+
Raises:
|
|
663
|
+
ProjectError: invalid_entity or invalid_component for
|
|
664
|
+
malformed parts; unknown_entity for a missing
|
|
665
|
+
entity; unknown_component for a missing component;
|
|
666
|
+
invalid_document for a corrupt entity record.
|
|
667
|
+
"""
|
|
668
|
+
self._refresh_durable()
|
|
669
|
+
try:
|
|
670
|
+
ecs.validate_entity_id(entity)
|
|
671
|
+
ecs.validate_component_name(component)
|
|
672
|
+
except ecs.EcsError as exc:
|
|
673
|
+
raise ProjectError(exc.code, str(exc)) from exc
|
|
674
|
+
if ecs.is_backfill_id(entity):
|
|
675
|
+
return self._read_backfill_component(entity, component)
|
|
676
|
+
path, decoded = self._read_entity_record(entity)
|
|
677
|
+
if component not in decoded["components"]:
|
|
678
|
+
raise ProjectError(
|
|
679
|
+
"unknown_component",
|
|
680
|
+
f"No component {component!r} on {entity}.",
|
|
681
|
+
)
|
|
682
|
+
return {
|
|
683
|
+
"address": ecs.format_address(entity, component),
|
|
684
|
+
"component": component,
|
|
685
|
+
"current_revision": self._current_revision,
|
|
686
|
+
"data": decoded["components"][component],
|
|
687
|
+
"entity": entity,
|
|
688
|
+
"entity_revision": self._entity_revision_of(decoded),
|
|
689
|
+
"path": path,
|
|
690
|
+
"project_id": self._project_id,
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
def components_of(self, entity):
|
|
694
|
+
"""List the component names one entity carries.
|
|
695
|
+
|
|
696
|
+
Args:
|
|
697
|
+
entity: Entity id to inspect (backfilled ids serve
|
|
698
|
+
their projected doc component).
|
|
699
|
+
|
|
700
|
+
Returns:
|
|
701
|
+
Mapping with project id, head revision, entity,
|
|
702
|
+
storage path, and sorted component names.
|
|
703
|
+
|
|
704
|
+
Raises:
|
|
705
|
+
ProjectError: invalid_entity for a malformed id;
|
|
706
|
+
unknown_entity for a missing entity;
|
|
707
|
+
invalid_document for a corrupt entity record.
|
|
708
|
+
"""
|
|
709
|
+
self._refresh_durable()
|
|
710
|
+
try:
|
|
711
|
+
ecs.validate_entity_id(entity)
|
|
712
|
+
except ecs.EcsError as exc:
|
|
713
|
+
raise ProjectError(exc.code, str(exc)) from exc
|
|
714
|
+
if ecs.is_backfill_id(entity):
|
|
715
|
+
record = self._find_backfill(entity)
|
|
716
|
+
return {
|
|
717
|
+
"components": sorted(record["components"]),
|
|
718
|
+
"current_revision": self._current_revision,
|
|
719
|
+
"entity": entity,
|
|
720
|
+
"path": record["components"][
|
|
721
|
+
ecs.BACKFILL_COMPONENT
|
|
722
|
+
]["path"],
|
|
723
|
+
"project_id": self._project_id,
|
|
724
|
+
}
|
|
725
|
+
path, decoded = self._read_entity_record(entity)
|
|
726
|
+
return {
|
|
727
|
+
"components": sorted(decoded["components"]),
|
|
728
|
+
"current_revision": self._current_revision,
|
|
729
|
+
"entity": entity,
|
|
730
|
+
"path": path,
|
|
731
|
+
"project_id": self._project_id,
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
def entities_with(self, component, predicate=None):
|
|
735
|
+
"""Filter entities to holders of one component.
|
|
736
|
+
|
|
737
|
+
Nets-of-block, pins-of-net, and open-problems become
|
|
738
|
+
filters here, not endpoints; backfilled docs answer the
|
|
739
|
+
doc component, so current project files stay filterable
|
|
740
|
+
without migration.
|
|
741
|
+
|
|
742
|
+
Args:
|
|
743
|
+
component: Component name holders must carry.
|
|
744
|
+
predicate: Exact-match mapping the component payload
|
|
745
|
+
must satisfy, or None for no filtering.
|
|
746
|
+
|
|
747
|
+
Returns:
|
|
748
|
+
Mapping with project id, head revision, component,
|
|
749
|
+
predicate, and sorted holder ids.
|
|
750
|
+
|
|
751
|
+
Raises:
|
|
752
|
+
ProjectError: invalid_component for a malformed
|
|
753
|
+
component name; invalid_predicate for a
|
|
754
|
+
non-mapping predicate; invalid_document for a
|
|
755
|
+
corrupt entity record.
|
|
756
|
+
"""
|
|
757
|
+
self._refresh_durable()
|
|
758
|
+
try:
|
|
759
|
+
ecs.validate_component_name(component)
|
|
760
|
+
ecs.validate_predicate(predicate)
|
|
761
|
+
except ecs.EcsError as exc:
|
|
762
|
+
raise ProjectError(exc.code, str(exc)) from exc
|
|
763
|
+
records = self._entity_records()
|
|
764
|
+
if component == ecs.BACKFILL_COMPONENT:
|
|
765
|
+
records.extend(self._backfill_records())
|
|
766
|
+
holders = ecs.filter_entities(
|
|
767
|
+
records, component, predicate
|
|
768
|
+
)
|
|
769
|
+
return {
|
|
770
|
+
"component": component,
|
|
771
|
+
"current_revision": self._current_revision,
|
|
772
|
+
"entities": holders,
|
|
773
|
+
"predicate": predicate,
|
|
774
|
+
"project_id": self._project_id,
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
def world_snapshot(self, include_backfill=True):
|
|
778
|
+
"""Bundle the miniplex-shaped world snapshot for mirrors.
|
|
779
|
+
|
|
780
|
+
A pure read: snapshotting never writes and never advances
|
|
781
|
+
the revision, so backfill cannot break any revision. Each
|
|
782
|
+
entry maps one-to-one onto a miniplex entity (id plus its
|
|
783
|
+
components), so mirrors load it directly.
|
|
784
|
+
|
|
785
|
+
Args:
|
|
786
|
+
include_backfill: Project current plain docs as
|
|
787
|
+
read-only virtual entities alongside engine
|
|
788
|
+
entities; engine entities only when False.
|
|
789
|
+
|
|
790
|
+
Returns:
|
|
791
|
+
Snapshot mapping with project id, head revision, and
|
|
792
|
+
entities sorted by id (each with id, kind, and
|
|
793
|
+
components).
|
|
794
|
+
|
|
795
|
+
Raises:
|
|
796
|
+
ProjectError: invalid_command for a non-bool flag;
|
|
797
|
+
invalid_document for a corrupt entity record.
|
|
798
|
+
"""
|
|
799
|
+
self._refresh_durable()
|
|
800
|
+
if not isinstance(include_backfill, bool):
|
|
801
|
+
raise ProjectError(
|
|
802
|
+
"invalid_command",
|
|
803
|
+
"include_backfill must be a bool, "
|
|
804
|
+
f"got {include_backfill!r}.",
|
|
805
|
+
)
|
|
806
|
+
records = self._entity_records()
|
|
807
|
+
if include_backfill:
|
|
808
|
+
records.extend(self._backfill_records())
|
|
809
|
+
return ecs.build_world_snapshot(
|
|
810
|
+
self._project_id, self._current_revision, records
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
def write_doc(self, path, content, request_id, command_text,
|
|
814
|
+
expected_revision=None):
|
|
815
|
+
"""Write one document: gate, store, advance, log.
|
|
816
|
+
|
|
817
|
+
A resubmitted envelope returns its stored response without
|
|
818
|
+
advancing; a new envelope gates its expected_revision, and
|
|
819
|
+
only an accepted write advances the revision and appends
|
|
820
|
+
the doc_written event.
|
|
821
|
+
|
|
822
|
+
Args:
|
|
823
|
+
path: Document path to create or overwrite.
|
|
824
|
+
content: UTF-8 text to store (1 MB limit).
|
|
825
|
+
request_id: Client envelope id; resubmitting the
|
|
826
|
+
identical text returns the identical response
|
|
827
|
+
without advancing.
|
|
828
|
+
command_text: YAML command text for identity comparison.
|
|
829
|
+
expected_revision: Revision the writer read.
|
|
830
|
+
|
|
831
|
+
Returns:
|
|
832
|
+
Mutation mapping with path, revision transition, new
|
|
833
|
+
head, actor, diff summary, rewind handle, the
|
|
834
|
+
appended event, cursor, and request id.
|
|
835
|
+
|
|
836
|
+
Raises:
|
|
837
|
+
ProjectError: request_conflict for a reused id with
|
|
838
|
+
new text; invalid_path or path_escape for a bad
|
|
839
|
+
path; invalid_command for non-text or oversize
|
|
840
|
+
content or a directory path; revision_conflict
|
|
841
|
+
for a stale head; invalid_ledger when the durable
|
|
842
|
+
write fails.
|
|
843
|
+
|
|
844
|
+
Caution: Every accepted write advances the revision — a
|
|
845
|
+
second writer at the old head fails instead of
|
|
846
|
+
merging.
|
|
847
|
+
"""
|
|
848
|
+
self._refresh_durable()
|
|
849
|
+
replay = self._check_replay(request_id, command_text)
|
|
850
|
+
if replay is not None:
|
|
851
|
+
return replay
|
|
852
|
+
canonical = _canonical_doc_path(path)
|
|
853
|
+
_check_reserved_doc(canonical, path)
|
|
854
|
+
if not isinstance(content, str):
|
|
855
|
+
raise ProjectError(
|
|
856
|
+
"invalid_command",
|
|
857
|
+
f"Write needs text content for document {path}.",
|
|
858
|
+
)
|
|
859
|
+
if len(content.encode("utf-8")) > _MAX_DOC_BYTES:
|
|
860
|
+
raise ProjectError(
|
|
861
|
+
"invalid_command",
|
|
862
|
+
f"Document exceeds the size limit: {path}.",
|
|
863
|
+
)
|
|
864
|
+
self._gate_revision(expected_revision)
|
|
865
|
+
previous = self._current_revision
|
|
866
|
+
self._ensure_snapshot(previous)
|
|
867
|
+
old = self._read_doc_text(canonical, path)
|
|
868
|
+
self._write_doc_text(canonical, content, path)
|
|
869
|
+
advanced = _advance_revision(previous)
|
|
870
|
+
try:
|
|
871
|
+
self._capture_snapshot(advanced)
|
|
872
|
+
except ProjectError:
|
|
873
|
+
try:
|
|
874
|
+
if old is None:
|
|
875
|
+
self._delete_doc_path(canonical, path)
|
|
876
|
+
else:
|
|
877
|
+
self._write_doc_text(canonical, old, path)
|
|
878
|
+
except Exception:
|
|
879
|
+
pass
|
|
880
|
+
raise
|
|
881
|
+
self._current_revision = advanced
|
|
882
|
+
new_bytes = len(content.encode("utf-8"))
|
|
883
|
+
if old is None:
|
|
884
|
+
diff = f"created {canonical} ({new_bytes} bytes)"
|
|
885
|
+
else:
|
|
886
|
+
diff = (
|
|
887
|
+
f"updated {canonical} "
|
|
888
|
+
f"({len(old.encode('utf-8'))} -> {new_bytes} bytes)"
|
|
889
|
+
)
|
|
890
|
+
record = self._append_event(
|
|
891
|
+
"doc_written",
|
|
892
|
+
request_id,
|
|
893
|
+
{
|
|
894
|
+
"revision": self._current_revision,
|
|
895
|
+
"operation": "write_doc",
|
|
896
|
+
"path": canonical,
|
|
897
|
+
"bytes": new_bytes,
|
|
898
|
+
"diff_summary": diff,
|
|
899
|
+
},
|
|
900
|
+
)
|
|
901
|
+
response = {
|
|
902
|
+
"project_id": self._project_id,
|
|
903
|
+
"path": canonical,
|
|
904
|
+
"previous_revision": previous,
|
|
905
|
+
"current_revision": self._current_revision,
|
|
906
|
+
"revision_transition": {
|
|
907
|
+
"from": previous,
|
|
908
|
+
"to": self._current_revision,
|
|
909
|
+
},
|
|
910
|
+
"actor": self._actor,
|
|
911
|
+
"diff_summary": diff,
|
|
912
|
+
"rewind_handle": {
|
|
913
|
+
"project_id": self._project_id,
|
|
914
|
+
"revision": previous,
|
|
915
|
+
"request_id": request_id,
|
|
916
|
+
},
|
|
917
|
+
"events": [record],
|
|
918
|
+
"event_cursor": self._cursor(),
|
|
919
|
+
"request_id": request_id,
|
|
920
|
+
}
|
|
921
|
+
self._requests[request_id] = (command_text, response)
|
|
922
|
+
self._persist_after_mutation()
|
|
923
|
+
return response
|
|
924
|
+
|
|
925
|
+
def write_settings(self, kind, settings, request_id, command_text,
|
|
926
|
+
expected_revision=None):
|
|
927
|
+
"""Write one settings kind: validate, gate, merge, log.
|
|
928
|
+
|
|
929
|
+
Full replacement of the kind's truth, persisted as the
|
|
930
|
+
settings/<kind>.yaml document through the same admission
|
|
931
|
+
machinery as write_doc (idempotent request ids, revision
|
|
932
|
+
gate, one new revision, one event, atomic persist, rewind
|
|
933
|
+
snapshots). The write merges onto the stored bytes so
|
|
934
|
+
unknown keys survive; data maps replace wholesale, so
|
|
935
|
+
text.vars deletes propagate. Unchanged bytes skip the
|
|
936
|
+
write: no new revision, no event.
|
|
937
|
+
|
|
938
|
+
A resubmitted envelope returns its stored response without
|
|
939
|
+
advancing.
|
|
940
|
+
|
|
941
|
+
Args:
|
|
942
|
+
kind: Settings kind (chip.manifest, design.rules,
|
|
943
|
+
net.classes, text.vars, lib.tables).
|
|
944
|
+
settings: Complete settings mapping at the head
|
|
945
|
+
version.
|
|
946
|
+
request_id: Client envelope id; resubmitting the
|
|
947
|
+
identical text returns the identical response
|
|
948
|
+
without advancing.
|
|
949
|
+
command_text: YAML command text for identity comparison.
|
|
950
|
+
expected_revision: Revision the writer read.
|
|
951
|
+
|
|
952
|
+
Returns:
|
|
953
|
+
Mutation mapping with path, revision transition, new
|
|
954
|
+
head, actor, diff summary, rewind handle, the
|
|
955
|
+
appended event, cursor, request id, written flag, and
|
|
956
|
+
settings version.
|
|
957
|
+
|
|
958
|
+
Raises:
|
|
959
|
+
ProjectError: request_conflict for a reused id with
|
|
960
|
+
new text; invalid_command for an unknown kind or
|
|
961
|
+
settings that break the kind schema;
|
|
962
|
+
revision_conflict for a stale head; future_format
|
|
963
|
+
when the stored file is newer than the head;
|
|
964
|
+
invalid_document for stored settings that do not
|
|
965
|
+
parse; invalid_ledger when the durable write fails.
|
|
966
|
+
|
|
967
|
+
Caution: Every accepted write advances the revision — a
|
|
968
|
+
second writer at the old head fails instead of
|
|
969
|
+
merging — and text.vars writes delete vars the new
|
|
970
|
+
map omits.
|
|
971
|
+
"""
|
|
972
|
+
self._refresh_durable()
|
|
973
|
+
replay = self._check_replay(request_id, command_text)
|
|
974
|
+
if replay is not None:
|
|
975
|
+
return replay
|
|
976
|
+
try:
|
|
977
|
+
settings_store.validate_settings(kind, settings)
|
|
978
|
+
except settings_store.SettingsError as exc:
|
|
979
|
+
raise ProjectError(
|
|
980
|
+
"invalid_command", f"Bad {kind} settings: {exc}."
|
|
981
|
+
) from exc
|
|
982
|
+
self._gate_revision(expected_revision)
|
|
983
|
+
canonical = _canonical_doc_path(f"{_SETTINGS_DOC_PREFIX}{kind}.yaml")
|
|
984
|
+
old_text = self._read_doc_text(canonical, canonical)
|
|
985
|
+
old_data = None
|
|
986
|
+
if old_text is not None:
|
|
987
|
+
try:
|
|
988
|
+
old_data = self._yaml.load(old_text)
|
|
989
|
+
except Exception as exc:
|
|
990
|
+
raise ProjectError(
|
|
991
|
+
"invalid_document",
|
|
992
|
+
f"Stored {kind} settings do not parse: {exc}.",
|
|
993
|
+
) from exc
|
|
994
|
+
if not isinstance(old_data, dict):
|
|
995
|
+
raise ProjectError(
|
|
996
|
+
"invalid_document",
|
|
997
|
+
f"Stored {kind} settings must be a mapping.",
|
|
998
|
+
)
|
|
999
|
+
old_version = settings_store.settings_version(kind, old_data)
|
|
1000
|
+
if old_version > settings_store.settings_version(kind, settings):
|
|
1001
|
+
raise ProjectError(
|
|
1002
|
+
"future_format",
|
|
1003
|
+
f"Stored {kind} settings are newer than this "
|
|
1004
|
+
"engine knows; refusing autosave.",
|
|
1005
|
+
)
|
|
1006
|
+
merged = settings_store.merge_settings(kind, old_data, settings)
|
|
1007
|
+
text = settings_store.dump_settings_text(merged)
|
|
1008
|
+
version = settings_store.settings_version(kind, merged)
|
|
1009
|
+
if old_text is not None and text == old_text:
|
|
1010
|
+
response = {
|
|
1011
|
+
"project_id": self._project_id,
|
|
1012
|
+
"path": canonical,
|
|
1013
|
+
"previous_revision": self._current_revision,
|
|
1014
|
+
"current_revision": self._current_revision,
|
|
1015
|
+
"revision_transition": {
|
|
1016
|
+
"from": self._current_revision,
|
|
1017
|
+
"to": self._current_revision,
|
|
1018
|
+
},
|
|
1019
|
+
"actor": self._actor,
|
|
1020
|
+
"diff_summary": f"unchanged {canonical}",
|
|
1021
|
+
"rewind_handle": {
|
|
1022
|
+
"project_id": self._project_id,
|
|
1023
|
+
"revision": self._current_revision,
|
|
1024
|
+
"request_id": request_id,
|
|
1025
|
+
},
|
|
1026
|
+
"events": [],
|
|
1027
|
+
"event_cursor": self._cursor(),
|
|
1028
|
+
"request_id": request_id,
|
|
1029
|
+
"written": False,
|
|
1030
|
+
"settings_version": version,
|
|
1031
|
+
}
|
|
1032
|
+
self._requests[request_id] = (command_text, response)
|
|
1033
|
+
self._persist_after_mutation()
|
|
1034
|
+
return response
|
|
1035
|
+
response = self.write_doc(
|
|
1036
|
+
canonical, text, request_id, command_text,
|
|
1037
|
+
expected_revision=expected_revision,
|
|
1038
|
+
)
|
|
1039
|
+
response["written"] = True
|
|
1040
|
+
response["settings_version"] = version
|
|
1041
|
+
return response
|
|
1042
|
+
|
|
1043
|
+
def create_entity(self, request_id, command_text, kind="",
|
|
1044
|
+
components=None, expected_revision=None):
|
|
1045
|
+
"""Create one entity with an engine-minted id.
|
|
1046
|
+
|
|
1047
|
+
The id is minted, never supplied: there is no id argument,
|
|
1048
|
+
so callers cannot collide with, guess, or squat on engine
|
|
1049
|
+
identity. The record persists as entities/<id>.json through
|
|
1050
|
+
the same admission machinery as write_doc (idempotent
|
|
1051
|
+
request ids, revision gate, one new revision, one event,
|
|
1052
|
+
atomic persist, rewind snapshots).
|
|
1053
|
+
|
|
1054
|
+
A resubmitted envelope returns its stored response without
|
|
1055
|
+
advancing.
|
|
1056
|
+
|
|
1057
|
+
Args:
|
|
1058
|
+
request_id: Client envelope id; resubmitting the
|
|
1059
|
+
identical text returns the identical response
|
|
1060
|
+
without advancing.
|
|
1061
|
+
command_text: YAML command text for identity comparison.
|
|
1062
|
+
kind: Kind label carried beside the components.
|
|
1063
|
+
components: Optional initial component mapping.
|
|
1064
|
+
expected_revision: Accepted and ignored (ENTITY-CAS):
|
|
1065
|
+
minted ids cannot collide, so creates never
|
|
1066
|
+
conflict — concurrent creates always land with
|
|
1067
|
+
distinct ids.
|
|
1068
|
+
|
|
1069
|
+
Returns:
|
|
1070
|
+
Mutation mapping with the minted entity, kind, storage
|
|
1071
|
+
path, revision transition, new head, actor, diff
|
|
1072
|
+
summary, rewind handle, the entity_created event,
|
|
1073
|
+
cursor, request id, initial component names, and the
|
|
1074
|
+
entity_revision (1).
|
|
1075
|
+
|
|
1076
|
+
Raises:
|
|
1077
|
+
ProjectError: request_conflict for a reused id with
|
|
1078
|
+
new text; invalid_kind, invalid_component, or
|
|
1079
|
+
invalid_data for malformed parts;
|
|
1080
|
+
invalid_ledger when the durable write fails.
|
|
1081
|
+
|
|
1082
|
+
Caution: Every accepted create advances the revision, but
|
|
1083
|
+
never fails on a stale head — identity is minted, so
|
|
1084
|
+
there is nothing to merge or conflict with.
|
|
1085
|
+
"""
|
|
1086
|
+
self._refresh_durable()
|
|
1087
|
+
replay = self._check_replay(request_id, command_text)
|
|
1088
|
+
if replay is not None:
|
|
1089
|
+
return replay
|
|
1090
|
+
try:
|
|
1091
|
+
ecs.validate_kind(kind)
|
|
1092
|
+
if components is not None and not isinstance(
|
|
1093
|
+
components, dict
|
|
1094
|
+
):
|
|
1095
|
+
raise ecs.EcsError(
|
|
1096
|
+
"invalid_data",
|
|
1097
|
+
"Initial components must be a mapping.",
|
|
1098
|
+
)
|
|
1099
|
+
initial = dict(components or {})
|
|
1100
|
+
for name, data in initial.items():
|
|
1101
|
+
ecs.validate_component_name(name)
|
|
1102
|
+
ecs.validate_component_data(data)
|
|
1103
|
+
except ecs.EcsError as exc:
|
|
1104
|
+
raise ProjectError(exc.code, str(exc)) from exc
|
|
1105
|
+
# No head gate: minted identity cannot collide, so concurrent
|
|
1106
|
+
# creates never conflict (ENTITY-CAS).
|
|
1107
|
+
previous = self._current_revision
|
|
1108
|
+
self._ensure_snapshot(previous)
|
|
1109
|
+
taken = set()
|
|
1110
|
+
for rel, _size in self._iter_doc_files():
|
|
1111
|
+
found = ecs.entity_from_doc_path(rel)
|
|
1112
|
+
if found is not None:
|
|
1113
|
+
taken.add(found)
|
|
1114
|
+
entity = ecs.mint_entity_id(existing=taken)
|
|
1115
|
+
path = ecs.entity_doc_path(entity)
|
|
1116
|
+
self._store_entity_record(path, entity, kind, initial, 1)
|
|
1117
|
+
advanced = _advance_revision(previous)
|
|
1118
|
+
try:
|
|
1119
|
+
self._capture_snapshot(advanced)
|
|
1120
|
+
except ProjectError:
|
|
1121
|
+
try:
|
|
1122
|
+
self._delete_doc_path(path, path)
|
|
1123
|
+
except Exception:
|
|
1124
|
+
pass
|
|
1125
|
+
raise
|
|
1126
|
+
self._current_revision = advanced
|
|
1127
|
+
diff = f"created entity {entity} ({path})"
|
|
1128
|
+
record = self._append_event(
|
|
1129
|
+
"entity_created",
|
|
1130
|
+
request_id,
|
|
1131
|
+
{
|
|
1132
|
+
"components": sorted(initial),
|
|
1133
|
+
"entity": entity,
|
|
1134
|
+
"kind": kind,
|
|
1135
|
+
"revision": self._current_revision,
|
|
1136
|
+
},
|
|
1137
|
+
)
|
|
1138
|
+
response = self._ecs_response(
|
|
1139
|
+
request_id, previous, diff, record,
|
|
1140
|
+
{
|
|
1141
|
+
"components": sorted(initial),
|
|
1142
|
+
"entity": entity,
|
|
1143
|
+
"entity_revision": 1,
|
|
1144
|
+
"kind": kind,
|
|
1145
|
+
"path": path,
|
|
1146
|
+
},
|
|
1147
|
+
)
|
|
1148
|
+
self._requests[request_id] = (command_text, response)
|
|
1149
|
+
self._persist_after_mutation()
|
|
1150
|
+
return response
|
|
1151
|
+
|
|
1152
|
+
def set_component(self, entity, component, data, request_id,
|
|
1153
|
+
command_text, expected_revision=None,
|
|
1154
|
+
expected_entity_revision=None):
|
|
1155
|
+
"""Upsert one component payload (create or overwrite).
|
|
1156
|
+
|
|
1157
|
+
Persists through the entity record at entities/<entity>.json
|
|
1158
|
+
with the same admission machinery as write_doc, and appends
|
|
1159
|
+
component_changed with entity, component, and revision.
|
|
1160
|
+
|
|
1161
|
+
A resubmitted envelope returns its stored response without
|
|
1162
|
+
advancing.
|
|
1163
|
+
|
|
1164
|
+
Args:
|
|
1165
|
+
entity: Entity id holding the component.
|
|
1166
|
+
component: Component name to write.
|
|
1167
|
+
data: JSON payload to store.
|
|
1168
|
+
request_id: Client envelope id; resubmitting the
|
|
1169
|
+
identical text returns the identical response
|
|
1170
|
+
without advancing.
|
|
1171
|
+
command_text: YAML command text for identity comparison.
|
|
1172
|
+
expected_revision: Revision the writer read.
|
|
1173
|
+
expected_entity_revision: Entity revision the writer
|
|
1174
|
+
read (ENTITY-CAS). Naming a current one skips the
|
|
1175
|
+
global head gate even beside a stale head, so
|
|
1176
|
+
writers on different entities never conflict; a
|
|
1177
|
+
stale entity revision fails with revision_conflict.
|
|
1178
|
+
|
|
1179
|
+
Returns:
|
|
1180
|
+
Mutation mapping with entity, component, canonical
|
|
1181
|
+
address, storage path, revision transition, new head,
|
|
1182
|
+
actor, diff summary, rewind handle, the
|
|
1183
|
+
component_changed event, cursor, request id, and the
|
|
1184
|
+
bumped entity_revision.
|
|
1185
|
+
|
|
1186
|
+
Raises:
|
|
1187
|
+
ProjectError: request_conflict for a reused id with
|
|
1188
|
+
new text; invalid_entity, invalid_component, or
|
|
1189
|
+
invalid_data for malformed parts; unknown_entity
|
|
1190
|
+
for a missing entity; backfill_readonly for a
|
|
1191
|
+
backfilled id; invalid_document for a corrupt
|
|
1192
|
+
entity record; revision_conflict for a stale head
|
|
1193
|
+
or a stale entity revision; invalid_ledger when
|
|
1194
|
+
the durable write fails.
|
|
1195
|
+
|
|
1196
|
+
Caution: Every accepted write advances the revision — a
|
|
1197
|
+
second writer at the old head fails instead of
|
|
1198
|
+
merging, unless it names a current entity
|
|
1199
|
+
revision.
|
|
1200
|
+
"""
|
|
1201
|
+
self._refresh_durable()
|
|
1202
|
+
replay = self._check_replay(request_id, command_text)
|
|
1203
|
+
if replay is not None:
|
|
1204
|
+
return replay
|
|
1205
|
+
try:
|
|
1206
|
+
ecs.validate_entity_id(entity)
|
|
1207
|
+
ecs.validate_component_name(component)
|
|
1208
|
+
ecs.validate_component_data(data)
|
|
1209
|
+
except ecs.EcsError as exc:
|
|
1210
|
+
raise ProjectError(exc.code, str(exc)) from exc
|
|
1211
|
+
if ecs.is_backfill_id(entity):
|
|
1212
|
+
raise ProjectError(
|
|
1213
|
+
"backfill_readonly",
|
|
1214
|
+
f"Backfilled entity is read-only: {entity}.",
|
|
1215
|
+
)
|
|
1216
|
+
path, decoded = self._read_entity_record(entity)
|
|
1217
|
+
live = self._gate_entity_revision(
|
|
1218
|
+
entity, decoded, expected_entity_revision
|
|
1219
|
+
)
|
|
1220
|
+
self._gate_entity_write(expected_revision, expected_entity_revision)
|
|
1221
|
+
previous = self._current_revision
|
|
1222
|
+
self._ensure_snapshot(previous)
|
|
1223
|
+
existed = component in decoded["components"]
|
|
1224
|
+
merged = dict(decoded["components"])
|
|
1225
|
+
merged[component] = data
|
|
1226
|
+
bumped = live + 1
|
|
1227
|
+
old_text = self._read_doc_text(path, path)
|
|
1228
|
+
self._store_entity_record(
|
|
1229
|
+
path, entity, decoded["kind"], merged, bumped
|
|
1230
|
+
)
|
|
1231
|
+
advanced = _advance_revision(previous)
|
|
1232
|
+
try:
|
|
1233
|
+
self._capture_snapshot(advanced)
|
|
1234
|
+
except ProjectError:
|
|
1235
|
+
try:
|
|
1236
|
+
self._write_doc_text(path, old_text, path)
|
|
1237
|
+
except Exception:
|
|
1238
|
+
pass
|
|
1239
|
+
raise
|
|
1240
|
+
self._current_revision = advanced
|
|
1241
|
+
verb = "updated" if existed else "set"
|
|
1242
|
+
address = ecs.format_address(entity, component)
|
|
1243
|
+
diff = f"{verb} {address} ({path})"
|
|
1244
|
+
record = self._append_event(
|
|
1245
|
+
"component_changed",
|
|
1246
|
+
request_id,
|
|
1247
|
+
ecs.component_changed_data(
|
|
1248
|
+
entity, component, self._current_revision,
|
|
1249
|
+
entity_revision=bumped,
|
|
1250
|
+
),
|
|
1251
|
+
)
|
|
1252
|
+
response = self._ecs_response(
|
|
1253
|
+
request_id, previous, diff, record,
|
|
1254
|
+
{
|
|
1255
|
+
"address": address,
|
|
1256
|
+
"component": component,
|
|
1257
|
+
"entity": entity,
|
|
1258
|
+
"entity_revision": bumped,
|
|
1259
|
+
"path": path,
|
|
1260
|
+
},
|
|
1261
|
+
)
|
|
1262
|
+
self._requests[request_id] = (command_text, response)
|
|
1263
|
+
self._persist_after_mutation()
|
|
1264
|
+
return response
|
|
1265
|
+
|
|
1266
|
+
def attach_component(self, entity, component, data, request_id,
|
|
1267
|
+
command_text, expected_revision=None,
|
|
1268
|
+
expected_entity_revision=None):
|
|
1269
|
+
"""Attach one new component (refuses overwrites).
|
|
1270
|
+
|
|
1271
|
+
Like set_component, but a component already living on the
|
|
1272
|
+
entity fails instead of overwriting — attach means add.
|
|
1273
|
+
|
|
1274
|
+
A resubmitted envelope returns its stored response without
|
|
1275
|
+
advancing.
|
|
1276
|
+
|
|
1277
|
+
Args:
|
|
1278
|
+
entity: Entity id holding the component.
|
|
1279
|
+
component: Component name to attach.
|
|
1280
|
+
data: JSON payload to store.
|
|
1281
|
+
request_id: Client envelope id; resubmitting the
|
|
1282
|
+
identical text returns the identical response
|
|
1283
|
+
without advancing.
|
|
1284
|
+
command_text: YAML command text for identity comparison.
|
|
1285
|
+
expected_revision: Revision the writer read.
|
|
1286
|
+
expected_entity_revision: Entity revision the writer
|
|
1287
|
+
read (ENTITY-CAS); naming a current one skips the
|
|
1288
|
+
global head gate even beside a stale head.
|
|
1289
|
+
|
|
1290
|
+
Returns:
|
|
1291
|
+
Mutation mapping with entity, component, canonical
|
|
1292
|
+
address, storage path, revision transition, new head,
|
|
1293
|
+
actor, diff summary, rewind handle, the
|
|
1294
|
+
component_changed event, cursor, request id, and the
|
|
1295
|
+
bumped entity_revision.
|
|
1296
|
+
|
|
1297
|
+
Raises:
|
|
1298
|
+
ProjectError: component_exists when the component
|
|
1299
|
+
already lives on the entity; otherwise like
|
|
1300
|
+
set_component.
|
|
1301
|
+
|
|
1302
|
+
Caution: Every accepted attach advances the revision — a
|
|
1303
|
+
second writer at the old head fails instead of
|
|
1304
|
+
merging, unless it names a current entity
|
|
1305
|
+
revision.
|
|
1306
|
+
"""
|
|
1307
|
+
self._refresh_durable()
|
|
1308
|
+
replay = self._check_replay(request_id, command_text)
|
|
1309
|
+
if replay is not None:
|
|
1310
|
+
return replay
|
|
1311
|
+
try:
|
|
1312
|
+
ecs.validate_entity_id(entity)
|
|
1313
|
+
ecs.validate_component_name(component)
|
|
1314
|
+
ecs.validate_component_data(data)
|
|
1315
|
+
except ecs.EcsError as exc:
|
|
1316
|
+
raise ProjectError(exc.code, str(exc)) from exc
|
|
1317
|
+
if ecs.is_backfill_id(entity):
|
|
1318
|
+
raise ProjectError(
|
|
1319
|
+
"backfill_readonly",
|
|
1320
|
+
f"Backfilled entity is read-only: {entity}.",
|
|
1321
|
+
)
|
|
1322
|
+
path, decoded = self._read_entity_record(entity)
|
|
1323
|
+
if component in decoded["components"]:
|
|
1324
|
+
raise ProjectError(
|
|
1325
|
+
"component_exists",
|
|
1326
|
+
"Component already attached: "
|
|
1327
|
+
f"{ecs.format_address(entity, component)}.",
|
|
1328
|
+
)
|
|
1329
|
+
live = self._gate_entity_revision(
|
|
1330
|
+
entity, decoded, expected_entity_revision
|
|
1331
|
+
)
|
|
1332
|
+
self._gate_entity_write(expected_revision, expected_entity_revision)
|
|
1333
|
+
previous = self._current_revision
|
|
1334
|
+
self._ensure_snapshot(previous)
|
|
1335
|
+
merged = dict(decoded["components"])
|
|
1336
|
+
merged[component] = data
|
|
1337
|
+
bumped = live + 1
|
|
1338
|
+
old_text = self._read_doc_text(path, path)
|
|
1339
|
+
self._store_entity_record(
|
|
1340
|
+
path, entity, decoded["kind"], merged, bumped
|
|
1341
|
+
)
|
|
1342
|
+
advanced = _advance_revision(previous)
|
|
1343
|
+
try:
|
|
1344
|
+
self._capture_snapshot(advanced)
|
|
1345
|
+
except ProjectError:
|
|
1346
|
+
try:
|
|
1347
|
+
self._write_doc_text(path, old_text, path)
|
|
1348
|
+
except Exception:
|
|
1349
|
+
pass
|
|
1350
|
+
raise
|
|
1351
|
+
self._current_revision = advanced
|
|
1352
|
+
address = ecs.format_address(entity, component)
|
|
1353
|
+
diff = f"attached {address} ({path})"
|
|
1354
|
+
record = self._append_event(
|
|
1355
|
+
"component_changed",
|
|
1356
|
+
request_id,
|
|
1357
|
+
ecs.component_changed_data(
|
|
1358
|
+
entity, component, self._current_revision,
|
|
1359
|
+
entity_revision=bumped,
|
|
1360
|
+
),
|
|
1361
|
+
)
|
|
1362
|
+
response = self._ecs_response(
|
|
1363
|
+
request_id, previous, diff, record,
|
|
1364
|
+
{
|
|
1365
|
+
"address": address,
|
|
1366
|
+
"component": component,
|
|
1367
|
+
"entity": entity,
|
|
1368
|
+
"entity_revision": bumped,
|
|
1369
|
+
"path": path,
|
|
1370
|
+
},
|
|
1371
|
+
)
|
|
1372
|
+
self._requests[request_id] = (command_text, response)
|
|
1373
|
+
self._persist_after_mutation()
|
|
1374
|
+
return response
|
|
1375
|
+
|
|
1376
|
+
def detach_component(self, entity, component, request_id,
|
|
1377
|
+
command_text, expected_revision=None,
|
|
1378
|
+
expected_entity_revision=None):
|
|
1379
|
+
"""Detach one component from its entity.
|
|
1380
|
+
|
|
1381
|
+
Persists through the entity record with the same admission
|
|
1382
|
+
machinery as write_doc; the component_changed event carries
|
|
1383
|
+
detached True.
|
|
1384
|
+
|
|
1385
|
+
A resubmitted envelope returns its stored response without
|
|
1386
|
+
advancing.
|
|
1387
|
+
|
|
1388
|
+
Args:
|
|
1389
|
+
entity: Entity id holding the component.
|
|
1390
|
+
component: Component name to remove.
|
|
1391
|
+
request_id: Client envelope id; resubmitting the
|
|
1392
|
+
identical text returns the identical response
|
|
1393
|
+
without advancing.
|
|
1394
|
+
command_text: YAML command text for identity comparison.
|
|
1395
|
+
expected_revision: Revision the writer read.
|
|
1396
|
+
expected_entity_revision: Entity revision the writer
|
|
1397
|
+
read (ENTITY-CAS).
|
|
1398
|
+
|
|
1399
|
+
Returns:
|
|
1400
|
+
Mutation mapping with entity, component, canonical
|
|
1401
|
+
address, storage path, revision transition, new head,
|
|
1402
|
+
actor, diff summary, rewind handle, the
|
|
1403
|
+
component_changed event, cursor, request id, and the
|
|
1404
|
+
bumped entity_revision.
|
|
1405
|
+
|
|
1406
|
+
Raises:
|
|
1407
|
+
ProjectError: unknown_component for a missing
|
|
1408
|
+
component; otherwise like set_component without
|
|
1409
|
+
the data errors.
|
|
1410
|
+
|
|
1411
|
+
Caution: Every accepted detach advances the revision — a
|
|
1412
|
+
second writer at the old head fails instead of
|
|
1413
|
+
merging, unless it names a current entity
|
|
1414
|
+
revision.
|
|
1415
|
+
"""
|
|
1416
|
+
self._refresh_durable()
|
|
1417
|
+
replay = self._check_replay(request_id, command_text)
|
|
1418
|
+
if replay is not None:
|
|
1419
|
+
return replay
|
|
1420
|
+
try:
|
|
1421
|
+
ecs.validate_entity_id(entity)
|
|
1422
|
+
ecs.validate_component_name(component)
|
|
1423
|
+
except ecs.EcsError as exc:
|
|
1424
|
+
raise ProjectError(exc.code, str(exc)) from exc
|
|
1425
|
+
if ecs.is_backfill_id(entity):
|
|
1426
|
+
raise ProjectError(
|
|
1427
|
+
"backfill_readonly",
|
|
1428
|
+
f"Backfilled entity is read-only: {entity}.",
|
|
1429
|
+
)
|
|
1430
|
+
path, decoded = self._read_entity_record(entity)
|
|
1431
|
+
if component not in decoded["components"]:
|
|
1432
|
+
raise ProjectError(
|
|
1433
|
+
"unknown_component",
|
|
1434
|
+
f"No component {component!r} on {entity}.",
|
|
1435
|
+
)
|
|
1436
|
+
live = self._gate_entity_revision(
|
|
1437
|
+
entity, decoded, expected_entity_revision
|
|
1438
|
+
)
|
|
1439
|
+
self._gate_entity_write(expected_revision, expected_entity_revision)
|
|
1440
|
+
previous = self._current_revision
|
|
1441
|
+
self._ensure_snapshot(previous)
|
|
1442
|
+
merged = dict(decoded["components"])
|
|
1443
|
+
del merged[component]
|
|
1444
|
+
bumped = live + 1
|
|
1445
|
+
old_text = self._read_doc_text(path, path)
|
|
1446
|
+
self._store_entity_record(
|
|
1447
|
+
path, entity, decoded["kind"], merged, bumped
|
|
1448
|
+
)
|
|
1449
|
+
advanced = _advance_revision(previous)
|
|
1450
|
+
try:
|
|
1451
|
+
self._capture_snapshot(advanced)
|
|
1452
|
+
except ProjectError:
|
|
1453
|
+
try:
|
|
1454
|
+
self._write_doc_text(path, old_text, path)
|
|
1455
|
+
except Exception:
|
|
1456
|
+
pass
|
|
1457
|
+
raise
|
|
1458
|
+
self._current_revision = advanced
|
|
1459
|
+
address = ecs.format_address(entity, component)
|
|
1460
|
+
diff = f"detached {address} ({path})"
|
|
1461
|
+
record = self._append_event(
|
|
1462
|
+
"component_changed",
|
|
1463
|
+
request_id,
|
|
1464
|
+
ecs.component_changed_data(
|
|
1465
|
+
entity, component, self._current_revision,
|
|
1466
|
+
detached=True, entity_revision=bumped,
|
|
1467
|
+
),
|
|
1468
|
+
)
|
|
1469
|
+
response = self._ecs_response(
|
|
1470
|
+
request_id, previous, diff, record,
|
|
1471
|
+
{
|
|
1472
|
+
"address": address,
|
|
1473
|
+
"component": component,
|
|
1474
|
+
"entity": entity,
|
|
1475
|
+
"entity_revision": bumped,
|
|
1476
|
+
"path": path,
|
|
1477
|
+
},
|
|
1478
|
+
)
|
|
1479
|
+
self._requests[request_id] = (command_text, response)
|
|
1480
|
+
self._persist_after_mutation()
|
|
1481
|
+
return response
|
|
1482
|
+
|
|
1483
|
+
def delete_entity(self, entity, request_id, command_text,
|
|
1484
|
+
expected_revision=None,
|
|
1485
|
+
expected_entity_revision=None):
|
|
1486
|
+
"""Delete one entity and its projected record.
|
|
1487
|
+
|
|
1488
|
+
Removes entities/<entity>.json through the same admission
|
|
1489
|
+
machinery as delete_doc, and appends entity_deleted.
|
|
1490
|
+
|
|
1491
|
+
A resubmitted envelope returns its stored response without
|
|
1492
|
+
advancing.
|
|
1493
|
+
|
|
1494
|
+
Args:
|
|
1495
|
+
entity: Entity id to delete.
|
|
1496
|
+
request_id: Client envelope id; resubmitting the
|
|
1497
|
+
identical text returns the identical response
|
|
1498
|
+
without advancing.
|
|
1499
|
+
command_text: YAML command text for identity comparison.
|
|
1500
|
+
expected_revision: Revision the writer read.
|
|
1501
|
+
expected_entity_revision: Entity revision the writer
|
|
1502
|
+
read (ENTITY-CAS).
|
|
1503
|
+
|
|
1504
|
+
Returns:
|
|
1505
|
+
Mutation mapping with entity, storage path, revision
|
|
1506
|
+
transition, new head, actor, diff summary, rewind
|
|
1507
|
+
handle, the entity_deleted event, cursor, request id,
|
|
1508
|
+
and the deleted entity_revision.
|
|
1509
|
+
|
|
1510
|
+
Raises:
|
|
1511
|
+
ProjectError: request_conflict for a reused id with
|
|
1512
|
+
new text; invalid_entity for a malformed id;
|
|
1513
|
+
unknown_entity for a missing entity;
|
|
1514
|
+
backfill_readonly for a backfilled id;
|
|
1515
|
+
invalid_document for a corrupt entity record;
|
|
1516
|
+
revision_conflict for a stale head or a stale
|
|
1517
|
+
entity revision; invalid_ledger
|
|
1518
|
+
when the durable delete fails.
|
|
1519
|
+
|
|
1520
|
+
Caution: Deleting advances the revision like any mutation
|
|
1521
|
+
— stale writers fail instead of merging, unless they
|
|
1522
|
+
name a current entity revision.
|
|
1523
|
+
"""
|
|
1524
|
+
self._refresh_durable()
|
|
1525
|
+
replay = self._check_replay(request_id, command_text)
|
|
1526
|
+
if replay is not None:
|
|
1527
|
+
return replay
|
|
1528
|
+
try:
|
|
1529
|
+
ecs.validate_entity_id(entity)
|
|
1530
|
+
except ecs.EcsError as exc:
|
|
1531
|
+
raise ProjectError(exc.code, str(exc)) from exc
|
|
1532
|
+
if ecs.is_backfill_id(entity):
|
|
1533
|
+
raise ProjectError(
|
|
1534
|
+
"backfill_readonly",
|
|
1535
|
+
f"Backfilled entity is read-only: {entity}.",
|
|
1536
|
+
)
|
|
1537
|
+
path, decoded = self._read_entity_record(entity)
|
|
1538
|
+
live = self._gate_entity_revision(
|
|
1539
|
+
entity, decoded, expected_entity_revision
|
|
1540
|
+
)
|
|
1541
|
+
self._gate_entity_write(expected_revision, expected_entity_revision)
|
|
1542
|
+
previous = self._current_revision
|
|
1543
|
+
self._ensure_snapshot(previous)
|
|
1544
|
+
old_text = self._read_doc_text(path, path)
|
|
1545
|
+
self._delete_doc_path(path, path)
|
|
1546
|
+
advanced = _advance_revision(previous)
|
|
1547
|
+
try:
|
|
1548
|
+
self._capture_snapshot(advanced)
|
|
1549
|
+
except ProjectError:
|
|
1550
|
+
try:
|
|
1551
|
+
self._write_doc_text(path, old_text, path)
|
|
1552
|
+
except Exception:
|
|
1553
|
+
pass
|
|
1554
|
+
raise
|
|
1555
|
+
self._current_revision = advanced
|
|
1556
|
+
diff = f"deleted entity {entity} ({path})"
|
|
1557
|
+
record = self._append_event(
|
|
1558
|
+
"entity_deleted",
|
|
1559
|
+
request_id,
|
|
1560
|
+
{"entity": entity, "revision": self._current_revision},
|
|
1561
|
+
)
|
|
1562
|
+
response = self._ecs_response(
|
|
1563
|
+
request_id, previous, diff, record,
|
|
1564
|
+
{"entity": entity, "entity_revision": live, "path": path},
|
|
1565
|
+
)
|
|
1566
|
+
self._requests[request_id] = (command_text, response)
|
|
1567
|
+
self._persist_after_mutation()
|
|
1568
|
+
return response
|
|
1569
|
+
|
|
1570
|
+
def delete_doc(self, path, request_id, command_text,
|
|
1571
|
+
expected_revision=None):
|
|
1572
|
+
"""Delete one document: gate, remove, advance, log.
|
|
1573
|
+
|
|
1574
|
+
Args:
|
|
1575
|
+
path: Document path to delete.
|
|
1576
|
+
request_id: Client envelope id for idempotent replay.
|
|
1577
|
+
command_text: YAML command text for identity comparison.
|
|
1578
|
+
expected_revision: Revision the writer read.
|
|
1579
|
+
|
|
1580
|
+
Returns:
|
|
1581
|
+
Mutation mapping with path, revision transition, new
|
|
1582
|
+
head, actor, diff summary, rewind handle, the
|
|
1583
|
+
appended event, cursor, and request id.
|
|
1584
|
+
|
|
1585
|
+
Raises:
|
|
1586
|
+
ProjectError: request_conflict for a reused id with
|
|
1587
|
+
new text; invalid_path or path_escape for a bad
|
|
1588
|
+
path; unknown_document for a missing document;
|
|
1589
|
+
invalid_command when the path names a directory;
|
|
1590
|
+
revision_conflict for a stale head; invalid_ledger
|
|
1591
|
+
when the durable delete fails.
|
|
1592
|
+
|
|
1593
|
+
Caution: Deleting advances the revision like any mutation
|
|
1594
|
+
— stale writers fail instead of merging.
|
|
1595
|
+
"""
|
|
1596
|
+
self._refresh_durable()
|
|
1597
|
+
replay = self._check_replay(request_id, command_text)
|
|
1598
|
+
if replay is not None:
|
|
1599
|
+
return replay
|
|
1600
|
+
canonical = _canonical_doc_path(path)
|
|
1601
|
+
_check_reserved_doc(canonical, path)
|
|
1602
|
+
old = self._read_doc_text(canonical, path)
|
|
1603
|
+
if old is None:
|
|
1604
|
+
raise ProjectError(
|
|
1605
|
+
"unknown_document", f"No such document: {path}."
|
|
1606
|
+
)
|
|
1607
|
+
self._gate_revision(expected_revision)
|
|
1608
|
+
previous = self._current_revision
|
|
1609
|
+
self._ensure_snapshot(previous)
|
|
1610
|
+
self._delete_doc_path(canonical, path)
|
|
1611
|
+
advanced = _advance_revision(previous)
|
|
1612
|
+
try:
|
|
1613
|
+
self._capture_snapshot(advanced)
|
|
1614
|
+
except ProjectError:
|
|
1615
|
+
try:
|
|
1616
|
+
self._write_doc_text(canonical, old, path)
|
|
1617
|
+
except Exception:
|
|
1618
|
+
pass
|
|
1619
|
+
raise
|
|
1620
|
+
self._current_revision = advanced
|
|
1621
|
+
diff = f"deleted {canonical} ({len(old.encode('utf-8'))} bytes)"
|
|
1622
|
+
record = self._append_event(
|
|
1623
|
+
"doc_deleted",
|
|
1624
|
+
request_id,
|
|
1625
|
+
{
|
|
1626
|
+
"revision": self._current_revision,
|
|
1627
|
+
"operation": "delete_doc",
|
|
1628
|
+
"path": canonical,
|
|
1629
|
+
"bytes": len(old.encode("utf-8")),
|
|
1630
|
+
"diff_summary": diff,
|
|
1631
|
+
},
|
|
1632
|
+
)
|
|
1633
|
+
response = {
|
|
1634
|
+
"project_id": self._project_id,
|
|
1635
|
+
"path": canonical,
|
|
1636
|
+
"previous_revision": previous,
|
|
1637
|
+
"current_revision": self._current_revision,
|
|
1638
|
+
"revision_transition": {
|
|
1639
|
+
"from": previous,
|
|
1640
|
+
"to": self._current_revision,
|
|
1641
|
+
},
|
|
1642
|
+
"actor": self._actor,
|
|
1643
|
+
"diff_summary": diff,
|
|
1644
|
+
"rewind_handle": {
|
|
1645
|
+
"project_id": self._project_id,
|
|
1646
|
+
"revision": previous,
|
|
1647
|
+
"request_id": request_id,
|
|
1648
|
+
},
|
|
1649
|
+
"events": [record],
|
|
1650
|
+
"event_cursor": self._cursor(),
|
|
1651
|
+
"request_id": request_id,
|
|
1652
|
+
}
|
|
1653
|
+
self._requests[request_id] = (command_text, response)
|
|
1654
|
+
self._persist_after_mutation()
|
|
1655
|
+
return response
|
|
1656
|
+
|
|
1657
|
+
def apply_mutations(self, mutations, request_id, command_text,
|
|
1658
|
+
expected_revision=None):
|
|
1659
|
+
"""Apply a batch atomically under one new revision (I31).
|
|
1660
|
+
|
|
1661
|
+
Every mutation is validated before anything lands: any
|
|
1662
|
+
failure names its document and leaves the revision, the
|
|
1663
|
+
files, and the log untouched. Only a fully valid batch at
|
|
1664
|
+
the live head advances once and appends a single
|
|
1665
|
+
mutations_applied event.
|
|
1666
|
+
|
|
1667
|
+
Args:
|
|
1668
|
+
mutations: Non-empty list of {op, path, content?}
|
|
1669
|
+
mappings; op is write (needs text content) or
|
|
1670
|
+
delete (takes none).
|
|
1671
|
+
request_id: Client envelope id for idempotent replay.
|
|
1672
|
+
command_text: YAML command text for identity comparison.
|
|
1673
|
+
expected_revision: Revision the writer read.
|
|
1674
|
+
|
|
1675
|
+
Returns:
|
|
1676
|
+
Mutation mapping with revision transition, new head,
|
|
1677
|
+
actor, joined diff summary, rewind handle, the single
|
|
1678
|
+
appended event, per-mutation results, cursor, and
|
|
1679
|
+
request id.
|
|
1680
|
+
|
|
1681
|
+
Raises:
|
|
1682
|
+
ProjectError: request_conflict for a reused id with
|
|
1683
|
+
new text; invalid_command for a malformed batch
|
|
1684
|
+
(names the document when one is addressable);
|
|
1685
|
+
invalid_path or path_escape for a bad path;
|
|
1686
|
+
unknown_document for a delete of a missing
|
|
1687
|
+
document; revision_conflict for a stale head;
|
|
1688
|
+
invalid_ledger when a durable write fails (best
|
|
1689
|
+
effort rollback already attempted).
|
|
1690
|
+
|
|
1691
|
+
Caution: All-or-nothing means one revision for the whole
|
|
1692
|
+
batch — a half-landed batch is a bug, never a state.
|
|
1693
|
+
"""
|
|
1694
|
+
self._refresh_durable()
|
|
1695
|
+
replay = self._check_replay(request_id, command_text)
|
|
1696
|
+
if replay is not None:
|
|
1697
|
+
return replay
|
|
1698
|
+
if not isinstance(mutations, list) or not mutations:
|
|
1699
|
+
raise ProjectError(
|
|
1700
|
+
"invalid_command",
|
|
1701
|
+
"apply_mutations needs a non-empty mutations list.",
|
|
1702
|
+
)
|
|
1703
|
+
prepared = []
|
|
1704
|
+
for index, item in enumerate(mutations):
|
|
1705
|
+
if not isinstance(item, dict):
|
|
1706
|
+
raise ProjectError(
|
|
1707
|
+
"invalid_command",
|
|
1708
|
+
f"Mutation {index} must be a mapping.",
|
|
1709
|
+
)
|
|
1710
|
+
op = item.get("op")
|
|
1711
|
+
raw_path = item.get("path")
|
|
1712
|
+
if op not in ("write", "delete"):
|
|
1713
|
+
if isinstance(raw_path, str):
|
|
1714
|
+
raise ProjectError(
|
|
1715
|
+
"invalid_command",
|
|
1716
|
+
f"Unknown op {op!r} for document {raw_path}.",
|
|
1717
|
+
)
|
|
1718
|
+
raise ProjectError(
|
|
1719
|
+
"invalid_command",
|
|
1720
|
+
f"Mutation {index} needs op write/delete.",
|
|
1721
|
+
)
|
|
1722
|
+
if not isinstance(raw_path, str):
|
|
1723
|
+
raise ProjectError(
|
|
1724
|
+
"invalid_command",
|
|
1725
|
+
f"Mutation {index} needs a text path.",
|
|
1726
|
+
)
|
|
1727
|
+
canonical = _canonical_doc_path(raw_path)
|
|
1728
|
+
_check_reserved_doc(canonical, raw_path)
|
|
1729
|
+
if op == "write":
|
|
1730
|
+
unknown = set(item) - {"op", "path", "content"}
|
|
1731
|
+
if unknown:
|
|
1732
|
+
raise ProjectError(
|
|
1733
|
+
"invalid_command",
|
|
1734
|
+
f"Unknown fields for document {raw_path}: "
|
|
1735
|
+
f"{', '.join(sorted(unknown))}.",
|
|
1736
|
+
)
|
|
1737
|
+
content = item.get("content")
|
|
1738
|
+
if "content" not in item or not isinstance(
|
|
1739
|
+
content, str
|
|
1740
|
+
):
|
|
1741
|
+
raise ProjectError(
|
|
1742
|
+
"invalid_command",
|
|
1743
|
+
f"Write needs text content for document "
|
|
1744
|
+
f"{raw_path}.",
|
|
1745
|
+
)
|
|
1746
|
+
if len(content.encode("utf-8")) > _MAX_DOC_BYTES:
|
|
1747
|
+
raise ProjectError(
|
|
1748
|
+
"invalid_command",
|
|
1749
|
+
f"Document exceeds the size limit: {raw_path}.",
|
|
1750
|
+
)
|
|
1751
|
+
else:
|
|
1752
|
+
unknown = set(item) - {"op", "path"}
|
|
1753
|
+
if unknown:
|
|
1754
|
+
raise ProjectError(
|
|
1755
|
+
"invalid_command",
|
|
1756
|
+
f"Unknown fields for document {raw_path}: "
|
|
1757
|
+
f"{', '.join(sorted(unknown))}.",
|
|
1758
|
+
)
|
|
1759
|
+
if "content" in item:
|
|
1760
|
+
raise ProjectError(
|
|
1761
|
+
"invalid_command",
|
|
1762
|
+
f"Delete takes no content for document "
|
|
1763
|
+
f"{raw_path}.",
|
|
1764
|
+
)
|
|
1765
|
+
content = None
|
|
1766
|
+
old = self._read_doc_text(canonical, raw_path)
|
|
1767
|
+
if op == "delete" and old is None:
|
|
1768
|
+
raise ProjectError(
|
|
1769
|
+
"unknown_document",
|
|
1770
|
+
f"No such document: {raw_path}.",
|
|
1771
|
+
)
|
|
1772
|
+
prepared.append((op, canonical, content, old, raw_path))
|
|
1773
|
+
self._gate_revision(expected_revision)
|
|
1774
|
+
previous = self._current_revision
|
|
1775
|
+
self._ensure_snapshot(previous)
|
|
1776
|
+
applied = []
|
|
1777
|
+
try:
|
|
1778
|
+
for op, canonical, content, _old, raw_path in prepared:
|
|
1779
|
+
if op == "write":
|
|
1780
|
+
self._write_doc_text(canonical, content, raw_path)
|
|
1781
|
+
else:
|
|
1782
|
+
self._delete_doc_path(canonical, raw_path)
|
|
1783
|
+
applied.append((canonical, _old))
|
|
1784
|
+
except ProjectError:
|
|
1785
|
+
for canonical, old in reversed(applied):
|
|
1786
|
+
try:
|
|
1787
|
+
if old is None:
|
|
1788
|
+
self._delete_doc_path(canonical, canonical)
|
|
1789
|
+
else:
|
|
1790
|
+
self._write_doc_text(
|
|
1791
|
+
canonical, old, canonical
|
|
1792
|
+
)
|
|
1793
|
+
except Exception:
|
|
1794
|
+
pass
|
|
1795
|
+
raise
|
|
1796
|
+
except OSError as exc:
|
|
1797
|
+
for canonical, old in reversed(applied):
|
|
1798
|
+
try:
|
|
1799
|
+
if old is None:
|
|
1800
|
+
self._delete_doc_path(canonical, canonical)
|
|
1801
|
+
else:
|
|
1802
|
+
self._write_doc_text(
|
|
1803
|
+
canonical, old, canonical
|
|
1804
|
+
)
|
|
1805
|
+
except Exception:
|
|
1806
|
+
pass
|
|
1807
|
+
raise ProjectError(
|
|
1808
|
+
"invalid_ledger",
|
|
1809
|
+
f"Batch write failed; rolled back: {exc}.",
|
|
1810
|
+
) from exc
|
|
1811
|
+
advanced = _advance_revision(previous)
|
|
1812
|
+
try:
|
|
1813
|
+
self._capture_snapshot(advanced)
|
|
1814
|
+
except ProjectError:
|
|
1815
|
+
for _op, canonical, _content, old, _raw in reversed(prepared):
|
|
1816
|
+
try:
|
|
1817
|
+
if old is None:
|
|
1818
|
+
self._delete_doc_path(canonical, canonical)
|
|
1819
|
+
else:
|
|
1820
|
+
self._write_doc_text(
|
|
1821
|
+
canonical, old, canonical
|
|
1822
|
+
)
|
|
1823
|
+
except Exception:
|
|
1824
|
+
pass
|
|
1825
|
+
raise
|
|
1826
|
+
self._current_revision = advanced
|
|
1827
|
+
parts = []
|
|
1828
|
+
results = []
|
|
1829
|
+
for op, canonical, content, old, _raw in prepared:
|
|
1830
|
+
if op == "write":
|
|
1831
|
+
new_bytes = len(content.encode("utf-8"))
|
|
1832
|
+
if old is None:
|
|
1833
|
+
parts.append(
|
|
1834
|
+
f"created {canonical} ({new_bytes} bytes)"
|
|
1835
|
+
)
|
|
1836
|
+
else:
|
|
1837
|
+
parts.append(
|
|
1838
|
+
f"updated {canonical} "
|
|
1839
|
+
f"({len(old.encode('utf-8'))} -> "
|
|
1840
|
+
f"{new_bytes} bytes)"
|
|
1841
|
+
)
|
|
1842
|
+
results.append(
|
|
1843
|
+
{"op": "write", "path": canonical, "bytes": new_bytes}
|
|
1844
|
+
)
|
|
1845
|
+
else:
|
|
1846
|
+
old_bytes = len(old.encode("utf-8"))
|
|
1847
|
+
parts.append(f"deleted {canonical} ({old_bytes} bytes)")
|
|
1848
|
+
results.append(
|
|
1849
|
+
{"op": "delete", "path": canonical, "bytes": old_bytes}
|
|
1850
|
+
)
|
|
1851
|
+
diff = "; ".join(parts)
|
|
1852
|
+
record = self._append_event(
|
|
1853
|
+
"mutations_applied",
|
|
1854
|
+
request_id,
|
|
1855
|
+
{
|
|
1856
|
+
"revision": self._current_revision,
|
|
1857
|
+
"operation": "apply_mutations",
|
|
1858
|
+
"mutations": [
|
|
1859
|
+
{"op": op, "path": canonical}
|
|
1860
|
+
for op, canonical, _, _, _ in prepared
|
|
1861
|
+
],
|
|
1862
|
+
"diff_summary": diff,
|
|
1863
|
+
},
|
|
1864
|
+
)
|
|
1865
|
+
response = {
|
|
1866
|
+
"project_id": self._project_id,
|
|
1867
|
+
"previous_revision": previous,
|
|
1868
|
+
"current_revision": self._current_revision,
|
|
1869
|
+
"revision_transition": {
|
|
1870
|
+
"from": previous,
|
|
1871
|
+
"to": self._current_revision,
|
|
1872
|
+
},
|
|
1873
|
+
"actor": self._actor,
|
|
1874
|
+
"diff_summary": diff,
|
|
1875
|
+
"rewind_handle": {
|
|
1876
|
+
"project_id": self._project_id,
|
|
1877
|
+
"revision": previous,
|
|
1878
|
+
"request_id": request_id,
|
|
1879
|
+
},
|
|
1880
|
+
"events": [record],
|
|
1881
|
+
"results": results,
|
|
1882
|
+
"event_cursor": self._cursor(),
|
|
1883
|
+
"request_id": request_id,
|
|
1884
|
+
}
|
|
1885
|
+
self._requests[request_id] = (command_text, response)
|
|
1886
|
+
self._persist_after_mutation()
|
|
1887
|
+
return response
|
|
1888
|
+
|
|
1889
|
+
def place_wire(self, segments, layer, endpoints, request_id,
|
|
1890
|
+
command_text, expected_revision=None):
|
|
1891
|
+
"""Place one wire: gate, transact, advance, log.
|
|
1892
|
+
|
|
1893
|
+
A resubmitted envelope returns its stored response without
|
|
1894
|
+
advancing; a new envelope gates its expected_revision, and
|
|
1895
|
+
only an accepted placement advances the revision and
|
|
1896
|
+
appends the place_wire event carrying the entity receipt.
|
|
1897
|
+
|
|
1898
|
+
Args:
|
|
1899
|
+
segments: Non-empty list of integer polylines.
|
|
1900
|
+
layer: Layer name for every segment.
|
|
1901
|
+
endpoints: [a, b] endpoint mappings; each ref is a
|
|
1902
|
+
pin/port/junction/net entity ID or null when
|
|
1903
|
+
dangling.
|
|
1904
|
+
request_id: Client envelope id; resubmitting the
|
|
1905
|
+
identical text returns the identical response
|
|
1906
|
+
without advancing.
|
|
1907
|
+
command_text: YAML command text for identity comparison.
|
|
1908
|
+
expected_revision: Revision the writer read.
|
|
1909
|
+
|
|
1910
|
+
Returns:
|
|
1911
|
+
Mutation mapping with the wire receipt, revision
|
|
1912
|
+
transition, new head, actor, diff summary, rewind
|
|
1913
|
+
handle, the appended event, cursor, and request id.
|
|
1914
|
+
|
|
1915
|
+
Raises:
|
|
1916
|
+
ProjectError: request_conflict for a reused id with
|
|
1917
|
+
new text; revision_conflict for a stale head;
|
|
1918
|
+
unknown_ref for a ref naming no entity;
|
|
1919
|
+
invalid_ref for a wire ref; invalid_wire for a
|
|
1920
|
+
malformed wire; unsupported_operation without the
|
|
1921
|
+
ENGINE-ECS entity store.
|
|
1922
|
+
|
|
1923
|
+
Caution: Every accepted placement advances the revision —
|
|
1924
|
+
a second writer at the old head fails instead of
|
|
1925
|
+
merging.
|
|
1926
|
+
"""
|
|
1927
|
+
return self._wire_mutation(
|
|
1928
|
+
"place_wire", request_id, command_text, expected_revision,
|
|
1929
|
+
lambda store: wireplace.place_wire(
|
|
1930
|
+
store, self._actor, segments, layer, endpoints
|
|
1931
|
+
),
|
|
1932
|
+
lambda receipt: f"placed wire {receipt['wire']} on {layer}",
|
|
1933
|
+
)
|
|
1934
|
+
|
|
1935
|
+
def place_junction(self, xy, layer, request_id, command_text,
|
|
1936
|
+
expected_revision=None):
|
|
1937
|
+
"""Place one junction dot: gate, transact, advance, log.
|
|
1938
|
+
|
|
1939
|
+
Args:
|
|
1940
|
+
xy: Integer [x, y] dot position.
|
|
1941
|
+
layer: Layer name for the dot.
|
|
1942
|
+
request_id: Client envelope id for idempotent replay.
|
|
1943
|
+
command_text: YAML command text for identity comparison.
|
|
1944
|
+
expected_revision: Revision the writer read.
|
|
1945
|
+
|
|
1946
|
+
Returns:
|
|
1947
|
+
Mutation mapping with the junction receipt, revision
|
|
1948
|
+
transition, new head, actor, diff summary, rewind
|
|
1949
|
+
handle, the appended event, cursor, and request id.
|
|
1950
|
+
|
|
1951
|
+
Raises:
|
|
1952
|
+
ProjectError: request_conflict for a reused id with
|
|
1953
|
+
new text; revision_conflict for a stale head;
|
|
1954
|
+
invalid_junction for a malformed dot;
|
|
1955
|
+
unsupported_operation without the ENGINE-ECS
|
|
1956
|
+
entity store.
|
|
1957
|
+
|
|
1958
|
+
Caution: Every accepted placement advances the revision —
|
|
1959
|
+
a second writer at the old head fails instead of
|
|
1960
|
+
merging.
|
|
1961
|
+
"""
|
|
1962
|
+
return self._wire_mutation(
|
|
1963
|
+
"place_junction", request_id, command_text, expected_revision,
|
|
1964
|
+
lambda store: wireplace.place_junction(
|
|
1965
|
+
store, self._actor, xy, layer
|
|
1966
|
+
),
|
|
1967
|
+
lambda receipt: f"placed junction {receipt['junction']}",
|
|
1968
|
+
)
|
|
1969
|
+
|
|
1970
|
+
def connect_endpoint(self, wire_id, end, ref, request_id,
|
|
1971
|
+
command_text, expected_revision=None):
|
|
1972
|
+
"""Point one wire endpoint at a new terminus: gate, run, log.
|
|
1973
|
+
|
|
1974
|
+
Geometry never moves — only the ref changes — and a
|
|
1975
|
+
no-change call returns the head unadvanced with an empty
|
|
1976
|
+
event list instead of logging a phantom revision.
|
|
1977
|
+
|
|
1978
|
+
Args:
|
|
1979
|
+
wire_id: Wire entity to rewire.
|
|
1980
|
+
end: "a" or "b" selecting the endpoint.
|
|
1981
|
+
ref: Pin/port/junction/net entity ID, or null to
|
|
1982
|
+
dangle the endpoint.
|
|
1983
|
+
request_id: Client envelope id for idempotent replay.
|
|
1984
|
+
command_text: YAML command text for identity comparison.
|
|
1985
|
+
expected_revision: Revision the writer read.
|
|
1986
|
+
|
|
1987
|
+
Returns:
|
|
1988
|
+
Mutation mapping with the connect receipt, revision
|
|
1989
|
+
transition, actor, diff summary, rewind handle, the
|
|
1990
|
+
appended event, cursor, and request id — or the
|
|
1991
|
+
unadvanced noop mapping when the ref is already set.
|
|
1992
|
+
|
|
1993
|
+
Raises:
|
|
1994
|
+
ProjectError: request_conflict for a reused id with
|
|
1995
|
+
new text; revision_conflict for a stale head;
|
|
1996
|
+
unknown_wire for a missing wire; invalid_endpoint
|
|
1997
|
+
for a bad selector; unknown_ref for a missing
|
|
1998
|
+
entity; invalid_ref for a wire/self ref;
|
|
1999
|
+
unsupported_operation without the ENGINE-ECS
|
|
2000
|
+
entity store.
|
|
2001
|
+
"""
|
|
2002
|
+
return self._wire_mutation(
|
|
2003
|
+
"connect_endpoint", request_id, command_text,
|
|
2004
|
+
expected_revision,
|
|
2005
|
+
lambda store: wireplace.connect_endpoint(
|
|
2006
|
+
store, self._actor, wire_id, end, ref
|
|
2007
|
+
),
|
|
2008
|
+
lambda receipt: (
|
|
2009
|
+
f"connected wire {wire_id} end {end} to {ref!r}"
|
|
2010
|
+
),
|
|
2011
|
+
)
|
|
2012
|
+
|
|
2013
|
+
def move_wire(self, wire_id, dx, dy, request_id, command_text,
|
|
2014
|
+
expected_revision=None):
|
|
2015
|
+
"""Translate one wire: gate, transact, advance, log.
|
|
2016
|
+
|
|
2017
|
+
Refs survive the move; a zero move returns the head
|
|
2018
|
+
unadvanced with an empty event list.
|
|
2019
|
+
|
|
2020
|
+
Args:
|
|
2021
|
+
wire_id: Wire entity to move.
|
|
2022
|
+
dx: Integer x translation.
|
|
2023
|
+
dy: Integer y translation.
|
|
2024
|
+
request_id: Client envelope id for idempotent replay.
|
|
2025
|
+
command_text: YAML command text for identity comparison.
|
|
2026
|
+
expected_revision: Revision the writer read.
|
|
2027
|
+
|
|
2028
|
+
Returns:
|
|
2029
|
+
Mutation mapping with the move receipt, revision
|
|
2030
|
+
transition, actor, diff summary, rewind handle, the
|
|
2031
|
+
appended event, cursor, and request id — or the
|
|
2032
|
+
unadvanced noop mapping for a zero move.
|
|
2033
|
+
|
|
2034
|
+
Raises:
|
|
2035
|
+
ProjectError: request_conflict for a reused id with
|
|
2036
|
+
new text; revision_conflict for a stale head;
|
|
2037
|
+
unknown_wire for a missing wire; invalid_geometry
|
|
2038
|
+
for non-integer deltas; unsupported_operation
|
|
2039
|
+
without the ENGINE-ECS entity store.
|
|
2040
|
+
"""
|
|
2041
|
+
return self._wire_mutation(
|
|
2042
|
+
"move_wire", request_id, command_text, expected_revision,
|
|
2043
|
+
lambda store: wireplace.move_wire(
|
|
2044
|
+
store, self._actor, wire_id, dx, dy
|
|
2045
|
+
),
|
|
2046
|
+
lambda receipt: f"moved wire {wire_id} by ({dx}, {dy})",
|
|
2047
|
+
)
|
|
2048
|
+
|
|
2049
|
+
def split_wire(self, wire_id, at, request_id, command_text,
|
|
2050
|
+
expected_revision=None):
|
|
2051
|
+
"""Split one wire at an interior point: gate, run, log.
|
|
2052
|
+
|
|
2053
|
+
The junction dot, the head rewrite, and the new tail wire
|
|
2054
|
+
land in one revision under one event.
|
|
2055
|
+
|
|
2056
|
+
Args:
|
|
2057
|
+
wire_id: Wire entity to split.
|
|
2058
|
+
at: Integer [x, y] point on the polyline interior.
|
|
2059
|
+
request_id: Client envelope id for idempotent replay.
|
|
2060
|
+
command_text: YAML command text for identity comparison.
|
|
2061
|
+
expected_revision: Revision the writer read.
|
|
2062
|
+
|
|
2063
|
+
Returns:
|
|
2064
|
+
Mutation mapping with the split receipt (wire,
|
|
2065
|
+
junction, new_wire), revision transition, new head,
|
|
2066
|
+
actor, diff summary, rewind handle, the appended
|
|
2067
|
+
event, cursor, and request id.
|
|
2068
|
+
|
|
2069
|
+
Raises:
|
|
2070
|
+
ProjectError: request_conflict for a reused id with
|
|
2071
|
+
new text; revision_conflict for a stale head;
|
|
2072
|
+
unknown_wire for a missing wire; invalid_split for
|
|
2073
|
+
a point off the interior or a multi-polyline wire;
|
|
2074
|
+
unsupported_operation without the ENGINE-ECS
|
|
2075
|
+
entity store.
|
|
2076
|
+
"""
|
|
2077
|
+
return self._wire_mutation(
|
|
2078
|
+
"split_wire", request_id, command_text, expected_revision,
|
|
2079
|
+
lambda store: wireplace.split_wire(
|
|
2080
|
+
store, self._actor, wire_id, at
|
|
2081
|
+
),
|
|
2082
|
+
lambda receipt: (
|
|
2083
|
+
f"split wire {wire_id} at {at} "
|
|
2084
|
+
f"(junction {receipt['junction']}, "
|
|
2085
|
+
f"tail {receipt['new_wire']})"
|
|
2086
|
+
),
|
|
2087
|
+
)
|
|
2088
|
+
|
|
2089
|
+
def delete_wire(self, wire_id, request_id, command_text,
|
|
2090
|
+
expected_revision=None):
|
|
2091
|
+
"""Delete one wire: gate, remove, advance, log.
|
|
2092
|
+
|
|
2093
|
+
Referenced junctions persist; Net entities rematerialize
|
|
2094
|
+
on the next materialize_nets call.
|
|
2095
|
+
|
|
2096
|
+
Args:
|
|
2097
|
+
wire_id: Wire entity to delete.
|
|
2098
|
+
request_id: Client envelope id for idempotent replay.
|
|
2099
|
+
command_text: YAML command text for identity comparison.
|
|
2100
|
+
expected_revision: Revision the writer read.
|
|
2101
|
+
|
|
2102
|
+
Returns:
|
|
2103
|
+
Mutation mapping with the delete receipt, revision
|
|
2104
|
+
transition, new head, actor, diff summary, rewind
|
|
2105
|
+
handle, the appended event, cursor, and request id.
|
|
2106
|
+
|
|
2107
|
+
Raises:
|
|
2108
|
+
ProjectError: request_conflict for a reused id with
|
|
2109
|
+
new text; revision_conflict for a stale head;
|
|
2110
|
+
unknown_wire for a missing wire;
|
|
2111
|
+
unsupported_operation without the ENGINE-ECS
|
|
2112
|
+
entity store.
|
|
2113
|
+
"""
|
|
2114
|
+
return self._wire_mutation(
|
|
2115
|
+
"delete_wire", request_id, command_text, expected_revision,
|
|
2116
|
+
lambda store: wireplace.delete_wire(
|
|
2117
|
+
store, self._actor, wire_id
|
|
2118
|
+
),
|
|
2119
|
+
lambda receipt: f"deleted wire {wire_id}",
|
|
2120
|
+
)
|
|
2121
|
+
|
|
2122
|
+
def delete_junction(self, junction_id, request_id, command_text,
|
|
2123
|
+
expected_revision=None):
|
|
2124
|
+
"""Delete one unreferenced junction: gate, remove, log.
|
|
2125
|
+
|
|
2126
|
+
Args:
|
|
2127
|
+
junction_id: Junction entity to delete.
|
|
2128
|
+
request_id: Client envelope id for idempotent replay.
|
|
2129
|
+
command_text: YAML command text for identity comparison.
|
|
2130
|
+
expected_revision: Revision the writer read.
|
|
2131
|
+
|
|
2132
|
+
Returns:
|
|
2133
|
+
Mutation mapping with the delete receipt, revision
|
|
2134
|
+
transition, new head, actor, diff summary, rewind
|
|
2135
|
+
handle, the appended event, cursor, and request id.
|
|
2136
|
+
|
|
2137
|
+
Raises:
|
|
2138
|
+
ProjectError: request_conflict for a reused id with
|
|
2139
|
+
new text; revision_conflict for a stale head;
|
|
2140
|
+
unknown_junction for a missing junction;
|
|
2141
|
+
junction_in_use naming the referencing wires;
|
|
2142
|
+
unsupported_operation without the ENGINE-ECS
|
|
2143
|
+
entity store.
|
|
2144
|
+
"""
|
|
2145
|
+
return self._wire_mutation(
|
|
2146
|
+
"delete_junction", request_id, command_text,
|
|
2147
|
+
expected_revision,
|
|
2148
|
+
lambda store: wireplace.delete_junction(
|
|
2149
|
+
store, self._actor, junction_id
|
|
2150
|
+
),
|
|
2151
|
+
lambda receipt: f"deleted junction {junction_id}",
|
|
2152
|
+
)
|
|
2153
|
+
|
|
2154
|
+
def materialize_nets(self, pins, request_id, command_text,
|
|
2155
|
+
labels=None, ports=None, cell="top",
|
|
2156
|
+
named_refs=None, expected_revision=None):
|
|
2157
|
+
"""Extract connectivity, upsert Net entities: gate, run, log.
|
|
2158
|
+
|
|
2159
|
+
Extraction always rebuilds from the stored wires; Nets
|
|
2160
|
+
keyed by canonical name are created or refreshed, vanished
|
|
2161
|
+
names pruned, all in one revision. An unchanged world
|
|
2162
|
+
returns the head unadvanced with an empty event list.
|
|
2163
|
+
|
|
2164
|
+
Args:
|
|
2165
|
+
pins: Caller pin records [{entity, instance, pin, xy}].
|
|
2166
|
+
request_id: Client envelope id for idempotent replay.
|
|
2167
|
+
command_text: YAML command text for identity comparison.
|
|
2168
|
+
labels: Caller label records [{name, xy}].
|
|
2169
|
+
ports: Caller port records [{name, direction}].
|
|
2170
|
+
cell: Top cell name.
|
|
2171
|
+
named_refs: {entity ID: net name} for port-style refs.
|
|
2172
|
+
expected_revision: Revision the writer read.
|
|
2173
|
+
|
|
2174
|
+
Returns:
|
|
2175
|
+
Mutation mapping with the materialize receipt (nets,
|
|
2176
|
+
created/updated/pruned, unresolved), revision
|
|
2177
|
+
transition, actor, diff summary, rewind handle, the
|
|
2178
|
+
appended event, cursor, and request id — or the
|
|
2179
|
+
unadvanced noop mapping when every Net matches.
|
|
2180
|
+
|
|
2181
|
+
Raises:
|
|
2182
|
+
ProjectError: request_conflict for a reused id with
|
|
2183
|
+
new text; revision_conflict for a stale head;
|
|
2184
|
+
invalid_pin / invalid_label / invalid_port /
|
|
2185
|
+
invalid_cell / invalid_ref for malformed inputs;
|
|
2186
|
+
extraction_failed when extraction refuses the
|
|
2187
|
+
world; unsupported_operation without the
|
|
2188
|
+
ENGINE-ECS entity store.
|
|
2189
|
+
"""
|
|
2190
|
+
return self._wire_mutation(
|
|
2191
|
+
"materialize_nets", request_id, command_text,
|
|
2192
|
+
expected_revision,
|
|
2193
|
+
lambda store: wireplace.materialize_nets(
|
|
2194
|
+
store, self._actor, pins, labels=labels or (),
|
|
2195
|
+
ports=ports or (), cell=cell, named_refs=named_refs,
|
|
2196
|
+
),
|
|
2197
|
+
lambda receipt: (
|
|
2198
|
+
f"materialized {len(receipt['nets'])} nets "
|
|
2199
|
+
f"({len(receipt['created'])} created, "
|
|
2200
|
+
f"{len(receipt['updated'])} updated, "
|
|
2201
|
+
f"{len(receipt['pruned'])} pruned)"
|
|
2202
|
+
),
|
|
2203
|
+
)
|
|
2204
|
+
|
|
2205
|
+
def rewind(self, target_revision, request_id, command_text,
|
|
2206
|
+
expected_revision=None):
|
|
2207
|
+
"""Restore managed bytes to a prior revision under a new head.
|
|
2208
|
+
|
|
2209
|
+
The target snapshot replaces every managed document
|
|
2210
|
+
byte-identically; runs, the ledger, and the snapshots
|
|
2211
|
+
themselves are left alone. History stays append-only: the
|
|
2212
|
+
restore advances from the live head and appends a single
|
|
2213
|
+
`project_rewound` event recording the target. Linked caller
|
|
2214
|
+
trees are never written — files outside `.rocket/` are
|
|
2215
|
+
listed in the response as left untouched.
|
|
2216
|
+
|
|
2217
|
+
Args:
|
|
2218
|
+
target_revision: Snapshot revision to restore.
|
|
2219
|
+
request_id: Client envelope id for idempotent replay.
|
|
2220
|
+
command_text: YAML command text for identity comparison.
|
|
2221
|
+
expected_revision: Revision the writer read.
|
|
2222
|
+
|
|
2223
|
+
Returns:
|
|
2224
|
+
Mutation mapping with revision transition, target
|
|
2225
|
+
revision, new head, actor, diff summary, external-file
|
|
2226
|
+
report, rewind handle, the appended event, cursor, and
|
|
2227
|
+
request id.
|
|
2228
|
+
|
|
2229
|
+
Raises:
|
|
2230
|
+
ProjectError: request_conflict for a reused id with new
|
|
2231
|
+
text; invalid_command for a missing target or a
|
|
2232
|
+
target that equals the live head; unknown_revision
|
|
2233
|
+
for a revision with no snapshot; revision_conflict
|
|
2234
|
+
for a stale head; invalid_ledger when the durable
|
|
2235
|
+
restore fails (best-effort rollback already
|
|
2236
|
+
attempted).
|
|
2237
|
+
|
|
2238
|
+
Caution: Rewinding advances — the new head is not the target
|
|
2239
|
+
id, and stale writers fail instead of merging. Caller
|
|
2240
|
+
files outside `.rocket/` are reported, never touched.
|
|
2241
|
+
"""
|
|
2242
|
+
self._refresh_durable()
|
|
2243
|
+
replay = self._check_replay(request_id, command_text)
|
|
2244
|
+
if replay is not None:
|
|
2245
|
+
return replay
|
|
2246
|
+
if not isinstance(target_revision, str) or not target_revision:
|
|
2247
|
+
raise ProjectError(
|
|
2248
|
+
"invalid_command",
|
|
2249
|
+
"rewind needs a target_revision argument.",
|
|
2250
|
+
)
|
|
2251
|
+
snapshot = self._load_snapshot(target_revision)
|
|
2252
|
+
if snapshot is None:
|
|
2253
|
+
raise ProjectError(
|
|
2254
|
+
"unknown_revision",
|
|
2255
|
+
f"No snapshot for revision: {target_revision}.",
|
|
2256
|
+
)
|
|
2257
|
+
if target_revision == self._current_revision:
|
|
2258
|
+
raise ProjectError(
|
|
2259
|
+
"invalid_command",
|
|
2260
|
+
f"Already at revision {target_revision}.",
|
|
2261
|
+
)
|
|
2262
|
+
self._gate_revision(expected_revision)
|
|
2263
|
+
previous = self._current_revision
|
|
2264
|
+
self._ensure_snapshot(previous)
|
|
2265
|
+
if self._dir is None:
|
|
2266
|
+
self._docs = dict(snapshot)
|
|
2267
|
+
else:
|
|
2268
|
+
pre = self._current_bytes()
|
|
2269
|
+
self._restore_snapshot_bytes(snapshot, pre)
|
|
2270
|
+
advanced = _advance_revision(previous)
|
|
2271
|
+
if self._dir is None:
|
|
2272
|
+
self._snapshots[advanced] = dict(self._docs)
|
|
2273
|
+
else:
|
|
2274
|
+
try:
|
|
2275
|
+
self._capture_snapshot(advanced)
|
|
2276
|
+
except ProjectError:
|
|
2277
|
+
try:
|
|
2278
|
+
self._clear_docs()
|
|
2279
|
+
self._write_bytes_map(pre)
|
|
2280
|
+
except Exception:
|
|
2281
|
+
pass
|
|
2282
|
+
raise
|
|
2283
|
+
self._current_revision = advanced
|
|
2284
|
+
external = self._linked_external_files()
|
|
2285
|
+
count = len(snapshot)
|
|
2286
|
+
noun = "doc" if count == 1 else "docs"
|
|
2287
|
+
diff = (
|
|
2288
|
+
f"rewound {previous} to {target_revision} "
|
|
2289
|
+
f"(restored {count} {noun} as {advanced})"
|
|
2290
|
+
)
|
|
2291
|
+
record = self._append_event(
|
|
2292
|
+
"project_rewound",
|
|
2293
|
+
request_id,
|
|
2294
|
+
{
|
|
2295
|
+
"revision": self._current_revision,
|
|
2296
|
+
"operation": "rewind",
|
|
2297
|
+
"target_revision": target_revision,
|
|
2298
|
+
"previous_revision": previous,
|
|
2299
|
+
"diff_summary": diff,
|
|
2300
|
+
"external_files": [e["path"] for e in external],
|
|
2301
|
+
},
|
|
2302
|
+
)
|
|
2303
|
+
response = {
|
|
2304
|
+
"project_id": self._project_id,
|
|
2305
|
+
"previous_revision": previous,
|
|
2306
|
+
"current_revision": self._current_revision,
|
|
2307
|
+
"revision_transition": {
|
|
2308
|
+
"from": previous,
|
|
2309
|
+
"to": self._current_revision,
|
|
2310
|
+
},
|
|
2311
|
+
"target_revision": target_revision,
|
|
2312
|
+
"actor": self._actor,
|
|
2313
|
+
"diff_summary": diff,
|
|
2314
|
+
"external_files": external,
|
|
2315
|
+
"rewind_handle": {
|
|
2316
|
+
"project_id": self._project_id,
|
|
2317
|
+
"revision": previous,
|
|
2318
|
+
"request_id": request_id,
|
|
2319
|
+
},
|
|
2320
|
+
"events": [record],
|
|
2321
|
+
"event_cursor": self._cursor(),
|
|
2322
|
+
"request_id": request_id,
|
|
2323
|
+
}
|
|
2324
|
+
self._requests[request_id] = (command_text, response)
|
|
2325
|
+
self._persist_after_mutation()
|
|
2326
|
+
return response
|
|
2327
|
+
|
|
2328
|
+
def apply_transaction(self, changes, request_id, command_text,
|
|
2329
|
+
expected_revision=None):
|
|
2330
|
+
"""Apply one atomic batch of entity changes (API-V1-EDITORS).
|
|
2331
|
+
|
|
2332
|
+
Alias creation resolves LocalRef before persistence; every
|
|
2333
|
+
change gates CAS, and a failed late change rolls back.
|
|
2334
|
+
|
|
2335
|
+
Args:
|
|
2336
|
+
changes: Change mappings with an op each.
|
|
2337
|
+
request_id: Client envelope id for idempotent replay.
|
|
2338
|
+
command_text: YAML command text for identity comparison.
|
|
2339
|
+
expected_revision: Revision the writer read.
|
|
2340
|
+
|
|
2341
|
+
Returns:
|
|
2342
|
+
Mutation mapping with receipt, events, and cursor.
|
|
2343
|
+
|
|
2344
|
+
Raises:
|
|
2345
|
+
ProjectError: request_conflict, revision_conflict,
|
|
2346
|
+
invalid_command, or the CAS refusal code.
|
|
2347
|
+
"""
|
|
2348
|
+
return _editors.editor_mutation(self,
|
|
2349
|
+
"apply_transaction", request_id, command_text,
|
|
2350
|
+
expected_revision,
|
|
2351
|
+
lambda _store, rid, rtext, live: (
|
|
2352
|
+
_transactions.apply_transaction(
|
|
2353
|
+
self, rid, rtext, changes, live)),
|
|
2354
|
+
lambda receipt: (
|
|
2355
|
+
f"applied {receipt.get('applied', len(changes))} "
|
|
2356
|
+
"changes"),
|
|
2357
|
+
)
|
|
2358
|
+
|
|
2359
|
+
def create_shape(self, shape_type, layer, data, request_id,
|
|
2360
|
+
command_text, units="nm", profile=None,
|
|
2361
|
+
anchor=None, expected_revision=None):
|
|
2362
|
+
"""Create one layout shape entity with provenance.
|
|
2363
|
+
|
|
2364
|
+
Args:
|
|
2365
|
+
shape_type: One of rect, polygon, path, label.
|
|
2366
|
+
layer: Stable layer lid the shape is drawn on.
|
|
2367
|
+
data: Shape payload for the type.
|
|
2368
|
+
request_id: Client envelope id for idempotent replay.
|
|
2369
|
+
command_text: YAML command text for identity comparison.
|
|
2370
|
+
units: Coordinate units label.
|
|
2371
|
+
profile: Technology profile pin for provenance.
|
|
2372
|
+
anchor: Optional cell-port anchor.
|
|
2373
|
+
expected_revision: Revision the writer read.
|
|
2374
|
+
|
|
2375
|
+
Returns:
|
|
2376
|
+
Mutation mapping with receipt, events, and cursor.
|
|
2377
|
+
|
|
2378
|
+
Raises:
|
|
2379
|
+
ProjectError: request_conflict, revision_conflict,
|
|
2380
|
+
invalid_command for a malformed payload.
|
|
2381
|
+
"""
|
|
2382
|
+
return _editors.editor_mutation(self,
|
|
2383
|
+
"create_shape", request_id, command_text,
|
|
2384
|
+
expected_revision,
|
|
2385
|
+
lambda _store, rid, rtext, live: _geometry.create_shape(
|
|
2386
|
+
self, rid, rtext, shape_type, layer, data,
|
|
2387
|
+
units=units, profile=profile, anchor=anchor,
|
|
2388
|
+
expected_revision=live),
|
|
2389
|
+
lambda receipt: (
|
|
2390
|
+
f"created {shape_type} shape "
|
|
2391
|
+
f"({receipt.get('entity')})"),
|
|
2392
|
+
)
|
|
2393
|
+
|
|
2394
|
+
def create_sheet(self, name, request_id, command_text, title="",
|
|
2395
|
+
expected_revision=None):
|
|
2396
|
+
"""Create one schematic sheet.
|
|
2397
|
+
|
|
2398
|
+
Args:
|
|
2399
|
+
name: Unique sheet name.
|
|
2400
|
+
request_id: Client envelope id for idempotent replay.
|
|
2401
|
+
command_text: YAML command text for identity comparison.
|
|
2402
|
+
title: Human title shown on the sheet frame.
|
|
2403
|
+
expected_revision: Revision the writer read.
|
|
2404
|
+
|
|
2405
|
+
Returns:
|
|
2406
|
+
Mutation mapping with receipt, events, and cursor.
|
|
2407
|
+
|
|
2408
|
+
Raises:
|
|
2409
|
+
ProjectError: request_conflict, revision_conflict,
|
|
2410
|
+
invalid_sheet, sheet_exists.
|
|
2411
|
+
"""
|
|
2412
|
+
return _editors.editor_mutation(self,
|
|
2413
|
+
"create_sheet", request_id, command_text,
|
|
2414
|
+
expected_revision,
|
|
2415
|
+
lambda store, _rid, _rtext, _live: _sheets.create_sheet(
|
|
2416
|
+
store, self._actor, name, title=title),
|
|
2417
|
+
lambda receipt: (
|
|
2418
|
+
f"created sheet {name} ({receipt.get('sheet')})"),
|
|
2419
|
+
)
|
|
2420
|
+
|
|
2421
|
+
def place_instance(self, sheet, symbol, x, y, request_id,
|
|
2422
|
+
command_text, rotation=0, mirror=False,
|
|
2423
|
+
properties=None, ref="",
|
|
2424
|
+
expected_revision=None):
|
|
2425
|
+
"""Place one symbol instance on a sheet.
|
|
2426
|
+
|
|
2427
|
+
Args:
|
|
2428
|
+
sheet: Sheet entity id hosting the instance.
|
|
2429
|
+
symbol: Bare project symbol name or scoped upstream ref.
|
|
2430
|
+
x: X position in schematic units.
|
|
2431
|
+
y: Y position in schematic units.
|
|
2432
|
+
request_id: Client envelope id for idempotent replay.
|
|
2433
|
+
command_text: YAML command text for identity comparison.
|
|
2434
|
+
rotation: Rotation in degrees (0/90/180/270).
|
|
2435
|
+
mirror: Mirror flag.
|
|
2436
|
+
properties: Text-to-text instance properties.
|
|
2437
|
+
ref: Annotation reference ("" means unannotated).
|
|
2438
|
+
expected_revision: Revision the writer read.
|
|
2439
|
+
|
|
2440
|
+
Returns:
|
|
2441
|
+
Mutation mapping with receipt, events, and cursor.
|
|
2442
|
+
|
|
2443
|
+
Raises:
|
|
2444
|
+
ProjectError: request_conflict, revision_conflict,
|
|
2445
|
+
unknown_sheet, unknown_symbol, invalid_instance.
|
|
2446
|
+
"""
|
|
2447
|
+
return _editors.editor_mutation(self,
|
|
2448
|
+
"place_instance", request_id, command_text,
|
|
2449
|
+
expected_revision,
|
|
2450
|
+
lambda store, _rid, _rt, _live: _instances.place_instance(
|
|
2451
|
+
store, self._actor, sheet, symbol, x, y,
|
|
2452
|
+
rotation=rotation, mirror=mirror,
|
|
2453
|
+
properties=properties or {}, ref=ref),
|
|
2454
|
+
lambda receipt: (
|
|
2455
|
+
f"placed {symbol} ({receipt.get('instance')})"),
|
|
2456
|
+
)
|
|
2457
|
+
|
|
2458
|
+
def bind_block(self, parent_sheet, child_sheet, instance,
|
|
2459
|
+
request_id, command_text, expected_revision=None):
|
|
2460
|
+
"""Bind a child sheet under a parent through a block instance.
|
|
2461
|
+
|
|
2462
|
+
Args:
|
|
2463
|
+
parent_sheet: Parent sheet entity id.
|
|
2464
|
+
child_sheet: Child sheet entity id.
|
|
2465
|
+
instance: Block instance entity id in the parent.
|
|
2466
|
+
request_id: Client envelope id for idempotent replay.
|
|
2467
|
+
command_text: YAML command text for identity comparison.
|
|
2468
|
+
expected_revision: Revision the writer read.
|
|
2469
|
+
|
|
2470
|
+
Returns:
|
|
2471
|
+
Mutation mapping with receipt, events, and cursor.
|
|
2472
|
+
|
|
2473
|
+
Raises:
|
|
2474
|
+
ProjectError: request_conflict, revision_conflict,
|
|
2475
|
+
unknown_sheet, unknown_instance, binding_conflict,
|
|
2476
|
+
invalid_hierarchy.
|
|
2477
|
+
"""
|
|
2478
|
+
return _editors.editor_mutation(self,
|
|
2479
|
+
"bind_block", request_id, command_text,
|
|
2480
|
+
expected_revision,
|
|
2481
|
+
lambda store, _rid, _rt, _live: _hierarchy.bind_block(
|
|
2482
|
+
store, self._actor, parent_sheet, child_sheet,
|
|
2483
|
+
instance),
|
|
2484
|
+
lambda receipt: (
|
|
2485
|
+
f"bound {child_sheet} under {parent_sheet}"),
|
|
2486
|
+
)
|
|
2487
|
+
|
|
2488
|
+
def create_symbol(self, name, pins, request_id, command_text,
|
|
2489
|
+
graphics=None, properties=None,
|
|
2490
|
+
expected_revision=None):
|
|
2491
|
+
"""Create one project-owned symbol definition.
|
|
2492
|
+
|
|
2493
|
+
Args:
|
|
2494
|
+
name: Unique symbol name.
|
|
2495
|
+
pins: Pin records with name, pin_type, x, and y.
|
|
2496
|
+
request_id: Client envelope id for idempotent replay.
|
|
2497
|
+
command_text: YAML command text for identity comparison.
|
|
2498
|
+
graphics: Editable graphics items.
|
|
2499
|
+
properties: Text-to-text symbol properties.
|
|
2500
|
+
expected_revision: Revision the writer read.
|
|
2501
|
+
|
|
2502
|
+
Returns:
|
|
2503
|
+
Mutation mapping with receipt, events, and cursor.
|
|
2504
|
+
|
|
2505
|
+
Raises:
|
|
2506
|
+
ProjectError: request_conflict, revision_conflict,
|
|
2507
|
+
invalid_symbol, symbol_exists.
|
|
2508
|
+
"""
|
|
2509
|
+
return _editors.editor_mutation(self,
|
|
2510
|
+
"create_symbol", request_id, command_text,
|
|
2511
|
+
expected_revision,
|
|
2512
|
+
lambda store, _rid, _rt, _live: _symbols.create_symbol(
|
|
2513
|
+
store, self._actor, name, pins, graphics=graphics,
|
|
2514
|
+
properties=properties),
|
|
2515
|
+
lambda receipt: (
|
|
2516
|
+
f"created symbol {name} ({receipt.get('symbol')})"),
|
|
2517
|
+
)
|
|
2518
|
+
|
|
2519
|
+
def create_cell(self, name, ports, request_id, command_text,
|
|
2520
|
+
properties=None, expected_revision=None):
|
|
2521
|
+
"""Create one physical cell definition.
|
|
2522
|
+
|
|
2523
|
+
Args:
|
|
2524
|
+
name: Unique cell name.
|
|
2525
|
+
ports: Port records for the cell.
|
|
2526
|
+
request_id: Client envelope id for idempotent replay.
|
|
2527
|
+
command_text: YAML command text for identity comparison.
|
|
2528
|
+
properties: Text-to-text cell properties.
|
|
2529
|
+
expected_revision: Revision the writer read.
|
|
2530
|
+
|
|
2531
|
+
Returns:
|
|
2532
|
+
Mutation mapping with receipt, events, and cursor.
|
|
2533
|
+
|
|
2534
|
+
Raises:
|
|
2535
|
+
ProjectError: request_conflict, revision_conflict,
|
|
2536
|
+
invalid_cell, cell_exists.
|
|
2537
|
+
"""
|
|
2538
|
+
return _editors.editor_mutation(self,
|
|
2539
|
+
"create_cell", request_id, command_text,
|
|
2540
|
+
expected_revision,
|
|
2541
|
+
lambda store, _rid, _rt, _live: _cells.create_cell(
|
|
2542
|
+
store, self._actor, name, ports,
|
|
2543
|
+
properties=properties),
|
|
2544
|
+
lambda receipt: (
|
|
2545
|
+
f"created cell {name} ({receipt.get('cell')})"),
|
|
2546
|
+
)
|
|
2547
|
+
|
|
2548
|
+
def map_pins(self, symbol, cell, pin_map, request_id,
|
|
2549
|
+
command_text, expected_revision=None):
|
|
2550
|
+
"""Create or remap the pin map for one symbol-cell pair.
|
|
2551
|
+
|
|
2552
|
+
Args:
|
|
2553
|
+
symbol: Project symbol name being linked.
|
|
2554
|
+
cell: Project cell name (or scoped upstream ref).
|
|
2555
|
+
pin_map: Explicit [{symPin, cellPin}] entries.
|
|
2556
|
+
request_id: Client envelope id for idempotent replay.
|
|
2557
|
+
command_text: YAML command text for identity comparison.
|
|
2558
|
+
expected_revision: Revision the writer read.
|
|
2559
|
+
|
|
2560
|
+
Returns:
|
|
2561
|
+
Mutation mapping with receipt, events, and cursor.
|
|
2562
|
+
|
|
2563
|
+
Raises:
|
|
2564
|
+
ProjectError: request_conflict, revision_conflict,
|
|
2565
|
+
unknown_symbol, unknown_cell, invalid_linkage.
|
|
2566
|
+
"""
|
|
2567
|
+
return _editors.editor_mutation(self,
|
|
2568
|
+
"map_pins", request_id, command_text,
|
|
2569
|
+
expected_revision,
|
|
2570
|
+
lambda store, _rid, _rt, _live: _linkage.map_pins(
|
|
2571
|
+
store, self._actor, symbol, cell, pin_map),
|
|
2572
|
+
lambda receipt: f"mapped {symbol} to {cell}",
|
|
2573
|
+
)
|
|
2574
|
+
|
|
2575
|
+
def bind_layers(self, profile, layers, request_id,
|
|
2576
|
+
command_text, source="profile",
|
|
2577
|
+
version="unknown", expected_revision=None):
|
|
2578
|
+
"""Bind project layers to a technology profile and save.
|
|
2579
|
+
|
|
2580
|
+
Builds the catalog from the given layers, binds it, and
|
|
2581
|
+
saves the report as `layers/bindings.yaml`. A missing
|
|
2582
|
+
binding never reinterprets geometry.
|
|
2583
|
+
|
|
2584
|
+
Args:
|
|
2585
|
+
profile: Technology profile mapping (name, version).
|
|
2586
|
+
layers: Layer entries with display attributes.
|
|
2587
|
+
request_id: Client envelope id for idempotent replay.
|
|
2588
|
+
command_text: YAML command text for identity comparison.
|
|
2589
|
+
source: Provenance label for the profile source.
|
|
2590
|
+
version: Profile version pinned into the report.
|
|
2591
|
+
expected_revision: Revision the writer read.
|
|
2592
|
+
|
|
2593
|
+
Returns:
|
|
2594
|
+
Mutation mapping with receipt, events, and cursor.
|
|
2595
|
+
|
|
2596
|
+
Raises:
|
|
2597
|
+
ProjectError: request_conflict, revision_conflict,
|
|
2598
|
+
invalid_command for a malformed catalog.
|
|
2599
|
+
"""
|
|
2600
|
+
def _run(_store, rid, rtext, live):
|
|
2601
|
+
catalog = _layer_catalog.LayerCatalog(
|
|
2602
|
+
profile=dict(profile or {}))
|
|
2603
|
+
for entry in layers or []:
|
|
2604
|
+
catalog.add_layer(
|
|
2605
|
+
entry.get("name"), purpose=entry.get(
|
|
2606
|
+
"purpose", "drawing"),
|
|
2607
|
+
color=entry.get("color", "#ffffff"),
|
|
2608
|
+
order=entry.get("order", 0),
|
|
2609
|
+
visible=entry.get("visible", True))
|
|
2610
|
+
report = _layer_bindings.bind_layers(
|
|
2611
|
+
catalog, (profile or {}).get("layers", []),
|
|
2612
|
+
source=source, version=version)
|
|
2613
|
+
self.write_doc(
|
|
2614
|
+
"layers/bindings.yaml",
|
|
2615
|
+
_dump_yaml(report),
|
|
2616
|
+
f"{rid}:bindings", f"{rtext}\n#inner bindings",
|
|
2617
|
+
expected_revision=live,
|
|
2618
|
+
)
|
|
2619
|
+
return {
|
|
2620
|
+
"op": "bind_layers",
|
|
2621
|
+
"profile": report["profile"],
|
|
2622
|
+
"profile_version": report["profile_version"],
|
|
2623
|
+
"entries": report["entries"],
|
|
2624
|
+
"i28": {"profile": {
|
|
2625
|
+
"name": report["profile"],
|
|
2626
|
+
"version": report["profile_version"]}},
|
|
2627
|
+
}
|
|
2628
|
+
return _editors.editor_mutation(self,
|
|
2629
|
+
"bind_layers", request_id, command_text,
|
|
2630
|
+
expected_revision, _run,
|
|
2631
|
+
lambda receipt: (
|
|
2632
|
+
f"bound {len(receipt.get('entries', []))} layers "
|
|
2633
|
+
f"to {receipt.get('profile')}"),
|
|
2634
|
+
)
|
|
2635
|
+
|
|
2636
|
+
def annotate(self, request_id, command_text, prefixes=None,
|
|
2637
|
+
sheet=None, expected_revision=None):
|
|
2638
|
+
"""Assign reference designators to unannotated instances.
|
|
2639
|
+
|
|
2640
|
+
Counters continue past the highest numeric suffix already
|
|
2641
|
+
on the sheet set; annotated instances keep their refs.
|
|
2642
|
+
|
|
2643
|
+
Args:
|
|
2644
|
+
request_id: Client envelope id for idempotent replay.
|
|
2645
|
+
command_text: YAML command text for identity comparison.
|
|
2646
|
+
prefixes: Optional per-symbol prefix overrides.
|
|
2647
|
+
sheet: Sheet entity id to scope to, or None for all.
|
|
2648
|
+
expected_revision: Revision the writer read.
|
|
2649
|
+
|
|
2650
|
+
Returns:
|
|
2651
|
+
Mutation mapping with receipt, events, and cursor.
|
|
2652
|
+
|
|
2653
|
+
Raises:
|
|
2654
|
+
ProjectError: request_conflict, revision_conflict.
|
|
2655
|
+
"""
|
|
2656
|
+
return _editors.editor_mutation(self,
|
|
2657
|
+
"annotate", request_id, command_text,
|
|
2658
|
+
expected_revision,
|
|
2659
|
+
lambda store, _rid, _rt, _live: _frontend13.annotate(
|
|
2660
|
+
store, self._actor, prefixes=prefixes,
|
|
2661
|
+
sheet=sheet),
|
|
2662
|
+
lambda receipt: (
|
|
2663
|
+
f"annotated {len(receipt.get('annotated', []))} "
|
|
2664
|
+
"instances"),
|
|
2665
|
+
)
|
|
2666
|
+
|
|
2667
|
+
def create_testbench(self, name, kind, targets, request_id,
|
|
2668
|
+
command_text, probes=None, harness=None,
|
|
2669
|
+
template=None, expected_revision=None):
|
|
2670
|
+
"""Create one scoped testbench block with editable source.
|
|
2671
|
+
|
|
2672
|
+
Validates the DUT scope, materializes the interface, renders
|
|
2673
|
+
the template source, stores the record, and saves the source.
|
|
2674
|
+
|
|
2675
|
+
Args:
|
|
2676
|
+
name: Block name (source saved as testbench/<name>).
|
|
2677
|
+
kind: Template kind (spice, hdl, or cocotb).
|
|
2678
|
+
targets: DUT target set (primary plus additional).
|
|
2679
|
+
request_id: Client envelope id for idempotent replay.
|
|
2680
|
+
command_text: YAML command text for identity comparison.
|
|
2681
|
+
probes: Probe mappings inside the DUT closure.
|
|
2682
|
+
harness: Explicit harness binding mapping.
|
|
2683
|
+
template: Reserved template override (unused).
|
|
2684
|
+
expected_revision: Revision the writer read.
|
|
2685
|
+
|
|
2686
|
+
Returns:
|
|
2687
|
+
Mutation mapping with receipt, events, and cursor.
|
|
2688
|
+
|
|
2689
|
+
Raises:
|
|
2690
|
+
ProjectError: request_conflict, revision_conflict,
|
|
2691
|
+
invalid_kind, invalid_target, overlapping_targets,
|
|
2692
|
+
illegal_scope.
|
|
2693
|
+
"""
|
|
2694
|
+
return _editors.editor_mutation(self,
|
|
2695
|
+
"create_testbench", request_id, command_text,
|
|
2696
|
+
expected_revision,
|
|
2697
|
+
lambda _store, rid, rtext, live: _testbench.create_block(
|
|
2698
|
+
self, rid, rtext, name, kind, targets,
|
|
2699
|
+
probes or [], harness or {}, template=template,
|
|
2700
|
+
expected_revision=live),
|
|
2701
|
+
lambda receipt: (
|
|
2702
|
+
f"created {kind} testbench {name} "
|
|
2703
|
+
f"({receipt.get('testbench')})"),
|
|
2704
|
+
)
|
|
2705
|
+
|
|
2706
|
+
def regenerate_testbench(self, testbench, request_id,
|
|
2707
|
+
command_text, expected_revision=None):
|
|
2708
|
+
"""Regenerate one block to a candidate without overwriting.
|
|
2709
|
+
|
|
2710
|
+
A fresh link returns a noop receipt; a drifted link renders
|
|
2711
|
+
the candidate beside the edited live source for adoption.
|
|
2712
|
+
|
|
2713
|
+
Args:
|
|
2714
|
+
testbench: Testbench entity id.
|
|
2715
|
+
request_id: Client envelope id for idempotent replay.
|
|
2716
|
+
command_text: YAML command text for identity comparison.
|
|
2717
|
+
expected_revision: Revision the writer read.
|
|
2718
|
+
|
|
2719
|
+
Returns:
|
|
2720
|
+
Mutation mapping with receipt, events, and cursor.
|
|
2721
|
+
|
|
2722
|
+
Raises:
|
|
2723
|
+
ProjectError: request_conflict, revision_conflict,
|
|
2724
|
+
unknown_entity.
|
|
2725
|
+
"""
|
|
2726
|
+
return _editors.editor_mutation(self,
|
|
2727
|
+
"regenerate_testbench", request_id, command_text,
|
|
2728
|
+
expected_revision,
|
|
2729
|
+
lambda _store, rid, rtext, live: (
|
|
2730
|
+
_testbench.regenerate_block(
|
|
2731
|
+
self, rid, rtext, testbench,
|
|
2732
|
+
expected_revision=live)),
|
|
2733
|
+
lambda receipt: (
|
|
2734
|
+
"testbench fresh" if receipt.get("noop")
|
|
2735
|
+
else f"regenerated {testbench}"),
|
|
2736
|
+
)
|
|
2737
|
+
|
|
2738
|
+
def entity_revision(self, entity):
|
|
2739
|
+
"""Read the live per-entity revision of one entity.
|
|
2740
|
+
|
|
2741
|
+
Args:
|
|
2742
|
+
entity: Engine-minted entity id to inspect.
|
|
2743
|
+
|
|
2744
|
+
Returns:
|
|
2745
|
+
Mapping with entity, head revision, and the live
|
|
2746
|
+
entity revision plus the I28 closure pin.
|
|
2747
|
+
|
|
2748
|
+
Raises:
|
|
2749
|
+
ProjectError: unknown_entity for a missing id.
|
|
2750
|
+
"""
|
|
2751
|
+
revision = _cas.read_entity_revision(self, entity)
|
|
2752
|
+
return {
|
|
2753
|
+
"entity": entity,
|
|
2754
|
+
"entity_revision": revision,
|
|
2755
|
+
"current_revision": self._current_revision,
|
|
2756
|
+
"i28": pin_closure(
|
|
2757
|
+
self._current_revision,
|
|
2758
|
+
entities={entity: revision}),
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
def erc_report(self, severities=None, pin_conflict=None,
|
|
2762
|
+
net_pins=None):
|
|
2763
|
+
"""Run the ERC_TESTER rule set over the schematic.
|
|
2764
|
+
|
|
2765
|
+
Args:
|
|
2766
|
+
severities: Rule-to-severity overrides.
|
|
2767
|
+
pin_conflict: Pin-conflict severity overrides.
|
|
2768
|
+
net_pins: Materialized rows for the pin-conflict rule.
|
|
2769
|
+
|
|
2770
|
+
Returns:
|
|
2771
|
+
Findings with verdict plus revision and I28 pin.
|
|
2772
|
+
|
|
2773
|
+
Raises:
|
|
2774
|
+
ProjectError: invalid_command for a malformed config.
|
|
2775
|
+
"""
|
|
2776
|
+
check = lambda store: _frontend13.erc_check( # noqa: E731
|
|
2777
|
+
store, severities=severities, pin_conflict=pin_conflict,
|
|
2778
|
+
net_pins=net_pins)
|
|
2779
|
+
return _editors.editor_read(self, "erc_check", check)
|
|
2780
|
+
|
|
2781
|
+
def graph_report(self, sheet=None, net=None, net_pins=None):
|
|
2782
|
+
"""Report the connection graph or one net's occurrences.
|
|
2783
|
+
|
|
2784
|
+
Args:
|
|
2785
|
+
sheet: Sheet entity id to scope to, or None for all.
|
|
2786
|
+
net: Net name to collect, or None for the graph.
|
|
2787
|
+
net_pins: Materialized rows net collection needs.
|
|
2788
|
+
|
|
2789
|
+
Returns:
|
|
2790
|
+
Graph nodes/edges (or occurrence rows) with I28 pin.
|
|
2791
|
+
|
|
2792
|
+
Raises:
|
|
2793
|
+
ProjectError: invalid_command for a missing net.
|
|
2794
|
+
"""
|
|
2795
|
+
if net is not None:
|
|
2796
|
+
if not isinstance(net_pins, list):
|
|
2797
|
+
raise ProjectError(
|
|
2798
|
+
"invalid_command",
|
|
2799
|
+
"net_occurrences needs net_pins rows.")
|
|
2800
|
+
return {
|
|
2801
|
+
"net": net,
|
|
2802
|
+
"occurrences": _frontend13.net_occurrences(
|
|
2803
|
+
net_pins, net),
|
|
2804
|
+
"current_revision": self._current_revision,
|
|
2805
|
+
"i28": pin_closure(self._current_revision),
|
|
2806
|
+
}
|
|
2807
|
+
graph = lambda store: _frontend13.connection_graph( # noqa: E731
|
|
2808
|
+
store, sheet=sheet)
|
|
2809
|
+
return _editors.editor_read(self, "connection_graph", graph)
|
|
2810
|
+
|
|
2811
|
+
def probe_report(self, instance, pin, net_pins=None):
|
|
2812
|
+
"""Cross-probe one pin: symbol, cell, cell pin, net.
|
|
2813
|
+
|
|
2814
|
+
Without extracted net_pins the probe reports net_known
|
|
2815
|
+
False instead of guessing.
|
|
2816
|
+
|
|
2817
|
+
Args:
|
|
2818
|
+
instance: Instance entity id to probe.
|
|
2819
|
+
pin: Symbol pin to probe.
|
|
2820
|
+
net_pins: Materialized rows, or None when none ran.
|
|
2821
|
+
|
|
2822
|
+
Returns:
|
|
2823
|
+
Probe mapping with I28 pin.
|
|
2824
|
+
|
|
2825
|
+
Raises:
|
|
2826
|
+
ProjectError: unknown_instance, unknown_pin.
|
|
2827
|
+
"""
|
|
2828
|
+
probe = lambda store: _frontend13.xprobe( # noqa: E731
|
|
2829
|
+
store, instance, pin, net_pins=net_pins)
|
|
2830
|
+
return _editors.editor_read(self, "xprobe", probe)
|
|
2831
|
+
|
|
2832
|
+
def plot_report(self, format="svg", sheet=None):
|
|
2833
|
+
"""Export the schematic drawing (plot/export op).
|
|
2834
|
+
|
|
2835
|
+
Args:
|
|
2836
|
+
format: Drawing format (svg or json).
|
|
2837
|
+
sheet: Sheet entity id to scope to, or None for all.
|
|
2838
|
+
|
|
2839
|
+
Returns:
|
|
2840
|
+
Drawing with I28 pin.
|
|
2841
|
+
|
|
2842
|
+
Raises:
|
|
2843
|
+
ProjectError: invalid_format for an unknown format.
|
|
2844
|
+
"""
|
|
2845
|
+
plot = lambda store: _frontend13.plot_export( # noqa: E731
|
|
2846
|
+
store, format=format, sheet=sheet)
|
|
2847
|
+
return _editors.editor_read(self, "plot_export", plot)
|
|
2848
|
+
|
|
2849
|
+
def netlist_report(self, language, sheet=None, title="top",
|
|
2850
|
+
net_pins=None, prior_text=None,
|
|
2851
|
+
adopted=False):
|
|
2852
|
+
"""Write (or rewrite and diff) a netlist for the schematic.
|
|
2853
|
+
|
|
2854
|
+
Harness-owned instances stay excluded unless the design
|
|
2855
|
+
adopted the block. Dangling pins emit explicit stubs.
|
|
2856
|
+
|
|
2857
|
+
Args:
|
|
2858
|
+
language: Netlist language (spice or verilog).
|
|
2859
|
+
sheet: Sheet entity id to scope to, or None for all.
|
|
2860
|
+
title: Netlist/circuit title.
|
|
2861
|
+
net_pins: Materialized rows (required).
|
|
2862
|
+
prior_text: Previously emitted text for rewrites.
|
|
2863
|
+
adopted: True includes adopted harness instances.
|
|
2864
|
+
|
|
2865
|
+
Returns:
|
|
2866
|
+
Netlist text, counts, and I28 pin.
|
|
2867
|
+
|
|
2868
|
+
Raises:
|
|
2869
|
+
ProjectError: invalid_language, invalid_command.
|
|
2870
|
+
"""
|
|
2871
|
+
if not isinstance(net_pins, list):
|
|
2872
|
+
raise ProjectError(
|
|
2873
|
+
"invalid_command",
|
|
2874
|
+
"Netlist emission needs net_pins rows.")
|
|
2875
|
+
rows = list(net_pins)
|
|
2876
|
+
if not adopted:
|
|
2877
|
+
excluded = _testbench.excluded_harness_instances(self)
|
|
2878
|
+
rows = [row for row in rows
|
|
2879
|
+
if row.get("instance") not in excluded]
|
|
2880
|
+
if prior_text is None:
|
|
2881
|
+
emit = lambda store: _frontend13.write_netlist( # noqa: E731
|
|
2882
|
+
store, language=language, sheet=sheet,
|
|
2883
|
+
title=title, net_pins=rows)
|
|
2884
|
+
else:
|
|
2885
|
+
emit = lambda store: _frontend13.update_netlist( # noqa: E731
|
|
2886
|
+
store, language, prior_text, sheet=sheet,
|
|
2887
|
+
title=title, net_pins=rows)
|
|
2888
|
+
return _editors.editor_read(self, "netlist", emit)
|
|
2889
|
+
|
|
2890
|
+
def capability_report(self, profile, operation, bindings=None):
|
|
2891
|
+
"""Check whether one operation may run under a profile.
|
|
2892
|
+
|
|
2893
|
+
Args:
|
|
2894
|
+
profile: Technology profile mapping with name,
|
|
2895
|
+
version, source hash, and capabilities.
|
|
2896
|
+
operation: Operation name to check.
|
|
2897
|
+
bindings: Layer bindings the design needs.
|
|
2898
|
+
|
|
2899
|
+
Returns:
|
|
2900
|
+
Capability verdict with profile pin and I28 pin.
|
|
2901
|
+
|
|
2902
|
+
Raises:
|
|
2903
|
+
ProjectError: invalid_command for a malformed profile.
|
|
2904
|
+
"""
|
|
2905
|
+
for key in ("name", "version", "source_hash"):
|
|
2906
|
+
if not isinstance(profile, dict) or not profile.get(key):
|
|
2907
|
+
raise ProjectError(
|
|
2908
|
+
"invalid_command",
|
|
2909
|
+
f"capability_report needs profile {key}.")
|
|
2910
|
+
tech = _process.TechProfile(
|
|
2911
|
+
profile["name"], profile["version"],
|
|
2912
|
+
profile["source_hash"],
|
|
2913
|
+
units=profile.get("units", "nm"),
|
|
2914
|
+
grid=profile.get("grid", 1),
|
|
2915
|
+
deck=profile.get("deck"),
|
|
2916
|
+
capabilities=profile.get("capabilities"),
|
|
2917
|
+
)
|
|
2918
|
+
result = _editors.editor_value_error(
|
|
2919
|
+
lambda: _process.check_capability(
|
|
2920
|
+
tech, operation, bindings or []))
|
|
2921
|
+
return {
|
|
2922
|
+
**result, "current_revision": self._current_revision,
|
|
2923
|
+
"i28": pin_closure(
|
|
2924
|
+
self._current_revision, profile=tech.pin()),
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
def testbench_report(self, testbench):
|
|
2928
|
+
"""Describe one testbench block with freshness and pins.
|
|
2929
|
+
|
|
2930
|
+
Args:
|
|
2931
|
+
testbench: Testbench entity id.
|
|
2932
|
+
|
|
2933
|
+
Returns:
|
|
2934
|
+
Record with freshness, pins, digest, and I28 pin.
|
|
2935
|
+
|
|
2936
|
+
Raises:
|
|
2937
|
+
ProjectError: unknown_entity for a missing block.
|
|
2938
|
+
"""
|
|
2939
|
+
record = _testbench.describe_block(self, testbench)
|
|
2940
|
+
record["current_revision"] = self._current_revision
|
|
2941
|
+
return record
|