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,897 @@
|
|
|
1
|
+
"""Headless Python execution (PYTHON-EXEC): inline lines + project files.
|
|
2
|
+
|
|
3
|
+
First-class scripted execution on the prepared worker substrate the
|
|
4
|
+
stdio console shares: every `python_execute` call runs in a fresh
|
|
5
|
+
Bubblewrap-confined worker (pinned interpreter, PDK access, tool
|
|
6
|
+
binaries) with the console prebounds (`project/tools/pdk/gf/np/plot`)
|
|
7
|
+
available and zero setup. Exactly two input modes exist — `lines`
|
|
8
|
+
(inline script text) and `path` (a project-contained script file) —
|
|
9
|
+
and nothing else: no shells, no absolute host paths.
|
|
10
|
+
|
|
11
|
+
Approval resolves per python-console-spec, never inside the run:
|
|
12
|
+
`pending_approval` / `approved` / `denied` / `expired` are approval
|
|
13
|
+
states, distinct from the job states (`queued` / `running` /
|
|
14
|
+
`completed` / `cancelled`) the execution service owns. Agent calls
|
|
15
|
+
request approval; only the human UI route resolves it
|
|
16
|
+
(`resolve_approval(via="ui")`) — MCP self-approval raises
|
|
17
|
+
`approval_forbidden`. Denial and expiry create no run.
|
|
18
|
+
|
|
19
|
+
Completion is never a check verdict: every terminal record carries
|
|
20
|
+
`state`/`ok` plus a `NOT_RUN` verdict — zero exit is not PASS, read
|
|
21
|
+
the execution record. Effects (op calls the cell recorded) replay
|
|
22
|
+
through the shared op dispatch after the worker exits, and their
|
|
23
|
+
child `run_ids` ride on the record as structured data.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import hashlib
|
|
27
|
+
import io
|
|
28
|
+
import json
|
|
29
|
+
import tempfile
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
32
|
+
import uuid
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from ruamel.yaml import YAML
|
|
36
|
+
|
|
37
|
+
from .errors import ExecutionError
|
|
38
|
+
from .service import ExecutionService
|
|
39
|
+
from .pyexec.approvals import _ApprovalMixin
|
|
40
|
+
from .pyexec.runner import PythonExecRunner
|
|
41
|
+
from .pyexec.support import (
|
|
42
|
+
_check_timeout,
|
|
43
|
+
_render_preamble,
|
|
44
|
+
_validate_artifacts,
|
|
45
|
+
CELL_ENTRY,
|
|
46
|
+
DEFAULT_TIMEOUT_S,
|
|
47
|
+
MAX_ARTIFACTS,
|
|
48
|
+
MAX_LINES_BYTES,
|
|
49
|
+
MAX_PATH_CHARS,
|
|
50
|
+
MAX_TIMEOUT_S,
|
|
51
|
+
MIN_TIMEOUT_S,
|
|
52
|
+
OPS_LOG_NAME,
|
|
53
|
+
PRELUDE_DIGEST,
|
|
54
|
+
PRELUDE_TEMPLATE,
|
|
55
|
+
build_worker_source,
|
|
56
|
+
not_run_verdict,
|
|
57
|
+
parse_exception,
|
|
58
|
+
prelude_digest_for,
|
|
59
|
+
resolve_script_path,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"CELL_ENTRY",
|
|
65
|
+
"DEFAULT_APPROVAL_TTL_S",
|
|
66
|
+
"DEFAULT_TIMEOUT_S",
|
|
67
|
+
"MAX_ARTIFACTS",
|
|
68
|
+
"MAX_LINES_BYTES",
|
|
69
|
+
"MAX_PATH_CHARS",
|
|
70
|
+
"MAX_TIMEOUT_S",
|
|
71
|
+
"MCP_INPUT_SCHEMA",
|
|
72
|
+
"MIN_TIMEOUT_S",
|
|
73
|
+
"MODES",
|
|
74
|
+
"OPS_LOG_NAME",
|
|
75
|
+
"PRELUDE_DIGEST",
|
|
76
|
+
"PRELUDE_TEMPLATE",
|
|
77
|
+
"PythonExecRunner",
|
|
78
|
+
"PythonExecService",
|
|
79
|
+
"build_worker_source",
|
|
80
|
+
"not_run_verdict",
|
|
81
|
+
"parse_exception",
|
|
82
|
+
"prelude_digest_for",
|
|
83
|
+
"resolve_script_path",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
#: Exactly two input modes: inline script text, or a project file.
|
|
87
|
+
MODES = ("lines", "path")
|
|
88
|
+
|
|
89
|
+
#: Pending approvals lapse after this long without a UI decision.
|
|
90
|
+
DEFAULT_APPROVAL_TTL_S = 900
|
|
91
|
+
|
|
92
|
+
#: Live index bound: denied/expired approvals past this count evict
|
|
93
|
+
#: oldest-first. Pending and approved approvals are live
|
|
94
|
+
#: references (runs and status reads resolve through them) and
|
|
95
|
+
#: never reap; the `approvals/*.yaml` audit trail keeps every
|
|
96
|
+
#: record on disk regardless of eviction.
|
|
97
|
+
MAX_TERMINAL_APPROVALS = 100
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
MCP_INPUT_SCHEMA = {
|
|
101
|
+
"type": "object",
|
|
102
|
+
"description": (
|
|
103
|
+
"Execute a Python cell in a fresh prepared worker. Exactly one "
|
|
104
|
+
"of lines (inline script text) or path (a project-contained "
|
|
105
|
+
"script file) is required; the other must be absent."
|
|
106
|
+
),
|
|
107
|
+
"properties": {
|
|
108
|
+
"mode": {
|
|
109
|
+
"type": "string",
|
|
110
|
+
"enum": ["lines", "path"],
|
|
111
|
+
"description": "Input mode: inline script text or project file.",
|
|
112
|
+
},
|
|
113
|
+
"lines": {
|
|
114
|
+
"type": "string",
|
|
115
|
+
"maxLength": MAX_LINES_BYTES,
|
|
116
|
+
"description": "Inline script text (lines mode only).",
|
|
117
|
+
},
|
|
118
|
+
"path": {
|
|
119
|
+
"type": "string",
|
|
120
|
+
"maxLength": MAX_PATH_CHARS,
|
|
121
|
+
"description": (
|
|
122
|
+
"Project-relative script path (path mode only); "
|
|
123
|
+
"traversal and out-of-project paths are rejected."
|
|
124
|
+
),
|
|
125
|
+
},
|
|
126
|
+
"timeout_s": {
|
|
127
|
+
"type": "integer",
|
|
128
|
+
"minimum": MIN_TIMEOUT_S,
|
|
129
|
+
"maximum": MAX_TIMEOUT_S,
|
|
130
|
+
"description": "Worker budget in seconds (default 120).",
|
|
131
|
+
},
|
|
132
|
+
"artifacts": {
|
|
133
|
+
"type": "array",
|
|
134
|
+
"items": {"type": "string"},
|
|
135
|
+
"maxItems": MAX_ARTIFACTS,
|
|
136
|
+
"description": "Scratch-relative paths to harvest.",
|
|
137
|
+
},
|
|
138
|
+
"expected_revision": {
|
|
139
|
+
"type": "string",
|
|
140
|
+
"description": "Project revision the approval binds.",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
"required": ["mode"],
|
|
144
|
+
"oneOf": [
|
|
145
|
+
{
|
|
146
|
+
"properties": {"mode": {"const": "lines"}},
|
|
147
|
+
"required": ["mode", "lines"],
|
|
148
|
+
"not": {"required": ["path"]},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"properties": {"mode": {"const": "path"}},
|
|
152
|
+
"required": ["mode", "path"],
|
|
153
|
+
"not": {"required": ["lines"]},
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class PythonExecService(_ApprovalMixin):
|
|
160
|
+
"""Approval-gated headless execution: request, resolve, run, cancel.
|
|
161
|
+
|
|
162
|
+
Agents request executions; the human UI route resolves the
|
|
163
|
+
approval; admitted runs execute in fresh prepared workers and
|
|
164
|
+
their recorded effects replay through the shared op dispatch.
|
|
165
|
+
Approval states (`pending_approval`/`approved`/`denied`/
|
|
166
|
+
`expired`) live in this service's approval ledger, never in the
|
|
167
|
+
job records — denial and expiry create no run, and a pending
|
|
168
|
+
approval addresses no runnable job.
|
|
169
|
+
|
|
170
|
+
History persists under the service root (`cells/<id>.py` holds
|
|
171
|
+
the exact submitted cell, `executions/<id>.yaml` the terminal
|
|
172
|
+
record, `approvals/<id>.yaml` the approval audit trail).
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
project_id: Project executions are bound to.
|
|
176
|
+
actor: Attribution recorded on approvals and runs.
|
|
177
|
+
root: Service state dir; temp dir default.
|
|
178
|
+
execution: Job surface owning run records; an in-memory
|
|
179
|
+
execution service when omitted.
|
|
180
|
+
project_root: Project dir path mode resolves inside; path
|
|
181
|
+
mode refuses loudly without one.
|
|
182
|
+
runner: Worker runner; a prepared runner when omitted
|
|
183
|
+
(built lazily, on the first admitted run).
|
|
184
|
+
dispatcher: Op dispatch effects replay through
|
|
185
|
+
(`execute(operation, arguments)`); without one, ops
|
|
186
|
+
are recorded but never replayed, loudly marked.
|
|
187
|
+
approval_ttl_s: Pending approvals lapse after this long.
|
|
188
|
+
clock: Monotonic clock seconds source (tests inject fakes).
|
|
189
|
+
revision_provider: Zero-arg callable returning the live
|
|
190
|
+
project revision; admission fails closed on drift when
|
|
191
|
+
set, skips the check when omitted.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
def __init__(self, project_id, actor="agent", root=None, execution=None,
|
|
195
|
+
project_root=None, runner=None, dispatcher=None,
|
|
196
|
+
approval_ttl_s=DEFAULT_APPROVAL_TTL_S,
|
|
197
|
+
clock=time.monotonic, revision_provider=None):
|
|
198
|
+
self._project_id = project_id
|
|
199
|
+
self._actor = actor
|
|
200
|
+
self._root = Path(
|
|
201
|
+
root if root is not None
|
|
202
|
+
else Path(tempfile.gettempdir()) / "rocketeda_pyexec" / project_id
|
|
203
|
+
)
|
|
204
|
+
(self._root / "cells").mkdir(parents=True, exist_ok=True)
|
|
205
|
+
(self._root / "executions").mkdir(parents=True, exist_ok=True)
|
|
206
|
+
(self._root / "approvals").mkdir(parents=True, exist_ok=True)
|
|
207
|
+
self._execution = (
|
|
208
|
+
execution if execution is not None
|
|
209
|
+
else ExecutionService(project_id)
|
|
210
|
+
)
|
|
211
|
+
self._project_root = project_root
|
|
212
|
+
self._runner = runner
|
|
213
|
+
self._dispatcher = dispatcher
|
|
214
|
+
self._approval_ttl_s = approval_ttl_s
|
|
215
|
+
self._clock = clock
|
|
216
|
+
self._revision_provider = revision_provider
|
|
217
|
+
self._generation = uuid.uuid4().hex
|
|
218
|
+
self._approvals = {}
|
|
219
|
+
self._requests = {}
|
|
220
|
+
self._runs = {}
|
|
221
|
+
self._records = {}
|
|
222
|
+
self._cancel_flags = set()
|
|
223
|
+
self._cancel_lock = threading.Lock()
|
|
224
|
+
self._run_lock = threading.Lock()
|
|
225
|
+
self._env_digest = prelude_digest_for(project_id)
|
|
226
|
+
self._cursor = len(list((self._root / "executions").glob("*.yaml")))
|
|
227
|
+
self._yaml = YAML(typ="safe")
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def generation(self):
|
|
231
|
+
"""Current approval generation; reset retires it.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Generation id string.
|
|
235
|
+
"""
|
|
236
|
+
return self._generation
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def cursor(self):
|
|
240
|
+
"""History cursor: count of persisted executions.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Execution count as an int.
|
|
244
|
+
"""
|
|
245
|
+
return self._cursor
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def job_surface(self):
|
|
249
|
+
"""Execution service owning this service's run records.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Bound execution service.
|
|
253
|
+
"""
|
|
254
|
+
return self._execution
|
|
255
|
+
|
|
256
|
+
def _dump(self, data):
|
|
257
|
+
buf = io.StringIO()
|
|
258
|
+
self._yaml.dump(data, buf)
|
|
259
|
+
return buf.getvalue()
|
|
260
|
+
|
|
261
|
+
@staticmethod
|
|
262
|
+
def _normalize(code):
|
|
263
|
+
text = code if code.endswith("\n") else code + "\n"
|
|
264
|
+
return text
|
|
265
|
+
|
|
266
|
+
def _read_cell(self, execution_id):
|
|
267
|
+
return (self._root / "cells" / ("%s.py" % execution_id)).read_text(
|
|
268
|
+
encoding="utf-8")
|
|
269
|
+
|
|
270
|
+
def _cell_digest(self, execution_id):
|
|
271
|
+
try:
|
|
272
|
+
data = (self._root / "cells" / ("%s.py" % execution_id)).read_bytes()
|
|
273
|
+
except OSError:
|
|
274
|
+
return None
|
|
275
|
+
return hashlib.sha256(data).hexdigest()
|
|
276
|
+
|
|
277
|
+
def _sweep_expired(self):
|
|
278
|
+
now = self._clock()
|
|
279
|
+
for record in self._approvals.values():
|
|
280
|
+
if record["state"] == "pending_approval" and now > record["expires_at"]:
|
|
281
|
+
record["state"] = "expired"
|
|
282
|
+
record["reason"] = "Approval TTL elapsed without a UI decision."
|
|
283
|
+
self._persist_approval(record)
|
|
284
|
+
self._reap_terminal()
|
|
285
|
+
|
|
286
|
+
def _reap_terminal(self):
|
|
287
|
+
"""Evict oldest terminal approvals past the live index bound.
|
|
288
|
+
|
|
289
|
+
Denied and expired approvals are dead references: no run or
|
|
290
|
+
status path can proceed through them. Past the bound the
|
|
291
|
+
oldest evict first (by creation, then id); pending and
|
|
292
|
+
approved approvals are live and never reap. The YAML audit
|
|
293
|
+
trail on disk is untouched — this bounds the live index,
|
|
294
|
+
not history.
|
|
295
|
+
"""
|
|
296
|
+
terminal = [
|
|
297
|
+
record for record in self._approvals.values()
|
|
298
|
+
if record["state"] in ("denied", "expired")
|
|
299
|
+
]
|
|
300
|
+
excess = len(terminal) - MAX_TERMINAL_APPROVALS
|
|
301
|
+
if excess <= 0:
|
|
302
|
+
return
|
|
303
|
+
# Stable sort: ties keep admission order, so the earliest
|
|
304
|
+
# admitted terminal approval evicts first even when the
|
|
305
|
+
# clock never advanced between admissions.
|
|
306
|
+
terminal.sort(key=lambda record: record.get("created_at", 0))
|
|
307
|
+
for record in terminal[:excess]:
|
|
308
|
+
del self._approvals[record["approval_id"]]
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _persist_execution(self, record):
|
|
312
|
+
stored = {
|
|
313
|
+
**record,
|
|
314
|
+
"project_id": self._project_id,
|
|
315
|
+
"actor": self._actor,
|
|
316
|
+
"generation": self._generation,
|
|
317
|
+
"env_digest": self._env_digest,
|
|
318
|
+
"history_cursor": self._cursor + 1,
|
|
319
|
+
}
|
|
320
|
+
(self._root / "executions" / ("%s.yaml" % record["execution_id"])).write_text(
|
|
321
|
+
self._dump(stored), encoding="utf-8"
|
|
322
|
+
)
|
|
323
|
+
self._cursor += 1
|
|
324
|
+
return stored
|
|
325
|
+
|
|
326
|
+
def validate_request(self, mode, lines=None, path=None, artifacts=(),
|
|
327
|
+
timeout_s=None):
|
|
328
|
+
"""Validate one execution request without changing state.
|
|
329
|
+
|
|
330
|
+
Pure pre-check: the Engine runs this before its own
|
|
331
|
+
revision-gated admission so malformed requests fail before
|
|
332
|
+
anything is persisted or queued.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
mode: Input mode, exactly `lines` or `path`.
|
|
336
|
+
lines: Inline script text for lines mode.
|
|
337
|
+
path: Project-relative script path for path mode.
|
|
338
|
+
artifacts: Scratch-relative paths to harvest.
|
|
339
|
+
timeout_s: Worker budget; the bounded default when
|
|
340
|
+
omitted.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
(display, source_file, budget, artifacts) tuple: the
|
|
344
|
+
normalized cell text, the project-relative source
|
|
345
|
+
(path mode) or None, the resolved budget, and the
|
|
346
|
+
validated artifact names.
|
|
347
|
+
|
|
348
|
+
Raises:
|
|
349
|
+
ExecutionError: invalid_command for a bad mode, a
|
|
350
|
+
mode/field mismatch, oversize input, or a malformed
|
|
351
|
+
timeout, invalid_cell/empty_cell for bad script
|
|
352
|
+
text, path_escape/invalid_path/cell_unreadable for
|
|
353
|
+
bad project files.
|
|
354
|
+
"""
|
|
355
|
+
if mode not in MODES:
|
|
356
|
+
raise ExecutionError(
|
|
357
|
+
"invalid_command",
|
|
358
|
+
f"mode must be one of {list(MODES)}, got {mode!r}.",
|
|
359
|
+
)
|
|
360
|
+
if mode == "lines" and (lines is None or path is not None):
|
|
361
|
+
raise ExecutionError(
|
|
362
|
+
"invalid_command",
|
|
363
|
+
"lines mode needs lines text and no path.",
|
|
364
|
+
)
|
|
365
|
+
if mode == "path" and (path is None or lines is not None):
|
|
366
|
+
raise ExecutionError(
|
|
367
|
+
"invalid_command",
|
|
368
|
+
"path mode needs a path and no lines text.",
|
|
369
|
+
)
|
|
370
|
+
budget = DEFAULT_TIMEOUT_S if timeout_s is None else _check_timeout(timeout_s)
|
|
371
|
+
names = _validate_artifacts(artifacts)
|
|
372
|
+
if mode == "lines":
|
|
373
|
+
if not isinstance(lines, str):
|
|
374
|
+
raise ExecutionError(
|
|
375
|
+
"invalid_cell",
|
|
376
|
+
f"Cell code must be UTF-8 text, got {type(lines).__name__}.",
|
|
377
|
+
)
|
|
378
|
+
if not lines.strip():
|
|
379
|
+
raise ExecutionError("empty_cell", "Cell is empty.")
|
|
380
|
+
if len(lines.encode("utf-8")) > MAX_LINES_BYTES:
|
|
381
|
+
raise ExecutionError(
|
|
382
|
+
"invalid_command",
|
|
383
|
+
f"lines exceed {MAX_LINES_BYTES} bytes.",
|
|
384
|
+
)
|
|
385
|
+
return self._normalize(lines), None, budget, names
|
|
386
|
+
script = resolve_script_path(self._project_root, path)
|
|
387
|
+
try:
|
|
388
|
+
text = script.read_text(encoding="utf-8")
|
|
389
|
+
except (OSError, ValueError) as exc:
|
|
390
|
+
raise ExecutionError(
|
|
391
|
+
"cell_unreadable",
|
|
392
|
+
f"Cannot read script file {path}: {exc}.",
|
|
393
|
+
) from exc
|
|
394
|
+
if not text.strip():
|
|
395
|
+
raise ExecutionError("empty_cell", "Cell is empty.")
|
|
396
|
+
return self._normalize(text), path, budget, names
|
|
397
|
+
|
|
398
|
+
def request_execution(self, mode, lines=None, path=None, request_id=None,
|
|
399
|
+
run_id=None, expected_revision="local",
|
|
400
|
+
artifacts=(), timeout_s=None):
|
|
401
|
+
"""Request one execution: persist the cell, open an approval.
|
|
402
|
+
|
|
403
|
+
Job keys:
|
|
404
|
+
mode (required): `lines` for inline script text, `path`
|
|
405
|
+
for a project-contained script file.
|
|
406
|
+
lines: Inline script text (lines mode only).
|
|
407
|
+
path: Project-relative script path (path mode only).
|
|
408
|
+
timeout_s: Worker budget in seconds (default 120).
|
|
409
|
+
artifacts: Scratch-relative paths to harvest.
|
|
410
|
+
expected_revision: Project revision the approval binds.
|
|
411
|
+
|
|
412
|
+
The call returns promptly with an approval handle; it runs
|
|
413
|
+
nothing. Repeating a request_id with identical code returns
|
|
414
|
+
the stored handle; changed code under the same id conflicts.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
mode: Input mode, exactly `lines` or `path`.
|
|
418
|
+
lines: Inline script text for lines mode.
|
|
419
|
+
path: Project-relative script path for path mode.
|
|
420
|
+
request_id: Client envelope id; generated when omitted.
|
|
421
|
+
run_id: Prescribed execution id (the Engine submit ack
|
|
422
|
+
id); minted at approval when omitted.
|
|
423
|
+
expected_revision: Revision the approval binds.
|
|
424
|
+
artifacts: Scratch-relative paths to harvest.
|
|
425
|
+
timeout_s: Worker budget; the bounded default when
|
|
426
|
+
omitted.
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
Approval handle with execution_id, approval_id,
|
|
430
|
+
pending_approval state, code hash, and the run_id
|
|
431
|
+
only when the caller prescribed one.
|
|
432
|
+
|
|
433
|
+
Raises:
|
|
434
|
+
ExecutionError: invalid_command for a bad mode, a
|
|
435
|
+
mode/field mismatch, oversize input, or a malformed
|
|
436
|
+
timeout, invalid_cell/empty_cell for bad script
|
|
437
|
+
text, path_escape/invalid_path/cell_unreadable for
|
|
438
|
+
bad project files, request_conflict for a reused
|
|
439
|
+
request id with new code.
|
|
440
|
+
"""
|
|
441
|
+
display, source_file, budget, names = self.validate_request(
|
|
442
|
+
mode, lines=lines, path=path, artifacts=artifacts,
|
|
443
|
+
timeout_s=timeout_s,
|
|
444
|
+
)
|
|
445
|
+
code_sha256 = hashlib.sha256(display.encode("utf-8")).hexdigest()
|
|
446
|
+
request_id = request_id or uuid.uuid4().hex
|
|
447
|
+
prev = self._requests.get(request_id)
|
|
448
|
+
if prev is not None:
|
|
449
|
+
if prev["code_sha256"] != code_sha256:
|
|
450
|
+
raise ExecutionError(
|
|
451
|
+
"request_conflict",
|
|
452
|
+
f"Request id reused with different code: {request_id}.",
|
|
453
|
+
)
|
|
454
|
+
return self._handle_for(prev["execution_id"], request_id)
|
|
455
|
+
if run_id is not None and run_id in self._runs:
|
|
456
|
+
raise ExecutionError(
|
|
457
|
+
"invalid_command",
|
|
458
|
+
f"run_id already admitted: {run_id}.",
|
|
459
|
+
)
|
|
460
|
+
execution_id = uuid.uuid4().hex
|
|
461
|
+
(self._root / "cells" / ("%s.py" % execution_id)).write_text(
|
|
462
|
+
display, encoding="utf-8")
|
|
463
|
+
approval_id = uuid.uuid4().hex
|
|
464
|
+
now = self._clock()
|
|
465
|
+
record = {
|
|
466
|
+
"approval_id": approval_id,
|
|
467
|
+
"execution_id": execution_id,
|
|
468
|
+
"run_id": run_id,
|
|
469
|
+
"request_id": request_id,
|
|
470
|
+
"mode": mode,
|
|
471
|
+
"source_file": source_file,
|
|
472
|
+
"code_sha256": code_sha256,
|
|
473
|
+
"history_cursor": self._cursor,
|
|
474
|
+
"env_digest": self._env_digest,
|
|
475
|
+
"expected_revision": expected_revision,
|
|
476
|
+
"generation": self._generation,
|
|
477
|
+
"timeout_s": budget,
|
|
478
|
+
"artifacts": names,
|
|
479
|
+
"state": "pending_approval",
|
|
480
|
+
"reason": None,
|
|
481
|
+
"created_at": now,
|
|
482
|
+
"expires_at": now + self._approval_ttl_s,
|
|
483
|
+
}
|
|
484
|
+
self._approvals[approval_id] = record
|
|
485
|
+
if run_id is not None:
|
|
486
|
+
self._runs[run_id] = execution_id
|
|
487
|
+
self._requests[request_id] = {
|
|
488
|
+
"execution_id": execution_id,
|
|
489
|
+
"approval_id": approval_id,
|
|
490
|
+
"code_sha256": code_sha256,
|
|
491
|
+
}
|
|
492
|
+
self._persist_approval(record)
|
|
493
|
+
self._reap_terminal()
|
|
494
|
+
return self._handle_for(execution_id, request_id)
|
|
495
|
+
|
|
496
|
+
def _handle_for(self, execution_id, request_id):
|
|
497
|
+
record = next(
|
|
498
|
+
item for item in self._approvals.values()
|
|
499
|
+
if item["execution_id"] == execution_id
|
|
500
|
+
)
|
|
501
|
+
handle = {
|
|
502
|
+
"request_id": request_id,
|
|
503
|
+
"execution_id": execution_id,
|
|
504
|
+
"approval_id": record["approval_id"],
|
|
505
|
+
"approval_state": record["state"],
|
|
506
|
+
"mode": record["mode"],
|
|
507
|
+
"source_file": record["source_file"],
|
|
508
|
+
"code_sha256": record["code_sha256"],
|
|
509
|
+
"history_cursor": record["history_cursor"],
|
|
510
|
+
"expected_revision": record["expected_revision"],
|
|
511
|
+
"timeout_s": record["timeout_s"],
|
|
512
|
+
}
|
|
513
|
+
if record["run_id"] is not None:
|
|
514
|
+
handle["run_id"] = record["run_id"]
|
|
515
|
+
return handle
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def run_job(self, run_id, on_live=None):
|
|
519
|
+
"""Execute an admitted run in a fresh worker, then replay ops.
|
|
520
|
+
|
|
521
|
+
Second preflight precedes any spawn: a lapsed approval (or a
|
|
522
|
+
retired generation) cancels the run instead of executing a
|
|
523
|
+
stranger's cell, and a cancelled run never spawns. Effects
|
|
524
|
+
the cell recorded replay through the shared op dispatch
|
|
525
|
+
after the worker exits; their child run_ids ride on the
|
|
526
|
+
terminal record as structured data.
|
|
527
|
+
|
|
528
|
+
Args:
|
|
529
|
+
run_id: Admitted execution id from resolve_approval.
|
|
530
|
+
on_live: Optional callback while the worker is live.
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
Terminal execution record with state, bounded stdio,
|
|
534
|
+
the parsed exception location, replayed effects
|
|
535
|
+
with child run_ids, and the NOT_RUN verdict.
|
|
536
|
+
|
|
537
|
+
Raises:
|
|
538
|
+
ExecutionError: unknown_run for an id never admitted,
|
|
539
|
+
approval_pending/approval_denied/approval_expired
|
|
540
|
+
when the run's approval lapses, approval_invalid
|
|
541
|
+
for binding drift or tampered cell bytes.
|
|
542
|
+
"""
|
|
543
|
+
try:
|
|
544
|
+
execution_id = self._runs[run_id]
|
|
545
|
+
except KeyError:
|
|
546
|
+
raise ExecutionError(
|
|
547
|
+
"unknown_run", f"Unknown run: {run_id}."
|
|
548
|
+
) from None
|
|
549
|
+
record = next(
|
|
550
|
+
item for item in self._approvals.values()
|
|
551
|
+
if item["execution_id"] == execution_id
|
|
552
|
+
)
|
|
553
|
+
approval_state = record["state"]
|
|
554
|
+
if approval_state == "pending_approval":
|
|
555
|
+
self._sweep_expired()
|
|
556
|
+
if record["state"] == "expired":
|
|
557
|
+
raise ExecutionError(
|
|
558
|
+
"approval_expired", "Approval expired; no run exists."
|
|
559
|
+
)
|
|
560
|
+
raise ExecutionError(
|
|
561
|
+
"approval_pending",
|
|
562
|
+
"Approval is still pending; the UI route has not resolved it.",
|
|
563
|
+
)
|
|
564
|
+
if approval_state == "denied":
|
|
565
|
+
raise ExecutionError(
|
|
566
|
+
"approval_denied", "Approval was denied; no run exists."
|
|
567
|
+
)
|
|
568
|
+
if approval_state == "expired":
|
|
569
|
+
raise ExecutionError(
|
|
570
|
+
"approval_expired", "Approval expired; no run exists."
|
|
571
|
+
)
|
|
572
|
+
with self._run_lock:
|
|
573
|
+
if run_id in self._records:
|
|
574
|
+
return dict(self._records[run_id])
|
|
575
|
+
state = self._execution.status(run_id)["state"]
|
|
576
|
+
if state == "cancelled":
|
|
577
|
+
return self._store_cancelled(
|
|
578
|
+
run_id, execution_id, spawned=False,
|
|
579
|
+
reason="cancelled before dispatch; worker never spawned.",
|
|
580
|
+
)
|
|
581
|
+
if state != "queued":
|
|
582
|
+
raise ExecutionError(
|
|
583
|
+
"invalid_command",
|
|
584
|
+
f"Run is already {state}: {run_id}.",
|
|
585
|
+
)
|
|
586
|
+
self._execution.mark_running(run_id)
|
|
587
|
+
lapse = self._lapse_reason(record)
|
|
588
|
+
if lapse is not None:
|
|
589
|
+
self._execution.cancel(run_id)
|
|
590
|
+
return self._store_cancelled(
|
|
591
|
+
run_id, execution_id, spawned=False, reason=lapse,
|
|
592
|
+
)
|
|
593
|
+
if self._cell_digest(execution_id) != record["code_sha256"]:
|
|
594
|
+
self._execution.cancel(run_id)
|
|
595
|
+
raise ExecutionError(
|
|
596
|
+
"approval_invalid",
|
|
597
|
+
"Cell bytes changed under their approval.",
|
|
598
|
+
)
|
|
599
|
+
display = self._read_cell(execution_id)
|
|
600
|
+
source = build_worker_source(display, project_id=self._project_id)
|
|
601
|
+
# Display line 1 sits one blank line below the preamble.
|
|
602
|
+
cell_line_offset = (
|
|
603
|
+
_render_preamble(self._project_id).count("\n") + 1
|
|
604
|
+
)
|
|
605
|
+
runner = self._runner or PythonExecRunner(
|
|
606
|
+
artifacts_root=str(self._root / "harvest"))
|
|
607
|
+
try:
|
|
608
|
+
result = runner.run(
|
|
609
|
+
source,
|
|
610
|
+
run_id=run_id,
|
|
611
|
+
timeout_s=record["timeout_s"],
|
|
612
|
+
artifacts=(OPS_LOG_NAME,) + tuple(record["artifacts"]),
|
|
613
|
+
on_live=on_live,
|
|
614
|
+
is_cancelled=lambda: self._cancelled(run_id),
|
|
615
|
+
cell_name=record["source_file"] or CELL_ENTRY,
|
|
616
|
+
cell_line_offset=cell_line_offset,
|
|
617
|
+
)
|
|
618
|
+
finally:
|
|
619
|
+
with self._cancel_lock:
|
|
620
|
+
self._cancel_flags.discard(run_id)
|
|
621
|
+
ops, skipped = self._load_harvested_ops(result)
|
|
622
|
+
op_results = self._replay(ops)
|
|
623
|
+
run_ids = [
|
|
624
|
+
item["result"]["run_id"] for item in op_results
|
|
625
|
+
if item["ok"] and isinstance(item.get("result"), dict)
|
|
626
|
+
and isinstance(item["result"].get("run_id"), str)
|
|
627
|
+
]
|
|
628
|
+
replayed = self._dispatcher is not None
|
|
629
|
+
effects = {
|
|
630
|
+
"ops": ops,
|
|
631
|
+
"op_results": op_results,
|
|
632
|
+
"ops_ok": all(item["ok"] for item in op_results) if replayed else not ops,
|
|
633
|
+
"ops_skipped": skipped,
|
|
634
|
+
"run_ids": run_ids,
|
|
635
|
+
"replayed": replayed,
|
|
636
|
+
}
|
|
637
|
+
verdict = not_run_verdict(self._verdict_reason(result, record))
|
|
638
|
+
terminal = {
|
|
639
|
+
"execution_id": execution_id,
|
|
640
|
+
"run_id": run_id,
|
|
641
|
+
"mode": record["mode"],
|
|
642
|
+
"source_file": record["source_file"],
|
|
643
|
+
"code_sha256": record["code_sha256"],
|
|
644
|
+
"state": result["state"],
|
|
645
|
+
"ok": bool(result["ok"]),
|
|
646
|
+
"reason": result.get("reason"),
|
|
647
|
+
"exit_code": result.get("exit_code"),
|
|
648
|
+
"stdout": result.get("stdout", ""),
|
|
649
|
+
"stderr": result.get("stderr", ""),
|
|
650
|
+
"partial": bool(result.get("partial", False)),
|
|
651
|
+
"exception": result.get("exception"),
|
|
652
|
+
"effects": effects,
|
|
653
|
+
"artifacts": result.get("artifacts", []),
|
|
654
|
+
"rejected_artifacts": result.get("rejected_artifacts", []),
|
|
655
|
+
"network_isolated": result.get("network_isolated", False),
|
|
656
|
+
"credential_revoked": result.get("credential_revoked", False),
|
|
657
|
+
"tree_killed": result.get("tree_killed", False),
|
|
658
|
+
"spawned": True,
|
|
659
|
+
"timeout_s": record["timeout_s"],
|
|
660
|
+
"verdict": verdict,
|
|
661
|
+
}
|
|
662
|
+
self._persist_execution(terminal)
|
|
663
|
+
self._records[run_id] = dict(terminal)
|
|
664
|
+
if result["state"] == "cancelled":
|
|
665
|
+
self._execution.cancel(run_id)
|
|
666
|
+
else:
|
|
667
|
+
self._execution.mark_completed(run_id, verdict)
|
|
668
|
+
return terminal
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _store_cancelled(self, run_id, execution_id, spawned, reason):
|
|
672
|
+
record = next(
|
|
673
|
+
item for item in self._approvals.values()
|
|
674
|
+
if item["execution_id"] == execution_id
|
|
675
|
+
)
|
|
676
|
+
verdict = not_run_verdict(reason + " Not a check verdict.")
|
|
677
|
+
terminal = {
|
|
678
|
+
"execution_id": execution_id,
|
|
679
|
+
"run_id": run_id,
|
|
680
|
+
"mode": record["mode"],
|
|
681
|
+
"source_file": record["source_file"],
|
|
682
|
+
"code_sha256": record["code_sha256"],
|
|
683
|
+
"state": "cancelled",
|
|
684
|
+
"ok": False,
|
|
685
|
+
"reason": reason,
|
|
686
|
+
"exit_code": None,
|
|
687
|
+
"stdout": "",
|
|
688
|
+
"stderr": "",
|
|
689
|
+
"partial": False,
|
|
690
|
+
"exception": None,
|
|
691
|
+
"effects": {
|
|
692
|
+
"ops": [], "op_results": [], "ops_ok": True,
|
|
693
|
+
"ops_skipped": 0, "run_ids": [], "replayed": True,
|
|
694
|
+
},
|
|
695
|
+
"artifacts": [],
|
|
696
|
+
"rejected_artifacts": [],
|
|
697
|
+
"network_isolated": False,
|
|
698
|
+
"credential_revoked": False,
|
|
699
|
+
"tree_killed": False,
|
|
700
|
+
"spawned": spawned,
|
|
701
|
+
"timeout_s": record["timeout_s"],
|
|
702
|
+
"verdict": verdict,
|
|
703
|
+
}
|
|
704
|
+
self._persist_execution(terminal)
|
|
705
|
+
self._records[run_id] = dict(terminal)
|
|
706
|
+
return terminal
|
|
707
|
+
|
|
708
|
+
@staticmethod
|
|
709
|
+
def _verdict_reason(result, record):
|
|
710
|
+
state = result["state"]
|
|
711
|
+
if state == "completed":
|
|
712
|
+
return (
|
|
713
|
+
"python_execute is not a check: exit 0 is not PASS; "
|
|
714
|
+
"read the execution record (state/ok/effects)."
|
|
715
|
+
)
|
|
716
|
+
if state == "failed":
|
|
717
|
+
exc = result.get("exception") or {}
|
|
718
|
+
where = exc.get("location") or "unknown location"
|
|
719
|
+
what = exc.get("type") or "error"
|
|
720
|
+
return (
|
|
721
|
+
f"Script failed ({what} at {where}; "
|
|
722
|
+
f"exit {result.get('exit_code')}); not a check verdict."
|
|
723
|
+
)
|
|
724
|
+
if state == "timeout":
|
|
725
|
+
return (
|
|
726
|
+
f"Timeout after {record['timeout_s']}s; partials retained; "
|
|
727
|
+
"not a check verdict."
|
|
728
|
+
)
|
|
729
|
+
if state == "crashed":
|
|
730
|
+
return "Worker died by signal; not a check verdict."
|
|
731
|
+
return "Cancelled via the job surface; not a check verdict."
|
|
732
|
+
|
|
733
|
+
def _cancelled(self, run_id):
|
|
734
|
+
with self._cancel_lock:
|
|
735
|
+
return run_id in self._cancel_flags
|
|
736
|
+
|
|
737
|
+
@staticmethod
|
|
738
|
+
def _load_harvested_ops(result):
|
|
739
|
+
ops, skipped = [], 0
|
|
740
|
+
for entry in result.get("artifacts", []):
|
|
741
|
+
if entry.get("name") != OPS_LOG_NAME:
|
|
742
|
+
continue
|
|
743
|
+
try:
|
|
744
|
+
lines = Path(entry["path"]).read_text(
|
|
745
|
+
encoding="utf-8").splitlines()
|
|
746
|
+
except OSError:
|
|
747
|
+
skipped += 1
|
|
748
|
+
continue
|
|
749
|
+
for line in lines:
|
|
750
|
+
if not line.strip():
|
|
751
|
+
continue
|
|
752
|
+
try:
|
|
753
|
+
item = json.loads(line)
|
|
754
|
+
except ValueError:
|
|
755
|
+
skipped += 1
|
|
756
|
+
continue
|
|
757
|
+
if (
|
|
758
|
+
not isinstance(item, dict)
|
|
759
|
+
or not isinstance(item.get("operation"), str)
|
|
760
|
+
or not isinstance(item.get("arguments"), dict)
|
|
761
|
+
):
|
|
762
|
+
skipped += 1
|
|
763
|
+
continue
|
|
764
|
+
ops.append({"operation": item["operation"],
|
|
765
|
+
"arguments": dict(item["arguments"])})
|
|
766
|
+
return ops, skipped
|
|
767
|
+
|
|
768
|
+
def _replay(self, ops):
|
|
769
|
+
if self._dispatcher is None:
|
|
770
|
+
return []
|
|
771
|
+
replayed = []
|
|
772
|
+
for op in ops:
|
|
773
|
+
try:
|
|
774
|
+
payload = self._dispatcher.execute(
|
|
775
|
+
op["operation"], op["arguments"]
|
|
776
|
+
)
|
|
777
|
+
except Exception as exc: # per-op truth: record, never raise
|
|
778
|
+
replayed.append({
|
|
779
|
+
"operation": op["operation"],
|
|
780
|
+
"ok": False,
|
|
781
|
+
"error": {
|
|
782
|
+
"code": getattr(exc, "code", None)
|
|
783
|
+
or "internal_error",
|
|
784
|
+
"message": str(exc),
|
|
785
|
+
},
|
|
786
|
+
})
|
|
787
|
+
continue
|
|
788
|
+
replayed.append({
|
|
789
|
+
"operation": op["operation"],
|
|
790
|
+
"ok": True,
|
|
791
|
+
"result": json.loads(json.dumps(payload, default=str)),
|
|
792
|
+
})
|
|
793
|
+
return replayed
|
|
794
|
+
|
|
795
|
+
def cancel(self, run_id):
|
|
796
|
+
"""Cancel a queued or running run through the job surface.
|
|
797
|
+
|
|
798
|
+
The cooperative flag kills a live worker tree promptly;
|
|
799
|
+
finished runs keep their terminal state, truthfully.
|
|
800
|
+
|
|
801
|
+
Args:
|
|
802
|
+
run_id: Admitted execution id to cancel.
|
|
803
|
+
|
|
804
|
+
Returns:
|
|
805
|
+
Mapping with run_id and state.
|
|
806
|
+
|
|
807
|
+
Raises:
|
|
808
|
+
ExecutionError: unknown_run for an id never admitted.
|
|
809
|
+
"""
|
|
810
|
+
with self._cancel_lock:
|
|
811
|
+
self._cancel_flags.add(run_id)
|
|
812
|
+
return self._execution.cancel(run_id)
|
|
813
|
+
|
|
814
|
+
def status(self, run_id):
|
|
815
|
+
"""Read one run's job state, verdict, and evidence links.
|
|
816
|
+
|
|
817
|
+
Pure job surface: approval states never appear here — read
|
|
818
|
+
them from approval_status instead.
|
|
819
|
+
|
|
820
|
+
Args:
|
|
821
|
+
run_id: Admitted execution id to inspect.
|
|
822
|
+
|
|
823
|
+
Returns:
|
|
824
|
+
Run state mapping with run_id, operation, state,
|
|
825
|
+
snapshot_id, and verdict.
|
|
826
|
+
|
|
827
|
+
Raises:
|
|
828
|
+
ExecutionError: unknown_run for an id never admitted.
|
|
829
|
+
"""
|
|
830
|
+
return self._execution.status(run_id)
|
|
831
|
+
|
|
832
|
+
def execution_record(self, run_id):
|
|
833
|
+
"""Read one run's terminal execution record, when finished.
|
|
834
|
+
|
|
835
|
+
Args:
|
|
836
|
+
run_id: Admitted execution id to inspect.
|
|
837
|
+
|
|
838
|
+
Returns:
|
|
839
|
+
Terminal record copy, or None while unfinished.
|
|
840
|
+
"""
|
|
841
|
+
record = self._records.get(run_id)
|
|
842
|
+
return dict(record) if record is not None else None
|
|
843
|
+
|
|
844
|
+
def console_view(self):
|
|
845
|
+
"""Read generation, environment, and pending approvals.
|
|
846
|
+
|
|
847
|
+
Args:
|
|
848
|
+
None.
|
|
849
|
+
|
|
850
|
+
Returns:
|
|
851
|
+
Console view with project_id, generation, env_digest,
|
|
852
|
+
history cursor, pending approval count, and the
|
|
853
|
+
pending approval ids.
|
|
854
|
+
"""
|
|
855
|
+
self._sweep_expired()
|
|
856
|
+
pending = [
|
|
857
|
+
item["approval_id"] for item in self._approvals.values()
|
|
858
|
+
if item["state"] == "pending_approval"
|
|
859
|
+
]
|
|
860
|
+
return {
|
|
861
|
+
"project_id": self._project_id,
|
|
862
|
+
"generation": self._generation,
|
|
863
|
+
"env_digest": self._env_digest,
|
|
864
|
+
"cursor": self._cursor,
|
|
865
|
+
"pending_approvals": len(pending),
|
|
866
|
+
"pending_approval_ids": pending,
|
|
867
|
+
"executions": self._cursor,
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
def reset(self):
|
|
871
|
+
"""Retire the generation; lapse pending, cancel queued.
|
|
872
|
+
|
|
873
|
+
Pending approvals expire (their generation is gone);
|
|
874
|
+
approved-but-unrun jobs cancel through the job surface.
|
|
875
|
+
Live workers are cancel territory, not reset: interrupting
|
|
876
|
+
one needs cancel(run_id), never reset.
|
|
877
|
+
|
|
878
|
+
Returns:
|
|
879
|
+
Mapping with the fresh generation id.
|
|
880
|
+
"""
|
|
881
|
+
self._generation = uuid.uuid4().hex
|
|
882
|
+
for record in self._approvals.values():
|
|
883
|
+
if record["state"] == "pending_approval":
|
|
884
|
+
record["state"] = "expired"
|
|
885
|
+
record["reason"] = "Approval generation retired; request again."
|
|
886
|
+
self._persist_approval(record)
|
|
887
|
+
elif record["state"] == "approved" and record["run_id"] is not None:
|
|
888
|
+
run_id = record["run_id"]
|
|
889
|
+
if run_id not in self._records:
|
|
890
|
+
with self._cancel_lock:
|
|
891
|
+
self._cancel_flags.add(run_id)
|
|
892
|
+
try:
|
|
893
|
+
self._execution.cancel(run_id)
|
|
894
|
+
except ExecutionError:
|
|
895
|
+
pass
|
|
896
|
+
self._reap_terminal()
|
|
897
|
+
return {"generation": self._generation}
|