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,797 @@
|
|
|
1
|
+
"""Shared-engine port bus (Step 7A, ECS-FLOW slices 1-4; Step 7 notify).
|
|
2
|
+
|
|
3
|
+
One shared Engine per process serves many attached ports (stdio
|
|
4
|
+
by default; MCP / loopback-HTTP toggled live). A change through
|
|
5
|
+
any port modifies the world once; the bus re-emits the appended
|
|
6
|
+
ledger event to the other ports over async per-port queues with
|
|
7
|
+
client acks (QoS-1, NATS-JetStream style). Submit/admission stays
|
|
8
|
+
synchronous on the writer's path — only notification fans out
|
|
9
|
+
async, so a slow port never stalls writers.
|
|
10
|
+
|
|
11
|
+
Delivery model (ADR-017): at-least-once with acks; ledger
|
|
12
|
+
position (the event seq) is the packet id, so duplicates
|
|
13
|
+
collapse by position and every port handler applies
|
|
14
|
+
idempotently (position check, then apply) through the shared
|
|
15
|
+
PositionGuard. Slow consumers degrade to restart-and-resync:
|
|
16
|
+
the staged 10/15/20s ladder warns, quarantines (backlog
|
|
17
|
+
dropped), then hands a resync directive to the client, which
|
|
18
|
+
re-runs its idempotent onboarding ritual from the current
|
|
19
|
+
cursor. The engine flags, never kills: quarantine is
|
|
20
|
+
engine-side bookkeeping plus a resync flag, never client
|
|
21
|
+
state. A queue-depth ceiling beside the time threshold bounds
|
|
22
|
+
a port that acks slowly but never trips the ladder.
|
|
23
|
+
|
|
24
|
+
Port toggles are local-only (LOCAL-SINGLE-USER): enabling or
|
|
25
|
+
disabling a port, or swapping the boot profile, requires the
|
|
26
|
+
caller to assert a local caller; remote callers are refused
|
|
27
|
+
loudly instead of rewired.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
32
|
+
|
|
33
|
+
from .errors import ExecutionError
|
|
34
|
+
|
|
35
|
+
#: Staged no-ack escalation, seconds (ADR-017 D5, tunable per
|
|
36
|
+
#: bus): warn, quarantine (drop backlog), resync directive.
|
|
37
|
+
WARN_AFTER_S = 10.0
|
|
38
|
+
QUARANTINE_AFTER_S = 15.0
|
|
39
|
+
RESYNC_AFTER_S = 20.0
|
|
40
|
+
|
|
41
|
+
#: Boot-time default port profile: stdio alone (ADR-017 D6).
|
|
42
|
+
BOOT_PROFILE = ("stdio",)
|
|
43
|
+
|
|
44
|
+
#: Port states: active delivery, warned past the first rung,
|
|
45
|
+
#: quarantined (backlog dropped, resync flag pending).
|
|
46
|
+
_PORT_STATES = ("active", "warned", "quarantined")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PositionGuard:
|
|
50
|
+
"""Shared position-check-then-apply guard all ports use.
|
|
51
|
+
|
|
52
|
+
QoS-1 redelivers facts; redelivery of one ledger position
|
|
53
|
+
must apply once. Every port handler routes through this one
|
|
54
|
+
helper so per-port hand checks cannot drift.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
None.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self):
|
|
61
|
+
"""Create a guard with no applied positions.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Empty guard; every first position applies.
|
|
65
|
+
"""
|
|
66
|
+
self._lock = threading.RLock()
|
|
67
|
+
self._applied = {}
|
|
68
|
+
|
|
69
|
+
def applies(self, port, seq):
|
|
70
|
+
"""Check whether one ledger position still needs applying.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
port: Port name the position would apply on.
|
|
74
|
+
seq: Ledger position (event seq) to check.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
True when the position is newer than everything the
|
|
78
|
+
port applied before; False for a duplicate or an
|
|
79
|
+
older position.
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
ExecutionError: invalid_command for a non-integer
|
|
83
|
+
position.
|
|
84
|
+
"""
|
|
85
|
+
if (not isinstance(seq, int) or
|
|
86
|
+
isinstance(seq, bool)):
|
|
87
|
+
raise ExecutionError(
|
|
88
|
+
"invalid_command",
|
|
89
|
+
f"Position must be an integer seq, got {seq!r}.",
|
|
90
|
+
)
|
|
91
|
+
with self._lock:
|
|
92
|
+
return (port not in self._applied or
|
|
93
|
+
seq > self._applied[port])
|
|
94
|
+
|
|
95
|
+
def record(self, port, seq):
|
|
96
|
+
"""Mark one ledger position applied without calling anything.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
port: Port name the position applied on.
|
|
100
|
+
seq: Ledger position (event seq) now applied.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The recorded position.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
ExecutionError: invalid_command for a non-integer
|
|
107
|
+
position.
|
|
108
|
+
"""
|
|
109
|
+
if (not isinstance(seq, int) or
|
|
110
|
+
isinstance(seq, bool)):
|
|
111
|
+
raise ExecutionError(
|
|
112
|
+
"invalid_command",
|
|
113
|
+
f"Position must be an integer seq, got {seq!r}.",
|
|
114
|
+
)
|
|
115
|
+
with self._lock:
|
|
116
|
+
if (port not in self._applied or
|
|
117
|
+
seq > self._applied[port]):
|
|
118
|
+
self._applied[port] = seq
|
|
119
|
+
return self._applied[port]
|
|
120
|
+
|
|
121
|
+
def apply_once(self, port, seq, func, *args, **kwargs):
|
|
122
|
+
"""Apply one position through func at most once per port.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
port: Port name the position applies on.
|
|
126
|
+
seq: Ledger position (event seq) to apply.
|
|
127
|
+
func: Apply callable run only when the position is
|
|
128
|
+
new for the port.
|
|
129
|
+
args: Positional arguments forwarded to func.
|
|
130
|
+
kwargs: Keyword arguments forwarded to func.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
(applied, result) pair: applied True plus the func
|
|
134
|
+
result on first application, (False, None) on a
|
|
135
|
+
duplicate position.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
ExecutionError: invalid_command for a non-integer
|
|
139
|
+
position.
|
|
140
|
+
Exception: Whatever func raises propagates, and the
|
|
141
|
+
position is left unrecorded so redelivery retries.
|
|
142
|
+
"""
|
|
143
|
+
if not self.applies(port, seq):
|
|
144
|
+
return False, None
|
|
145
|
+
result = func(*args, **kwargs)
|
|
146
|
+
self.record(port, seq)
|
|
147
|
+
return True, result
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class PortBus:
|
|
151
|
+
"""Async per-port queues with acks over the ledger event log.
|
|
152
|
+
|
|
153
|
+
The bus never executes and never merges: writers append to
|
|
154
|
+
the ledger on their own synchronous path, then hand the
|
|
155
|
+
appended events to ingest (or the Engine syncs them with
|
|
156
|
+
sync_ports). Ingest fans out to every enabled push port
|
|
157
|
+
without blocking — a full or silent port is quarantined,
|
|
158
|
+
never a reason to stall a writer.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
clock: Nullary callable returning seconds; time.time
|
|
162
|
+
when omitted. Tests inject a manual clock so the
|
|
163
|
+
ladder stays deterministic.
|
|
164
|
+
warn_after: Seconds of unacked backlog before the warn
|
|
165
|
+
directive (ADR-017 default 10).
|
|
166
|
+
quarantine_after: Seconds of unacked backlog before the
|
|
167
|
+
backlog is dropped and the port quarantines
|
|
168
|
+
(default 15).
|
|
169
|
+
resync_after: Seconds of unacked backlog before the
|
|
170
|
+
resync directive fires (default 20).
|
|
171
|
+
max_depth: Per-port queue ceiling beside the time
|
|
172
|
+
ladder; a port past it quarantines immediately
|
|
173
|
+
(default 1024).
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def __init__(self, clock=None, warn_after=WARN_AFTER_S,
|
|
177
|
+
quarantine_after=QUARANTINE_AFTER_S,
|
|
178
|
+
resync_after=RESYNC_AFTER_S, max_depth=1024):
|
|
179
|
+
self._clock = clock or time.time
|
|
180
|
+
self._warn_after = float(warn_after)
|
|
181
|
+
self._quarantine_after = float(quarantine_after)
|
|
182
|
+
self._resync_after = float(resync_after)
|
|
183
|
+
self._max_depth = int(max_depth)
|
|
184
|
+
self._lock = threading.RLock()
|
|
185
|
+
self._ports = {}
|
|
186
|
+
self._guards = {}
|
|
187
|
+
self._head = 0
|
|
188
|
+
self._pumps = {}
|
|
189
|
+
|
|
190
|
+
def _now(self):
|
|
191
|
+
"""Read the bus clock.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Current time in seconds.
|
|
195
|
+
"""
|
|
196
|
+
return float(self._clock())
|
|
197
|
+
|
|
198
|
+
def _require(self, name):
|
|
199
|
+
"""Fetch one attached port record or fail loudly.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
name: Port name to look up.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Live port record mapping.
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
ExecutionError: unknown_port for a detached name.
|
|
209
|
+
"""
|
|
210
|
+
try:
|
|
211
|
+
return self._ports[name]
|
|
212
|
+
except KeyError:
|
|
213
|
+
raise ExecutionError(
|
|
214
|
+
"unknown_port", f"No such attached port: {name}."
|
|
215
|
+
) from None
|
|
216
|
+
|
|
217
|
+
def attach(self, name, push=True, max_depth=None):
|
|
218
|
+
"""Attach one port to the shared world.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
name: Port name (stdio, mcp, ide, loopback-http).
|
|
222
|
+
push: True for async push delivery; False leaves
|
|
223
|
+
the port pull-only (MCP default per ADR-017 D7:
|
|
224
|
+
it polls the cursor log, never wakes on push).
|
|
225
|
+
max_depth: Per-port queue ceiling; the bus default
|
|
226
|
+
when omitted.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Attached port descriptor (name, push, enabled,
|
|
230
|
+
state, cursor).
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
ExecutionError: invalid_command for a non-text or
|
|
234
|
+
empty name, port_exists for an attached name.
|
|
235
|
+
"""
|
|
236
|
+
if (not isinstance(name, str) or not name):
|
|
237
|
+
raise ExecutionError(
|
|
238
|
+
"invalid_command",
|
|
239
|
+
f"Port needs a non-empty text name, got {name!r}.",
|
|
240
|
+
)
|
|
241
|
+
with self._lock:
|
|
242
|
+
if name in self._ports:
|
|
243
|
+
raise ExecutionError(
|
|
244
|
+
"port_exists",
|
|
245
|
+
f"Port already attached: {name}.",
|
|
246
|
+
)
|
|
247
|
+
depth = (int(max_depth) if max_depth is not None
|
|
248
|
+
else self._max_depth)
|
|
249
|
+
record = {"name": name, "push": bool(push),
|
|
250
|
+
"enabled": True, "state": "active",
|
|
251
|
+
"resync_required": False, "queue": [],
|
|
252
|
+
"seen": 0, "acked": 0,
|
|
253
|
+
"last_ack_at": self._now(),
|
|
254
|
+
"max_depth": depth}
|
|
255
|
+
self._ports[name] = record
|
|
256
|
+
self._guards[name] = PositionGuard()
|
|
257
|
+
return self._describe_locked(record)
|
|
258
|
+
|
|
259
|
+
def detach(self, name):
|
|
260
|
+
"""Detach one port; its backlog is dropped with it.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
name: Attached port name to remove.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
Detached port descriptor.
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
ExecutionError: unknown_port for a detached name.
|
|
270
|
+
"""
|
|
271
|
+
with self._lock:
|
|
272
|
+
record = self._require(name)
|
|
273
|
+
self.stop(name)
|
|
274
|
+
del self._ports[name]
|
|
275
|
+
self._guards.pop(name, None)
|
|
276
|
+
return self._describe_locked(record)
|
|
277
|
+
|
|
278
|
+
def guard_for(self, name):
|
|
279
|
+
"""Fetch one port's bound position guard.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
name: Attached port name.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
The port's shared PositionGuard.
|
|
286
|
+
|
|
287
|
+
Raises:
|
|
288
|
+
ExecutionError: unknown_port for a detached name.
|
|
289
|
+
"""
|
|
290
|
+
with self._lock:
|
|
291
|
+
self._require(name)
|
|
292
|
+
return self._guards[name]
|
|
293
|
+
|
|
294
|
+
def _describe_locked(self, record):
|
|
295
|
+
"""Render one port record without its queued events.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
record: Live port record mapping.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
Descriptor with name, push, enabled, state,
|
|
302
|
+
resync_required, backlog, cursor, acked, and
|
|
303
|
+
max_depth.
|
|
304
|
+
"""
|
|
305
|
+
return {"name": record["name"], "push": record["push"],
|
|
306
|
+
"enabled": record["enabled"],
|
|
307
|
+
"state": record["state"],
|
|
308
|
+
"resync_required": record["resync_required"],
|
|
309
|
+
"backlog": len(record["queue"]),
|
|
310
|
+
"cursor": record["seen"],
|
|
311
|
+
"acked": record["acked"],
|
|
312
|
+
"max_depth": record["max_depth"]}
|
|
313
|
+
|
|
314
|
+
def port_state(self, name):
|
|
315
|
+
"""Read one port's engine-side bookkeeping.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
name: Attached port name.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
Descriptor with name, push, enabled, state,
|
|
322
|
+
resync_required, backlog, cursor, acked, and
|
|
323
|
+
max_depth.
|
|
324
|
+
|
|
325
|
+
Raises:
|
|
326
|
+
ExecutionError: unknown_port for a detached name.
|
|
327
|
+
"""
|
|
328
|
+
with self._lock:
|
|
329
|
+
return self._describe_locked(self._require(name))
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def cursor(self):
|
|
333
|
+
"""Highest ledger position ingested across all ports.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
Bus head seq; ports resync to this position.
|
|
337
|
+
"""
|
|
338
|
+
with self._lock:
|
|
339
|
+
return self._head
|
|
340
|
+
|
|
341
|
+
def _quarantine_locked(self, record, reason):
|
|
342
|
+
"""Quarantine one port: drop backlog, flag resync.
|
|
343
|
+
|
|
344
|
+
The engine flags, never kills: the client process is
|
|
345
|
+
untouched; only delivery stops until the client
|
|
346
|
+
re-runs its onboarding ritual and resyncs.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
record: Live port record mapping.
|
|
350
|
+
reason: Why the port quarantined (ladder or depth).
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
Quarantine directive mapping.
|
|
354
|
+
"""
|
|
355
|
+
record["queue"].clear()
|
|
356
|
+
record["state"] = "quarantined"
|
|
357
|
+
record["resync_required"] = True
|
|
358
|
+
return {"port": record["name"], "directive": reason,
|
|
359
|
+
"resync_required": True}
|
|
360
|
+
|
|
361
|
+
def ingest(self, events):
|
|
362
|
+
"""Fan appended ledger events out to every push port.
|
|
363
|
+
|
|
364
|
+
Never blocks and never raises for port conditions: a
|
|
365
|
+
port past its depth ceiling quarantines (backlog
|
|
366
|
+
dropped, resync flagged) while writers and every other
|
|
367
|
+
port continue. Disabled, pull-only, and quarantined
|
|
368
|
+
ports are skipped — a quarantined port converges only
|
|
369
|
+
through resync, never through catch-up flooding.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
events: Ledger event mappings, each with an
|
|
373
|
+
integer seq, oldest first.
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
Per-port enqueue counts for the ports that took
|
|
377
|
+
the batch.
|
|
378
|
+
|
|
379
|
+
Raises:
|
|
380
|
+
ExecutionError: invalid_command for a non-mapping
|
|
381
|
+
event or a non-integer seq.
|
|
382
|
+
"""
|
|
383
|
+
batch = list(events or [])
|
|
384
|
+
for event in batch:
|
|
385
|
+
if not isinstance(event, dict):
|
|
386
|
+
raise ExecutionError(
|
|
387
|
+
"invalid_command",
|
|
388
|
+
f"Bus events must be mappings, got {event!r}.",
|
|
389
|
+
)
|
|
390
|
+
seq = event.get("seq")
|
|
391
|
+
if (not isinstance(seq, int) or
|
|
392
|
+
isinstance(seq, bool)):
|
|
393
|
+
raise ExecutionError(
|
|
394
|
+
"invalid_command",
|
|
395
|
+
f"Bus events need an integer seq, got {event!r}.",
|
|
396
|
+
)
|
|
397
|
+
counts = {}
|
|
398
|
+
with self._lock:
|
|
399
|
+
for event in batch:
|
|
400
|
+
seq = event["seq"]
|
|
401
|
+
if seq > self._head:
|
|
402
|
+
self._head = seq
|
|
403
|
+
for record in self._ports.values():
|
|
404
|
+
if (not record["enabled"] or
|
|
405
|
+
not record["push"] or
|
|
406
|
+
record["state"] == "quarantined"):
|
|
407
|
+
continue
|
|
408
|
+
if seq <= record["seen"]:
|
|
409
|
+
continue
|
|
410
|
+
record["queue"].append(dict(event))
|
|
411
|
+
record["seen"] = seq
|
|
412
|
+
counts[record["name"]] = (
|
|
413
|
+
counts.get(record["name"], 0) + 1)
|
|
414
|
+
if (len(record["queue"]) >
|
|
415
|
+
record["max_depth"]):
|
|
416
|
+
self._quarantine_locked(record,
|
|
417
|
+
"depth_ceiling")
|
|
418
|
+
return counts
|
|
419
|
+
|
|
420
|
+
def pending(self, name):
|
|
421
|
+
"""Read one port's queued events without consuming them.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
name: Attached port name.
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
Queued event copies, oldest first.
|
|
428
|
+
|
|
429
|
+
Raises:
|
|
430
|
+
ExecutionError: unknown_port for a detached name.
|
|
431
|
+
"""
|
|
432
|
+
with self._lock:
|
|
433
|
+
record = self._require(name)
|
|
434
|
+
return [dict(event) for event in record["queue"]]
|
|
435
|
+
|
|
436
|
+
def ack(self, name, seq):
|
|
437
|
+
"""Acknowledge delivery through one ledger position.
|
|
438
|
+
|
|
439
|
+
Any ack — even for an already-dropped position — counts
|
|
440
|
+
as client activity and refreshes the ladder clock.
|
|
441
|
+
Queue entries at or below the position are dropped.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
name: Attached port name.
|
|
445
|
+
seq: Ledger position acknowledged.
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
Ack record with port, acked position, and backlog.
|
|
449
|
+
|
|
450
|
+
Raises:
|
|
451
|
+
ExecutionError: unknown_port for a detached name,
|
|
452
|
+
invalid_command for a non-integer position.
|
|
453
|
+
"""
|
|
454
|
+
if (not isinstance(seq, int) or
|
|
455
|
+
isinstance(seq, bool)):
|
|
456
|
+
raise ExecutionError(
|
|
457
|
+
"invalid_command",
|
|
458
|
+
f"Ack needs an integer seq, got {seq!r}.",
|
|
459
|
+
)
|
|
460
|
+
with self._lock:
|
|
461
|
+
record = self._require(name)
|
|
462
|
+
if seq > record["acked"]:
|
|
463
|
+
record["acked"] = seq
|
|
464
|
+
record["queue"] = [event for event in record["queue"]
|
|
465
|
+
if event["seq"] > record["acked"]]
|
|
466
|
+
record["last_ack_at"] = self._now()
|
|
467
|
+
return {"port": name, "acked": record["acked"],
|
|
468
|
+
"backlog": len(record["queue"])}
|
|
469
|
+
|
|
470
|
+
def port_evaluate(self):
|
|
471
|
+
"""Run the staged no-ack ladder over every attached port.
|
|
472
|
+
|
|
473
|
+
A port with an unacked backlog escalates: past
|
|
474
|
+
warn_after it is flagged warned, past
|
|
475
|
+
quarantine_after its backlog is dropped and it
|
|
476
|
+
quarantines; past resync_after the engine-side resync
|
|
477
|
+
flag (the resync directive) fires for the drained
|
|
478
|
+
port. Each rung fires once per episode; a port that
|
|
479
|
+
keeps consuming or acking never escalates.
|
|
480
|
+
|
|
481
|
+
Args:
|
|
482
|
+
None.
|
|
483
|
+
|
|
484
|
+
Returns:
|
|
485
|
+
Directive mappings (port, directive, resync_required
|
|
486
|
+
for the terminal rungs) in attach order.
|
|
487
|
+
"""
|
|
488
|
+
directives = []
|
|
489
|
+
with self._lock:
|
|
490
|
+
now = self._now()
|
|
491
|
+
for record in self._ports.values():
|
|
492
|
+
if not record["enabled"]:
|
|
493
|
+
continue
|
|
494
|
+
age = now - record["last_ack_at"]
|
|
495
|
+
if record["queue"]:
|
|
496
|
+
if (age > self._warn_after and
|
|
497
|
+
record["state"] == "active"):
|
|
498
|
+
record["state"] = "warned"
|
|
499
|
+
directives.append(
|
|
500
|
+
{"port": record["name"],
|
|
501
|
+
"directive": "warn", "age_s": age})
|
|
502
|
+
if (age > self._quarantine_after and
|
|
503
|
+
record["state"] == "warned"):
|
|
504
|
+
record["queue"].clear()
|
|
505
|
+
record["state"] = "quarantined"
|
|
506
|
+
record["resync_required"] = True
|
|
507
|
+
directives.append(
|
|
508
|
+
{"port": record["name"],
|
|
509
|
+
"directive": "quarantine",
|
|
510
|
+
"age_s": age,
|
|
511
|
+
"resync_required": True})
|
|
512
|
+
if (age > self._resync_after and
|
|
513
|
+
record["state"] == "quarantined" and
|
|
514
|
+
record["resync_required"] and
|
|
515
|
+
not record["queue"]):
|
|
516
|
+
directives.append(
|
|
517
|
+
{"port": record["name"],
|
|
518
|
+
"directive": "resync_required",
|
|
519
|
+
"age_s": age, "resync_required": True})
|
|
520
|
+
return directives
|
|
521
|
+
|
|
522
|
+
def port_resync(self, name, head=None):
|
|
523
|
+
"""Converge one port onto the current ledger head.
|
|
524
|
+
|
|
525
|
+
The client re-runs its idempotent onboarding ritual
|
|
526
|
+
from this position: the backlog is dropped, the cursor
|
|
527
|
+
jumps to head, flags clear. Quarantine is dodgeable
|
|
528
|
+
only through a fresh port otherwise — reconnects come
|
|
529
|
+
back as new ports with fresh cursors.
|
|
530
|
+
|
|
531
|
+
Args:
|
|
532
|
+
name: Attached port name.
|
|
533
|
+
head: Ledger position to converge on; the bus head
|
|
534
|
+
when omitted.
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
Resync receipt with port, cursor, state, and the
|
|
538
|
+
cleared resync flag.
|
|
539
|
+
|
|
540
|
+
Raises:
|
|
541
|
+
ExecutionError: unknown_port for a detached name,
|
|
542
|
+
invalid_command for a non-integer head.
|
|
543
|
+
"""
|
|
544
|
+
if head is not None and (
|
|
545
|
+
not isinstance(head, int) or
|
|
546
|
+
isinstance(head, bool)):
|
|
547
|
+
raise ExecutionError(
|
|
548
|
+
"invalid_command",
|
|
549
|
+
f"Resync head must be an integer seq, got {head!r}.",
|
|
550
|
+
)
|
|
551
|
+
with self._lock:
|
|
552
|
+
record = self._require(name)
|
|
553
|
+
target = self._head if head is None else head
|
|
554
|
+
record["queue"].clear()
|
|
555
|
+
record["seen"] = max(record["seen"], target)
|
|
556
|
+
record["acked"] = max(record["acked"], target)
|
|
557
|
+
record["state"] = "active"
|
|
558
|
+
record["resync_required"] = False
|
|
559
|
+
record["last_ack_at"] = self._now()
|
|
560
|
+
return {"port": name, "cursor": record["seen"],
|
|
561
|
+
"state": "active",
|
|
562
|
+
"resync_required": False}
|
|
563
|
+
|
|
564
|
+
def set_enabled(self, name, enabled, local=False):
|
|
565
|
+
"""Enable or disable one port live (local-only toggle).
|
|
566
|
+
|
|
567
|
+
Disabling quarantines like a dead ship: the backlog is
|
|
568
|
+
dropped and the resync flag is set, so a returning
|
|
569
|
+
port must resync before it converges. Enabling never
|
|
570
|
+
clears the flag — the client resyncs explicitly.
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
name: Attached port name.
|
|
574
|
+
enabled: True to attach the port to live delivery,
|
|
575
|
+
False to park it.
|
|
576
|
+
local: Caller asserts a local (loopback/stdio)
|
|
577
|
+
caller; anything else is refused loudly per
|
|
578
|
+
LOCAL-SINGLE-USER.
|
|
579
|
+
|
|
580
|
+
Returns:
|
|
581
|
+
Updated port descriptor.
|
|
582
|
+
|
|
583
|
+
Raises:
|
|
584
|
+
ExecutionError: unknown_port for a detached name,
|
|
585
|
+
remote_toggle_denied for a non-local caller.
|
|
586
|
+
"""
|
|
587
|
+
if local is not True:
|
|
588
|
+
raise ExecutionError(
|
|
589
|
+
"remote_toggle_denied",
|
|
590
|
+
"Port toggles are local-only; a remote caller "
|
|
591
|
+
f"cannot change port {name}.",
|
|
592
|
+
)
|
|
593
|
+
with self._lock:
|
|
594
|
+
record = self._require(name)
|
|
595
|
+
record["enabled"] = bool(enabled)
|
|
596
|
+
if not enabled:
|
|
597
|
+
record["queue"].clear()
|
|
598
|
+
record["state"] = "quarantined"
|
|
599
|
+
record["resync_required"] = True
|
|
600
|
+
return self._describe_locked(record)
|
|
601
|
+
|
|
602
|
+
def set_profile(self, names, local=False):
|
|
603
|
+
"""Swap the live port profile (local-only toggle).
|
|
604
|
+
|
|
605
|
+
Missing names attach as push ports; every other
|
|
606
|
+
attached port disables like a dead ship (backlog
|
|
607
|
+
dropped, resync flagged). The boot profile is stdio
|
|
608
|
+
alone; MCP attaches when the agent arrives, loopback
|
|
609
|
+
HTTP when the IDE wants network.
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
names: Port names forming the live profile.
|
|
613
|
+
local: Caller asserts a local (loopback/stdio)
|
|
614
|
+
caller; anything else is refused loudly per
|
|
615
|
+
LOCAL-SINGLE-USER.
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
Profile mapping with the enabled names sorted.
|
|
619
|
+
|
|
620
|
+
Raises:
|
|
621
|
+
ExecutionError: remote_toggle_denied for a
|
|
622
|
+
non-local caller, invalid_command for a
|
|
623
|
+
non-list or empty profile.
|
|
624
|
+
"""
|
|
625
|
+
if local is not True:
|
|
626
|
+
raise ExecutionError(
|
|
627
|
+
"remote_toggle_denied",
|
|
628
|
+
"Port profile swaps are local-only; a remote "
|
|
629
|
+
"caller cannot change the profile.",
|
|
630
|
+
)
|
|
631
|
+
wanted = list(names or [])
|
|
632
|
+
if (not isinstance(names, (list, tuple)) or
|
|
633
|
+
not wanted):
|
|
634
|
+
raise ExecutionError(
|
|
635
|
+
"invalid_command",
|
|
636
|
+
"A port profile needs a non-empty list of names.",
|
|
637
|
+
)
|
|
638
|
+
with self._lock:
|
|
639
|
+
for name in wanted:
|
|
640
|
+
if name not in self._ports:
|
|
641
|
+
self._ports[name] = {
|
|
642
|
+
"name": name, "push": True,
|
|
643
|
+
"enabled": True, "state": "active",
|
|
644
|
+
"resync_required": False, "queue": [],
|
|
645
|
+
"seen": 0, "acked": 0,
|
|
646
|
+
"last_ack_at": self._now(),
|
|
647
|
+
"max_depth": self._max_depth}
|
|
648
|
+
self._guards[name] = PositionGuard()
|
|
649
|
+
for name, record in self._ports.items():
|
|
650
|
+
if name in wanted:
|
|
651
|
+
record["enabled"] = True
|
|
652
|
+
else:
|
|
653
|
+
record["enabled"] = False
|
|
654
|
+
record["queue"].clear()
|
|
655
|
+
record["state"] = "quarantined"
|
|
656
|
+
record["resync_required"] = True
|
|
657
|
+
return {"profile": sorted(wanted)}
|
|
658
|
+
|
|
659
|
+
def drain(self, name, handler, limit=None, guarded=True):
|
|
660
|
+
"""Deliver one port's backlog through a handler, then ack.
|
|
661
|
+
|
|
662
|
+
Handlers run outside the bus lock; a slow handler never
|
|
663
|
+
blocks writers or other ports. With the port's bound
|
|
664
|
+
guard, a redelivered position is acknowledged but not
|
|
665
|
+
re-applied. A raising handler keeps its event at the
|
|
666
|
+
head of the queue and stops the drain — nothing is
|
|
667
|
+
lost, nothing is acked past the failure.
|
|
668
|
+
|
|
669
|
+
Args:
|
|
670
|
+
name: Attached port name.
|
|
671
|
+
handler: Callable taking one event mapping.
|
|
672
|
+
limit: Maximum events to deliver; the whole
|
|
673
|
+
backlog when omitted.
|
|
674
|
+
guarded: True routes every position through the
|
|
675
|
+
port's bound PositionGuard; False applies
|
|
676
|
+
every delivery unconditionally.
|
|
677
|
+
|
|
678
|
+
Returns:
|
|
679
|
+
Drain record with port, delivered and applied
|
|
680
|
+
counts, the acked position, and handler
|
|
681
|
+
error messages.
|
|
682
|
+
|
|
683
|
+
Raises:
|
|
684
|
+
ExecutionError: unknown_port for a detached name.
|
|
685
|
+
"""
|
|
686
|
+
with self._lock:
|
|
687
|
+
record = self._require(name)
|
|
688
|
+
guard = self._guards[name]
|
|
689
|
+
batch = list(record["queue"])
|
|
690
|
+
if limit is not None:
|
|
691
|
+
batch = batch[:max(0, int(limit))]
|
|
692
|
+
del record["queue"][:len(batch)]
|
|
693
|
+
delivered = 0
|
|
694
|
+
applied = 0
|
|
695
|
+
errors = []
|
|
696
|
+
last_seq = None
|
|
697
|
+
for index, event in enumerate(batch):
|
|
698
|
+
seq = event["seq"]
|
|
699
|
+
try:
|
|
700
|
+
if guarded:
|
|
701
|
+
did, _ = guard.apply_once(
|
|
702
|
+
name, seq, handler, event)
|
|
703
|
+
else:
|
|
704
|
+
handler(event)
|
|
705
|
+
did = True
|
|
706
|
+
except Exception as exc:
|
|
707
|
+
with self._lock:
|
|
708
|
+
live = self._ports.get(name)
|
|
709
|
+
if live is not None:
|
|
710
|
+
live["queue"][0:0] = batch[index:]
|
|
711
|
+
errors.append(f"{type(exc).__name__}: {exc}")
|
|
712
|
+
break
|
|
713
|
+
delivered += 1
|
|
714
|
+
if did:
|
|
715
|
+
applied += 1
|
|
716
|
+
last_seq = seq
|
|
717
|
+
if last_seq is not None:
|
|
718
|
+
self.ack(name, last_seq)
|
|
719
|
+
with self._lock:
|
|
720
|
+
acked = self._require(name)["acked"]
|
|
721
|
+
return {"port": name, "delivered": delivered,
|
|
722
|
+
"applied": applied, "acked": acked,
|
|
723
|
+
"errors": errors}
|
|
724
|
+
|
|
725
|
+
def start(self, name, handler, interval=0.01):
|
|
726
|
+
"""Pump one port on a daemon thread until stopped.
|
|
727
|
+
|
|
728
|
+
Args:
|
|
729
|
+
name: Attached port name.
|
|
730
|
+
handler: Callable taking one event mapping; its
|
|
731
|
+
exceptions are contained per drain and
|
|
732
|
+
retried on the next pass.
|
|
733
|
+
interval: Idle seconds between passes when the
|
|
734
|
+
backlog is empty.
|
|
735
|
+
|
|
736
|
+
Returns:
|
|
737
|
+
True once the pump runs.
|
|
738
|
+
|
|
739
|
+
Raises:
|
|
740
|
+
ExecutionError: unknown_port for a detached name,
|
|
741
|
+
delivery_running for an already-pumped port.
|
|
742
|
+
"""
|
|
743
|
+
with self._lock:
|
|
744
|
+
self._require(name)
|
|
745
|
+
if name in self._pumps:
|
|
746
|
+
raise ExecutionError(
|
|
747
|
+
"delivery_running",
|
|
748
|
+
f"Port pump already runs: {name}.",
|
|
749
|
+
)
|
|
750
|
+
halt = threading.Event()
|
|
751
|
+
worker = threading.Thread(
|
|
752
|
+
target=self._pump, args=(name, handler,
|
|
753
|
+
float(interval), halt),
|
|
754
|
+
daemon=True, name=f"port-pump-{name}")
|
|
755
|
+
self._pumps[name] = (worker, halt)
|
|
756
|
+
worker.start()
|
|
757
|
+
return True
|
|
758
|
+
|
|
759
|
+
def _pump(self, name, handler, interval, halt):
|
|
760
|
+
"""Run one port pump until its halt event is set.
|
|
761
|
+
|
|
762
|
+
Args:
|
|
763
|
+
name: Attached port name.
|
|
764
|
+
handler: Callable taking one event mapping.
|
|
765
|
+
interval: Idle seconds between empty passes.
|
|
766
|
+
halt: Threading event stopping the pump.
|
|
767
|
+
|
|
768
|
+
Returns:
|
|
769
|
+
None.
|
|
770
|
+
"""
|
|
771
|
+
while not halt.is_set():
|
|
772
|
+
try:
|
|
773
|
+
out = self.drain(name, handler)
|
|
774
|
+
except ExecutionError:
|
|
775
|
+
return
|
|
776
|
+
if not out["delivered"]:
|
|
777
|
+
halt.wait(interval)
|
|
778
|
+
|
|
779
|
+
def stop(self, name):
|
|
780
|
+
"""Stop one port's background pump, if any.
|
|
781
|
+
|
|
782
|
+
Args:
|
|
783
|
+
name: Attached port name.
|
|
784
|
+
|
|
785
|
+
Returns:
|
|
786
|
+
True when a pump stopped, False when the port had
|
|
787
|
+
none running.
|
|
788
|
+
"""
|
|
789
|
+
with self._lock:
|
|
790
|
+
entry = self._pumps.pop(name, None)
|
|
791
|
+
if entry is None:
|
|
792
|
+
return False
|
|
793
|
+
worker, halt = entry
|
|
794
|
+
halt.set()
|
|
795
|
+
if threading.current_thread() is not worker:
|
|
796
|
+
worker.join(timeout=5)
|
|
797
|
+
return True
|