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.
Files changed (54) hide show
  1. starpulse/__init__.py +1 -0
  2. starpulse/__main__.py +24 -0
  3. starpulse/adapter_kit.py +327 -0
  4. starpulse/agent_cli.py +855 -0
  5. starpulse/analytics.py +105 -0
  6. starpulse/board.py +153 -0
  7. starpulse/board_feed.py +501 -0
  8. starpulse/claude_code.py +122 -0
  9. starpulse/codex.py +132 -0
  10. starpulse/config.py +182 -0
  11. starpulse/contracts.py +249 -0
  12. starpulse/dagu.py +419 -0
  13. starpulse/demo.py +464 -0
  14. starpulse/doctor.py +98 -0
  15. starpulse/emit.py +69 -0
  16. starpulse/event_log.py +298 -0
  17. starpulse/events.py +46 -0
  18. starpulse/harness.py +15 -0
  19. starpulse/harnesses.py +100 -0
  20. starpulse/history.py +309 -0
  21. starpulse/machine.schema.json +128 -0
  22. starpulse/machine_definition.py +247 -0
  23. starpulse/machine_tasks.py +127 -0
  24. starpulse/machines/harness.yaml +23 -0
  25. starpulse/mermaid_import.py +144 -0
  26. starpulse/native.py +178 -0
  27. starpulse/otlp.py +201 -0
  28. starpulse/pull_requests.py +143 -0
  29. starpulse/push_runs.py +140 -0
  30. starpulse/run_events.py +39 -0
  31. starpulse/schemas/board.schema.json +169 -0
  32. starpulse/schemas/machine-events.schema.json +66 -0
  33. starpulse/schemas/pools.schema.json +37 -0
  34. starpulse/schemas/runs.schema.json +234 -0
  35. starpulse/server.py +783 -0
  36. starpulse/session_start.py +40 -0
  37. starpulse/settings.py +71 -0
  38. starpulse/skill_install.py +70 -0
  39. starpulse/skills/authoring-starpulse-machines/SKILL.md +67 -0
  40. starpulse/skills/operating-starpulse-board/SKILL.md +45 -0
  41. starpulse/skills/setting-up-starpulse/SKILL.md +37 -0
  42. starpulse/skills/writing-starpulse-adapters/SKILL.md +65 -0
  43. starpulse/snapshot.py +92 -0
  44. starpulse/static/assets/index-CmFGyNSW.js +33 -0
  45. starpulse/static/assets/index-DghGjk0a.css +1 -0
  46. starpulse/static/index.html +16 -0
  47. starpulse/tables.py +49 -0
  48. starpulse/upstream_backlog.py +340 -0
  49. starpulse-0.1.0.dist-info/METADATA +321 -0
  50. starpulse-0.1.0.dist-info/RECORD +54 -0
  51. starpulse-0.1.0.dist-info/WHEEL +5 -0
  52. starpulse-0.1.0.dist-info/entry_points.txt +2 -0
  53. starpulse-0.1.0.dist-info/licenses/LICENSE +21 -0
  54. 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())
@@ -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())