starpulse 0.1.0__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.
- starpulse/__init__.py +1 -0
- starpulse/__main__.py +24 -0
- starpulse/adapter_kit.py +327 -0
- starpulse/agent_cli.py +855 -0
- starpulse/analytics.py +105 -0
- starpulse/board.py +153 -0
- starpulse/board_feed.py +501 -0
- starpulse/claude_code.py +122 -0
- starpulse/codex.py +132 -0
- starpulse/config.py +182 -0
- starpulse/contracts.py +249 -0
- starpulse/dagu.py +419 -0
- starpulse/demo.py +464 -0
- starpulse/doctor.py +98 -0
- starpulse/emit.py +69 -0
- starpulse/event_log.py +298 -0
- starpulse/events.py +46 -0
- starpulse/harness.py +15 -0
- starpulse/harnesses.py +100 -0
- starpulse/history.py +309 -0
- starpulse/machine.schema.json +128 -0
- starpulse/machine_definition.py +247 -0
- starpulse/machine_tasks.py +127 -0
- starpulse/machines/harness.yaml +23 -0
- starpulse/mermaid_import.py +144 -0
- starpulse/native.py +178 -0
- starpulse/otlp.py +201 -0
- starpulse/pull_requests.py +143 -0
- starpulse/push_runs.py +140 -0
- starpulse/run_events.py +39 -0
- starpulse/schemas/board.schema.json +169 -0
- starpulse/schemas/machine-events.schema.json +66 -0
- starpulse/schemas/pools.schema.json +37 -0
- starpulse/schemas/runs.schema.json +234 -0
- starpulse/server.py +783 -0
- starpulse/session_start.py +40 -0
- starpulse/settings.py +71 -0
- starpulse/skill_install.py +70 -0
- starpulse/skills/authoring-starpulse-machines/SKILL.md +67 -0
- starpulse/skills/operating-starpulse-board/SKILL.md +45 -0
- starpulse/skills/setting-up-starpulse/SKILL.md +37 -0
- starpulse/skills/writing-starpulse-adapters/SKILL.md +65 -0
- starpulse/snapshot.py +92 -0
- starpulse/static/assets/index-CmFGyNSW.js +33 -0
- starpulse/static/assets/index-DghGjk0a.css +1 -0
- starpulse/static/index.html +16 -0
- starpulse/tables.py +49 -0
- starpulse/upstream_backlog.py +340 -0
- starpulse-0.1.0.dist-info/METADATA +321 -0
- starpulse-0.1.0.dist-info/RECORD +54 -0
- starpulse-0.1.0.dist-info/WHEEL +5 -0
- starpulse-0.1.0.dist-info/entry_points.txt +2 -0
- starpulse-0.1.0.dist-info/licenses/LICENSE +21 -0
- starpulse-0.1.0.dist-info/top_level.txt +1 -0
starpulse/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Browser view of the workflows runs adapters read, the lifecycle machines and the agents moving through them."""
|
starpulse/__main__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""`starpulse serve|emit|<verb> ...` (or `python -m starpulse`): the package's command line.
|
|
2
|
+
|
|
3
|
+
`serve` runs the view (`starpulse.server`) and `emit` pushes a workflow run onto the runs stream (`starpulse.emit`);
|
|
4
|
+
each reads the rest of the line with its own flags, so `starpulse serve --help` lists the server's. Any other word is
|
|
5
|
+
a verb that reads the running server as JSON (`starpulse.agent_cli`), and `starpulse help --agent` lists them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
|
|
11
|
+
from starpulse import agent_cli, emit, server
|
|
12
|
+
|
|
13
|
+
COMMANDS: dict[str, Callable[[list[str]], int | None]] = {"serve": server.main, "emit": emit.main}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv: list[str] | None = None) -> int | None:
|
|
17
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
18
|
+
if argv and argv[0] in COMMANDS:
|
|
19
|
+
return COMMANDS[argv[0]](argv[1:])
|
|
20
|
+
return agent_cli.main(argv)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
raise SystemExit(main())
|
starpulse/adapter_kit.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""The test kit an adapter author runs against their adapter.
|
|
2
|
+
|
|
3
|
+
Subclass the kit for the contract the adapter writes, declare the adapter's task keys and branch
|
|
4
|
+
examples, and say how to produce its records; pytest collects the checks on the subclass:
|
|
5
|
+
|
|
6
|
+
class TestMyBoard(BoardAdapterKit):
|
|
7
|
+
keys = TaskKeys(key=re.compile(r"PROJ-\\d+"), branch=re.compile(r"feature/(PROJ-\\d+)"))
|
|
8
|
+
branches = {"feature/PROJ-1-add-x": "PROJ-1", "main": None}
|
|
9
|
+
|
|
10
|
+
def produce(self) -> list[dict]:
|
|
11
|
+
return [{"id": "PROJ-1", "title": "Add x", "lane": "in_progress"}]
|
|
12
|
+
|
|
13
|
+
`produce` returns the records the adapter writes for StarPulse, as plain dicts shaped as the
|
|
14
|
+
contract's JSON Schema (`starpulse.contracts.SCHEMAS`). A machine event may carry its `time` as text,
|
|
15
|
+
as a stream does. The kit checks each record against the contract and its schema, checks task keys
|
|
16
|
+
and branch examples against the adapter's `TaskKeys`, and hands the records to StarPulse's own
|
|
17
|
+
feeds to see that each is placed.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
26
|
+
from contextlib import contextmanager
|
|
27
|
+
from http.client import HTTPResponse
|
|
28
|
+
from http.server import ThreadingHTTPServer
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import ClassVar
|
|
31
|
+
|
|
32
|
+
import jsonschema
|
|
33
|
+
from pydantic import BaseModel
|
|
34
|
+
|
|
35
|
+
from starpulse.board import (
|
|
36
|
+
AssigneeWriter,
|
|
37
|
+
Board,
|
|
38
|
+
MoveWriter,
|
|
39
|
+
TaskArchiver,
|
|
40
|
+
TaskCreator,
|
|
41
|
+
TaskEditor,
|
|
42
|
+
TaskReader,
|
|
43
|
+
Written,
|
|
44
|
+
)
|
|
45
|
+
from starpulse.board_feed import BoardFeed
|
|
46
|
+
from starpulse.config import Config
|
|
47
|
+
from starpulse.contracts import SCHEMAS, BoardTask, Dag, MachineEvent, Pool, TaskKeys
|
|
48
|
+
from starpulse.harnesses import Harnesses
|
|
49
|
+
from starpulse.history import History, HistoryStore
|
|
50
|
+
from starpulse.machine_tasks import MachineTasks
|
|
51
|
+
from starpulse.server import _handler, move_task
|
|
52
|
+
from starpulse.server import assemble as _assemble
|
|
53
|
+
from starpulse.settings import HistoryWindow
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"BoardAdapterKit",
|
|
57
|
+
"MachineEventsAdapterKit",
|
|
58
|
+
"RunsAdapterKit",
|
|
59
|
+
"assembled",
|
|
60
|
+
"next_event",
|
|
61
|
+
"serve",
|
|
62
|
+
"task",
|
|
63
|
+
"url",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _AdapterKit:
|
|
68
|
+
"""The checks every contract shares; a subclass names the contract and what a record's task key is."""
|
|
69
|
+
|
|
70
|
+
#: The adapter's task key pattern and branch mapping.
|
|
71
|
+
keys: ClassVar[TaskKeys]
|
|
72
|
+
#: Branch names the adapter maps to a task key (or None for a branch that names no task); at least one of each.
|
|
73
|
+
branches: ClassVar[Mapping[str, str | None]]
|
|
74
|
+
_name: ClassVar[str]
|
|
75
|
+
_model: ClassVar[type[BaseModel]]
|
|
76
|
+
|
|
77
|
+
def produce(self) -> list[dict]:
|
|
78
|
+
"""The records the adapter writes; the adapter's test overrides this."""
|
|
79
|
+
raise NotImplementedError("override produce() to return the adapter's records")
|
|
80
|
+
|
|
81
|
+
def records(self) -> list:
|
|
82
|
+
"""What `produce` returned, parsed as the contract."""
|
|
83
|
+
return [self._model.model_validate(record) for record in self.produce()]
|
|
84
|
+
|
|
85
|
+
def test_the_adapter_produces_records(self) -> None:
|
|
86
|
+
assert self.produce(), "the adapter produced no records, so nothing is checked"
|
|
87
|
+
|
|
88
|
+
def test_every_record_follows_its_contract_and_json_schema(self) -> None:
|
|
89
|
+
for record in self.records():
|
|
90
|
+
jsonschema.validate(record.model_dump(mode="json", by_alias=True), SCHEMAS[self._name])
|
|
91
|
+
|
|
92
|
+
def test_the_branch_examples_map_to_the_task_keys_they_declare(self) -> None:
|
|
93
|
+
assert any(key for key in self.branches.values()) and None in self.branches.values(), (
|
|
94
|
+
"declare a branch that names a task and one that names none"
|
|
95
|
+
)
|
|
96
|
+
mapped = {ref: self.keys.for_branch(ref) for ref in self.branches}
|
|
97
|
+
assert mapped == dict(self.branches)
|
|
98
|
+
assert all(self.keys.matches(key) for key in mapped.values() if key)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class BoardAdapterKit(_AdapterKit):
|
|
102
|
+
"""For an adapter writing `BoardTask` records."""
|
|
103
|
+
|
|
104
|
+
_name = "board"
|
|
105
|
+
_model = BoardTask
|
|
106
|
+
#: The machines the page draws, the Board's as `board`, as the adapter's `starpulse.board.Board` draws them.
|
|
107
|
+
machines: ClassVar[Mapping[str, dict]]
|
|
108
|
+
#: The adapter's board writer over the project `produce` reads, when it has one; with a writer, `produce` offers
|
|
109
|
+
#: a move any actor may make and one the machine leaves to the operator.
|
|
110
|
+
writer: MoveWriter | None = None
|
|
111
|
+
|
|
112
|
+
def test_every_task_key_is_in_the_declared_scheme(self) -> None:
|
|
113
|
+
assert [t.id for t in self.records() if not self.keys.matches(t.id)] == []
|
|
114
|
+
|
|
115
|
+
def _move(self, actor: str, permitted: bool) -> tuple[tuple[int, dict], list[str]]:
|
|
116
|
+
"""Ask the server to make the first offered move `actor` is (`permitted`) or is not allowed; the writer's calls."""
|
|
117
|
+
assert self.writer is not None
|
|
118
|
+
calls: list[str] = []
|
|
119
|
+
|
|
120
|
+
def write(task: str, status: str, by: str) -> Written:
|
|
121
|
+
calls.append(task)
|
|
122
|
+
assert self.writer is not None
|
|
123
|
+
return self.writer(task, status, by)
|
|
124
|
+
|
|
125
|
+
feed = BoardFeed(keys=self.keys, machines=self.machines)
|
|
126
|
+
records = self.records()
|
|
127
|
+
for task in records:
|
|
128
|
+
feed.put(task)
|
|
129
|
+
offered = [
|
|
130
|
+
(task.id, column)
|
|
131
|
+
for task in records
|
|
132
|
+
for column, move in task.moves.items()
|
|
133
|
+
if move.allowed and move.permits(actor) == permitted
|
|
134
|
+
]
|
|
135
|
+
assert offered, f"produce() offers no move that {actor} {'may' if permitted else 'may not'} make"
|
|
136
|
+
task, column = offered[0]
|
|
137
|
+
raw = json.dumps({"task": task, "to": column, "actor": actor}).encode()
|
|
138
|
+
return move_task("127.0.0.1", raw, feed, write), calls
|
|
139
|
+
|
|
140
|
+
def test_a_move_the_actor_may_make_is_written_by_the_board_writer(self) -> None:
|
|
141
|
+
if self.writer is None:
|
|
142
|
+
return
|
|
143
|
+
(status, reply), calls = self._move("operator", permitted=True)
|
|
144
|
+
assert (status, calls != []) == (200, True), reply
|
|
145
|
+
|
|
146
|
+
def test_a_move_the_machine_leaves_to_others_is_refused_to_an_agent_before_the_writer(self) -> None:
|
|
147
|
+
if self.writer is None:
|
|
148
|
+
return
|
|
149
|
+
(status, reply), calls = self._move("agent", permitted=False)
|
|
150
|
+
assert (status, calls) == (409, []), reply
|
|
151
|
+
|
|
152
|
+
def test_the_flow_view_places_every_task_in_its_lane_or_as_settled(self) -> None:
|
|
153
|
+
feed = BoardFeed(keys=self.keys, machines=self.machines)
|
|
154
|
+
for task in self.records():
|
|
155
|
+
feed.put(task)
|
|
156
|
+
lanes = {state["id"] for state in self.machines["board"]["states"]}
|
|
157
|
+
latest = {task.id: task for task in self.records()}
|
|
158
|
+
snapshot = feed.snapshot()
|
|
159
|
+
assert snapshot["flows"][0]["machine"] == self.machines["board"]
|
|
160
|
+
placed = {a["id"]: a["state"] for a in snapshot["flows"][0]["agents"]}
|
|
161
|
+
assert {t.id: t.lane for t in latest.values() if not t.settled} == placed
|
|
162
|
+
assert {t.id: t.settled for t in latest.values() if t.settled} == {
|
|
163
|
+
id: entry["state"] for id, entry in snapshot["settled"].items()
|
|
164
|
+
}
|
|
165
|
+
assert set(placed.values()) - lanes == set()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class MachineEventsAdapterKit(_AdapterKit):
|
|
169
|
+
"""For an adapter writing `MachineEvent` records."""
|
|
170
|
+
|
|
171
|
+
_name = "machine-events"
|
|
172
|
+
_model = MachineEvent
|
|
173
|
+
#: The machines the adapter's events move, as the page draws them.
|
|
174
|
+
machines: ClassVar[Mapping[str, dict]]
|
|
175
|
+
|
|
176
|
+
def test_every_task_key_is_in_the_declared_scheme(self) -> None:
|
|
177
|
+
assert [e.task for e in self.records() if e.task and not self.keys.matches(e.task)] == []
|
|
178
|
+
|
|
179
|
+
def test_every_event_names_a_machine_and_event_the_flow_view_draws(self) -> None:
|
|
180
|
+
drawn = {name: {t["event"] for t in machine["transitions"]} for name, machine in self.machines.items()}
|
|
181
|
+
assert [(e.machine, e.event) for e in self.records() if e.event not in drawn.get(e.machine, ())] == []
|
|
182
|
+
|
|
183
|
+
def test_the_flow_view_places_every_task_an_event_moved(self) -> None:
|
|
184
|
+
feed = BoardFeed(machines=self.machines)
|
|
185
|
+
tasks = MachineTasks(feed, keys=self.keys, machines=self.machines)
|
|
186
|
+
events = [e for e in self.records() if e.task]
|
|
187
|
+
for event in events:
|
|
188
|
+
tasks.put(event)
|
|
189
|
+
placed = {(f["name"], a["id"]) for f in feed.snapshot()["flows"] for a in f["agents"]}
|
|
190
|
+
assert {(e.machine, e.task) for e in events} == placed
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class RunsAdapterKit(_AdapterKit):
|
|
194
|
+
"""For an adapter writing `Dag` records."""
|
|
195
|
+
|
|
196
|
+
_name = "runs"
|
|
197
|
+
_model = Dag
|
|
198
|
+
|
|
199
|
+
def produce_pools(self) -> list[dict]:
|
|
200
|
+
"""The concurrency pools the adapter reports, as plain dicts shaped as the `pools` schema; an adapter with none leaves this empty."""
|
|
201
|
+
return []
|
|
202
|
+
|
|
203
|
+
def test_every_step_waits_only_on_steps_of_its_own_dag(self) -> None:
|
|
204
|
+
for dag in self.records():
|
|
205
|
+
names = [step.name for step in dag.steps]
|
|
206
|
+
assert len(names) == len(set(names)), f"{dag.name} repeats a step name"
|
|
207
|
+
assert [d for step in dag.steps for d in step.depends if d not in names] == []
|
|
208
|
+
|
|
209
|
+
def test_every_active_run_is_in_flight_and_stays_within_its_own_dag(self) -> None:
|
|
210
|
+
for dag in self.records():
|
|
211
|
+
names = {step.name for step in dag.steps}
|
|
212
|
+
ids = [run.run_id for run in dag.active]
|
|
213
|
+
assert len(ids) == len(set(ids)), f"{dag.name} repeats an active run id"
|
|
214
|
+
for run in dag.active:
|
|
215
|
+
assert run.status in ("queued", "running"), f"{dag.name} run {run.run_id} is not queued or running"
|
|
216
|
+
if names:
|
|
217
|
+
assert set(run.steps) <= names, f"{dag.name} run {run.run_id} reports a step the DAG lacks"
|
|
218
|
+
assert run.step in names | {""}, f"{dag.name} run {run.run_id} is in a step the DAG lacks"
|
|
219
|
+
|
|
220
|
+
def test_every_pool_follows_its_contract_and_every_pool_a_dag_names_is_reported(self) -> None:
|
|
221
|
+
pools = [Pool.model_validate(pool) for pool in self.produce_pools()]
|
|
222
|
+
for pool in pools:
|
|
223
|
+
jsonschema.validate(pool.model_dump(mode="json", by_alias=True), SCHEMAS["pools"])
|
|
224
|
+
names = [pool.name for pool in pools]
|
|
225
|
+
assert len(names) == len(set(names)), "a pool is reported twice"
|
|
226
|
+
assert [dag.name for dag in self.records() if dag.pool and dag.pool not in names] == []
|
|
227
|
+
|
|
228
|
+
def test_the_flow_view_draws_every_dag_and_pool(self) -> None:
|
|
229
|
+
feed = BoardFeed()
|
|
230
|
+
dags, pools = self.produce(), self.produce_pools()
|
|
231
|
+
feed.runs("kit").set_dags(dags, None, pools)
|
|
232
|
+
snapshot = feed.snapshot()
|
|
233
|
+
assert snapshot["dags"] == [
|
|
234
|
+
{**dag, "name": f"kit/{dag['name']}"} | ({"pool": f"kit/{dag['pool']}"} if dag.get("pool") else {})
|
|
235
|
+
for dag in dags
|
|
236
|
+
]
|
|
237
|
+
assert snapshot["pools"] == [{**pool, "name": f"kit/{pool['name']}"} for pool in pools]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# The helpers below drive an adapter through the flow view's own server and feed.
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _no_writer(task: str, status: str, actor: str = "") -> Written:
|
|
244
|
+
raise AssertionError(f"the test reached the board writer: {task} {status}")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@contextmanager
|
|
248
|
+
def serve(
|
|
249
|
+
tmp_path: Path,
|
|
250
|
+
feed: BoardFeed | None = None,
|
|
251
|
+
starts: Mapping[str, Callable[[str], str]] | None = None,
|
|
252
|
+
history: History | None = None,
|
|
253
|
+
run_safe: frozenset[str] = frozenset(),
|
|
254
|
+
writer: MoveWriter | None = None,
|
|
255
|
+
harnesses: Harnesses | None = None,
|
|
256
|
+
assign: AssigneeWriter | None = None,
|
|
257
|
+
start_session: Callable[[str], str] | None = None,
|
|
258
|
+
window: HistoryWindow | None = None,
|
|
259
|
+
read: TaskReader | None = None,
|
|
260
|
+
edit: TaskEditor | None = None,
|
|
261
|
+
archive: TaskArchiver | None = None,
|
|
262
|
+
create: TaskCreator | None = None,
|
|
263
|
+
clock: Callable[[], float] = time.time,
|
|
264
|
+
) -> Iterator[ThreadingHTTPServer]:
|
|
265
|
+
"""Serve a stub build in `tmp_path` until the `with` block ends."""
|
|
266
|
+
static = tmp_path / "static"
|
|
267
|
+
(static / "assets").mkdir(parents=True, exist_ok=True)
|
|
268
|
+
(static / "index.html").write_text("<!doctype html>")
|
|
269
|
+
(static / "assets" / "index-abc123.js").write_text("")
|
|
270
|
+
(tmp_path / "secret.txt").write_text("")
|
|
271
|
+
served = feed or BoardFeed()
|
|
272
|
+
handler = _handler(
|
|
273
|
+
served,
|
|
274
|
+
static,
|
|
275
|
+
starts or {},
|
|
276
|
+
run_safe,
|
|
277
|
+
history or HistoryStore("sqlite://", feed.machines if feed else {}),
|
|
278
|
+
window or HistoryWindow(served, 6, tmp_path / "starpulse-settings.json"),
|
|
279
|
+
writer or _no_writer,
|
|
280
|
+
harnesses,
|
|
281
|
+
assign or _no_writer,
|
|
282
|
+
start_session,
|
|
283
|
+
read,
|
|
284
|
+
edit,
|
|
285
|
+
archive,
|
|
286
|
+
create,
|
|
287
|
+
clock,
|
|
288
|
+
)
|
|
289
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
|
290
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
291
|
+
try:
|
|
292
|
+
yield server
|
|
293
|
+
finally:
|
|
294
|
+
server.shutdown()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def url(server: ThreadingHTTPServer, path: str) -> str:
|
|
298
|
+
return f"http://127.0.0.1:{server.server_port}{path}"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def next_event(resp: HTTPResponse) -> tuple[str, dict]:
|
|
302
|
+
"""The next named event on the stream, skipping its keep-alive comments."""
|
|
303
|
+
name = ""
|
|
304
|
+
while line := resp.readline().decode():
|
|
305
|
+
if line.startswith("event: "):
|
|
306
|
+
name = line.removeprefix("event: ").strip()
|
|
307
|
+
elif line.startswith("data: "):
|
|
308
|
+
return name, json.loads(line.removeprefix("data: "))
|
|
309
|
+
raise AssertionError("the stream ended")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def task(task_id: str, status: str = "To Do", **fields) -> BoardTask:
|
|
313
|
+
"""A task in the lane `status` names, its title derived from its key unless given."""
|
|
314
|
+
return BoardTask(
|
|
315
|
+
id=task_id,
|
|
316
|
+
title=fields.pop("title", f"Title of {task_id}"),
|
|
317
|
+
lane=status.lower().replace(" ", "_"),
|
|
318
|
+
**fields,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def assembled(config: Config, base: Path) -> tuple[Board, BoardFeed]:
|
|
323
|
+
"""The board the config's `[board]` adapter builds and the feed that draws it, as `starpulse serve` assembles them.
|
|
324
|
+
|
|
325
|
+
`base` is the directory the config's relative paths are read from.
|
|
326
|
+
"""
|
|
327
|
+
return _assemble(config, base, None, config.qualified_run_safe())
|