whiletrue 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 (98) hide show
  1. whiletrue/__init__.py +0 -0
  2. whiletrue/__main__.py +3 -0
  3. whiletrue/abort.py +79 -0
  4. whiletrue/basics/__init__.py +99 -0
  5. whiletrue/basics/agent.py +249 -0
  6. whiletrue/basics/comments.py +113 -0
  7. whiletrue/basics/diff_size.py +80 -0
  8. whiletrue/basics/exec_.py +84 -0
  9. whiletrue/basics/history.py +117 -0
  10. whiletrue/basics/human.py +201 -0
  11. whiletrue/basics/json_source.py +35 -0
  12. whiletrue/basics/markdown_source.py +55 -0
  13. whiletrue/basics/process.py +99 -0
  14. whiletrue/basics/sessions.py +62 -0
  15. whiletrue/basics/set_.py +29 -0
  16. whiletrue/basics/shell.py +98 -0
  17. whiletrue/basics/wake.py +27 -0
  18. whiletrue/claude_code/__init__.py +58 -0
  19. whiletrue/claude_code/endpoint.py +143 -0
  20. whiletrue/claude_code/headless.py +181 -0
  21. whiletrue/claude_code/interactive.py +344 -0
  22. whiletrue/claude_code/learnings.py +32 -0
  23. whiletrue/claude_code/panes.py +164 -0
  24. whiletrue/claude_code/plugin/.claude-plugin/plugin.json +5 -0
  25. whiletrue/claude_code/plugin/hook.py +44 -0
  26. whiletrue/claude_code/plugin/hooks/hooks.json +81 -0
  27. whiletrue/claude_code/plugin/mcp_server.py +98 -0
  28. whiletrue/claude_code/session.py +345 -0
  29. whiletrue/claude_code/transcript.py +123 -0
  30. whiletrue/cli/__init__.py +15 -0
  31. whiletrue/cli/__main__.py +3 -0
  32. whiletrue/cli/app.py +550 -0
  33. whiletrue/cli/common.py +36 -0
  34. whiletrue/cli/gates.py +156 -0
  35. whiletrue/cli/ops.py +339 -0
  36. whiletrue/cli/panel.py +198 -0
  37. whiletrue/cli/registry.py +143 -0
  38. whiletrue/cli/templates.py +24 -0
  39. whiletrue/codex/__init__.py +14 -0
  40. whiletrue/codex/runner.py +243 -0
  41. whiletrue/control/__init__.py +38 -0
  42. whiletrue/control/chain.py +61 -0
  43. whiletrue/control/loop.py +139 -0
  44. whiletrue/control/map_.py +101 -0
  45. whiletrue/control/parallel.py +32 -0
  46. whiletrue/control/retry.py +100 -0
  47. whiletrue/control/sequence.py +40 -0
  48. whiletrue/control/switch.py +67 -0
  49. whiletrue/core/__init__.py +260 -0
  50. whiletrue/core/caps.py +139 -0
  51. whiletrue/core/comments.py +80 -0
  52. whiletrue/core/composite.py +140 -0
  53. whiletrue/core/config.py +461 -0
  54. whiletrue/core/context.py +177 -0
  55. whiletrue/core/duration.py +19 -0
  56. whiletrue/core/events.py +94 -0
  57. whiletrue/core/expr.py +85 -0
  58. whiletrue/core/history.py +83 -0
  59. whiletrue/core/lock.py +86 -0
  60. whiletrue/core/memory_store.py +352 -0
  61. whiletrue/core/module.py +146 -0
  62. whiletrue/core/outcomes.py +82 -0
  63. whiletrue/core/registry.py +129 -0
  64. whiletrue/core/replay.py +58 -0
  65. whiletrue/core/runner.py +98 -0
  66. whiletrue/core/runtime.py +538 -0
  67. whiletrue/core/source.py +45 -0
  68. whiletrue/core/store.py +207 -0
  69. whiletrue/core/template.py +60 -0
  70. whiletrue/core/validate.py +142 -0
  71. whiletrue/core/wake.py +117 -0
  72. whiletrue/core/workspace.py +55 -0
  73. whiletrue/git/__init__.py +31 -0
  74. whiletrue/git/modules.py +128 -0
  75. whiletrue/git/workspaces.py +77 -0
  76. whiletrue/github/__init__.py +30 -0
  77. whiletrue/github/client.py +269 -0
  78. whiletrue/github/filebacked.py +87 -0
  79. whiletrue/github/memory.py +96 -0
  80. whiletrue/github/plugin.py +343 -0
  81. whiletrue/github/specs.py +39 -0
  82. whiletrue/opencode/__init__.py +25 -0
  83. whiletrue/opencode/runner.py +186 -0
  84. whiletrue/py.typed +0 -0
  85. whiletrue/recipes/issue-loop.yaml +59 -0
  86. whiletrue/recipes/ralph.yaml +14 -0
  87. whiletrue/recipes/review-pass.yaml +27 -0
  88. whiletrue/recipes/review-respond.yaml +22 -0
  89. whiletrue/store_sqlite/__init__.py +6 -0
  90. whiletrue/store_sqlite/migrations.py +132 -0
  91. whiletrue/store_sqlite/sqlite_store.py +537 -0
  92. whiletrue/tmux/__init__.py +11 -0
  93. whiletrue/tmux/controller.py +197 -0
  94. whiletrue-0.1.0.dist-info/METADATA +48 -0
  95. whiletrue-0.1.0.dist-info/RECORD +98 -0
  96. whiletrue-0.1.0.dist-info/WHEEL +4 -0
  97. whiletrue-0.1.0.dist-info/entry_points.txt +2 -0
  98. whiletrue-0.1.0.dist-info/licenses/LICENSE +21 -0
whiletrue/cli/gates.py ADDED
@@ -0,0 +1,156 @@
1
+ """Commands for the humans in the loop: approve, reject, comment, history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import time
7
+ import uuid
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+
11
+ from whiletrue.cli.common import EXIT, Io
12
+ from whiletrue.core import Decision
13
+ from whiletrue.store_sqlite import SqliteStore
14
+
15
+
16
+ def _now() -> int:
17
+ return int(time.time() * 1000)
18
+
19
+
20
+ def _who() -> str:
21
+ try:
22
+ return getpass.getuser()
23
+ except Exception: # pragma: no cover (no passwd entry)
24
+ return "cli"
25
+
26
+
27
+ def open_store(io: Io) -> SqliteStore | None:
28
+ db_path = Path(io.cwd) / ".whiletrue" / "state.db"
29
+ if not db_path.exists():
30
+ io.err("No runs yet (.whiletrue/state.db not found)\n")
31
+ return None
32
+ return SqliteStore(db_path)
33
+
34
+
35
+ def decide(io: Io, decision: Decision, item: str | None, note: str | None, step: str | None) -> int:
36
+ verb = "approve" if decision == "approved" else "reject"
37
+ if item is None:
38
+ io.err(
39
+ f"Usage: whiletrue {verb} <item>{' --note "..."' if decision == 'rejected' else ''}\n"
40
+ )
41
+ return EXIT.usage
42
+ if decision == "rejected" and not (note and note.strip()):
43
+ io.err('A rejection needs a reason: whiletrue reject <item> --note "what to change"\n')
44
+ return EXIT.usage
45
+ store = open_store(io)
46
+ if store is None:
47
+ return EXIT.failed
48
+ try:
49
+ waiting = store.list_parks(item, undecided_only=True)
50
+ if step is not None:
51
+ waiting = [p for p in waiting if p.node_path == step]
52
+ if not waiting:
53
+ io.err(f'Nothing is waiting on a decision for "{item}"\n')
54
+ return EXIT.failed
55
+ steps = sorted({p.node_path for p in waiting})
56
+ if len(steps) > 1:
57
+ io.err(f'"{item}" is waiting at several gates ({", ".join(steps)}); pass --step <id>\n')
58
+ return EXIT.usage
59
+ park = waiting[-1]
60
+ store.decide_park(park.id, decision, decided_by=_who(), decided_at=_now(), notes=note)
61
+ io.out(f"{item}: {decision} ({park.what} at {park.node_path})\n")
62
+ io.out("Run `whiletrue resume` to continue (a watching run picks it up by itself).\n")
63
+ return EXIT.ok
64
+ finally:
65
+ store.close()
66
+
67
+
68
+ def comment(io: Io, item: str | None, text: str | None) -> int:
69
+ if item is None or not text or not text.strip():
70
+ io.err('Usage: whiletrue comment <item> "your comment"\n')
71
+ return EXIT.usage
72
+ store = open_store(io)
73
+ if store is None:
74
+ return EXIT.failed
75
+ try:
76
+ added = store.add_comment(
77
+ item_id=item,
78
+ source="local",
79
+ external_id=uuid.uuid4().hex,
80
+ author=_who(),
81
+ body=text.strip(),
82
+ created_at=_now(),
83
+ )
84
+ assert added is not None
85
+ io.out(f"comment #{added.id} added to {item}; the pipeline answers it at the next wake\n")
86
+ return EXIT.ok
87
+ finally:
88
+ store.close()
89
+
90
+
91
+ def _stamp(ms: int) -> str:
92
+ return datetime.fromtimestamp(ms / 1000, UTC).strftime("%Y-%m-%d %H:%M:%S")
93
+
94
+
95
+ def history(io: Io, item: str | None) -> int:
96
+ if item is None:
97
+ io.err("Usage: whiletrue history <item>\n")
98
+ return EXIT.usage
99
+ store = open_store(io)
100
+ if store is None:
101
+ return EXIT.failed
102
+ try:
103
+ lines: list[tuple[int, str]] = []
104
+ for park in store.list_parks(item):
105
+ lines.append((park.created_at, f"parked at {park.node_path}: waiting for {park.what}"))
106
+ if park.decision is not None and park.decided_at is not None:
107
+ note = f" — {park.notes}" if park.notes else ""
108
+ lines.append(
109
+ (park.decided_at, f"{park.decision} by {park.decided_by}{note}"),
110
+ )
111
+ for c in store.list_comments(item):
112
+ lines.append((c.created_at, f"comment from {c.author} ({c.source}): {c.body}"))
113
+ if c.reply:
114
+ lines.append((c.created_at, f" reply: {c.reply}"))
115
+ for s in store.list_steers(item):
116
+ lines.append((s.created_at, f"steered {s.step}: {s.prompt}"))
117
+ if not lines:
118
+ io.err(f'No history for "{item}"\n')
119
+ return EXIT.failed
120
+ for when, text in sorted(lines, key=lambda x: x[0]):
121
+ io.out(f"{_stamp(when)} {text}\n")
122
+ return EXIT.ok
123
+ finally:
124
+ store.close()
125
+
126
+
127
+ def learnings(io: Io, action: str | None, learning_id: str | None) -> int:
128
+ """List proposed lessons, or approve/reject one. Only approved lessons reach agents."""
129
+ store = open_store(io)
130
+ if store is None:
131
+ return EXIT.failed
132
+ try:
133
+ if action in (None, "list"):
134
+ rows = store.list_learnings()
135
+ if not rows:
136
+ io.out("No learnings yet\n")
137
+ return EXIT.ok
138
+ for row in rows:
139
+ source = row.provenance.get("source", "?")
140
+ item = row.provenance.get("item", "?")
141
+ io.out(f"#{row.id} {row.status:<8} {row.note} (from {source} on {item})\n")
142
+ return EXIT.ok
143
+ if action not in ("approve", "reject") or not (learning_id or "").isdigit():
144
+ io.err("Usage: whiletrue learnings [list | approve <id> | reject <id>]\n")
145
+ return EXIT.usage
146
+ try:
147
+ store.set_learning_status(
148
+ int(learning_id or 0), "approved" if action == "approve" else "rejected"
149
+ )
150
+ except KeyError:
151
+ io.err(f"No learning #{learning_id}\n")
152
+ return EXIT.failed
153
+ io.out(f"learning #{learning_id}: {action}d\n")
154
+ return EXIT.ok
155
+ finally:
156
+ store.close()
whiletrue/cli/ops.py ADDED
@@ -0,0 +1,339 @@
1
+ """Operating a project's runs: the run lock, crash recovery, rerun, cancel, logs and cost."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import fcntl
7
+ import os
8
+ import time
9
+ from collections import defaultdict
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Any, cast
12
+
13
+ from whiletrue.cli.common import EXIT, Io
14
+ from whiletrue.core import (
15
+ ReplayError,
16
+ RunItem,
17
+ SourceError,
18
+ fetch_items,
19
+ find_dependency_cycles,
20
+ load_recording,
21
+ run_pipeline,
22
+ )
23
+ from whiletrue.store_sqlite import SqliteStore
24
+
25
+ if TYPE_CHECKING:
26
+ from whiletrue.cli.app import _Loaded # pyright: ignore[reportPrivateUsage]
27
+
28
+
29
+ class RunLock:
30
+ """An exclusive lock on `.whiletrue/run.lock`, held for as long as a run's process lives.
31
+
32
+ It tells a crashed run (status `running`, nobody holds the lock) from a live one.
33
+ """
34
+
35
+ def __init__(self, cwd: str) -> None:
36
+ self._path = Path(cwd) / ".whiletrue" / "run.lock"
37
+ self._handle: Any = None
38
+
39
+ def acquire(self) -> bool:
40
+ self._path.parent.mkdir(parents=True, exist_ok=True)
41
+ handle = self._path.open("a+")
42
+ try:
43
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
44
+ except OSError:
45
+ handle.close()
46
+ return False
47
+ handle.seek(0)
48
+ handle.truncate()
49
+ handle.write(str(os.getpid()))
50
+ handle.flush()
51
+ self._handle = handle
52
+ return True
53
+
54
+ def release(self) -> None:
55
+ if self._handle is not None:
56
+ fcntl.flock(self._handle, fcntl.LOCK_UN)
57
+ self._handle.close()
58
+ self._handle = None
59
+
60
+ def holder(self) -> str:
61
+ try:
62
+ return self._path.read_text().strip() or "?"
63
+ except OSError:
64
+ return "?"
65
+
66
+
67
+ def item_line(item: Any) -> str:
68
+ """One result line; a failure or park says why, so nobody has to dig in the database."""
69
+ line = f" {item.item_id}: {item.state} ({item.port})"
70
+ value: Any = item.value
71
+ if item.port in ("fail", "needs_human", "rejected") and isinstance(value, dict):
72
+ fields = cast(dict[str, Any], value)
73
+ why = fields.get("error") or fields.get("reason") or fields.get("detail")
74
+ if why:
75
+ line += f" — {str(why).strip().splitlines()[0][:200]}"
76
+ return line + "\n"
77
+
78
+
79
+ def _status_code(status: str) -> int:
80
+ return {"done": EXIT.ok, "parked": EXIT.parked, "cancelled": EXIT.cancelled}.get(
81
+ status, EXIT.failed
82
+ )
83
+
84
+
85
+ def _log(io: Io):
86
+ def log(level: str, message: str, data: object = None) -> None:
87
+ if level != "debug":
88
+ io.out(f"[{level}] {message}\n")
89
+
90
+ return log
91
+
92
+
93
+ async def source_items(loaded: _Loaded) -> list[RunItem] | None:
94
+ return await fetch_items(loaded.registry, loaded.config.extra, loaded.cwd)
95
+
96
+
97
+ def cycle_error(items: list[RunItem] | None) -> str | None:
98
+ cycles = find_dependency_cycles(items or [])
99
+ if not cycles:
100
+ return None
101
+ return "Items depend on each other in a cycle: " + "; ".join(" -> ".join(c) for c in cycles)
102
+
103
+
104
+ async def resume(io: Io, loaded: _Loaded, run_id: str | None, lock_hash: str | None) -> int:
105
+ """Continue a parked run, or one whose process died: recorded steps are replayed."""
106
+ db_path = Path(loaded.cwd) / ".whiletrue" / "state.db"
107
+ if not db_path.exists():
108
+ io.err("No runs yet (.whiletrue/state.db not found)\n")
109
+ return EXIT.failed
110
+ store = SqliteStore(db_path)
111
+ try:
112
+ if run_id is None:
113
+ latest = store.latest_run()
114
+ run_id = latest.id if latest else ""
115
+ run = store.get_run(run_id)
116
+ if run is not None and run.status == "running":
117
+ # We hold the run lock, so nothing is running it: the process died.
118
+ store.interrupt_run(run_id, int(time.time() * 1000))
119
+ io.out(f"run {run_id} was interrupted (its process is gone); recovering it\n")
120
+ try:
121
+ recording = load_recording(store, run_id)
122
+ except ReplayError as error:
123
+ io.err(f"{error}\n")
124
+ return EXIT.failed
125
+ if recording.status != "parked":
126
+ io.err(f"Run {run_id} is {recording.status}, not parked: nothing to resume\n")
127
+ return EXIT.failed
128
+ items = [i for i in recording.items if recording.item_states.get(i.id) != "cancelled"]
129
+ try:
130
+ fresh = await source_items(loaded)
131
+ except SourceError as error:
132
+ io.err(f"Cannot fetch items: {error}\n")
133
+ return EXIT.failed
134
+ if fresh is not None:
135
+ # The source wins: items it no longer lists are dropped, and its newest data is used.
136
+ wanted = {i.id for i in items}
137
+ gone = sorted(wanted - {f.id for f in fresh})
138
+ if gone:
139
+ io.out(f"no longer in the source, dropped: {', '.join(gone)}\n")
140
+ items = [f for f in fresh if f.id in wanted or f.id not in recording.item_states]
141
+ result = await run_pipeline(
142
+ registry=loaded.registry,
143
+ config=loaded.config,
144
+ store=store,
145
+ cwd=loaded.cwd,
146
+ signal=io.signal,
147
+ log=_log(io),
148
+ lockfile=lock_hash,
149
+ items=items,
150
+ replay=recording.steps,
151
+ resume=True,
152
+ lineage=recording.lineage,
153
+ )
154
+ io.out(f"resumed {recording.run_id} as run {result.run_id}: {result.status}\n")
155
+ for item in result.items:
156
+ io.out(item_line(item))
157
+ return _status_code(result.status)
158
+ finally:
159
+ store.close()
160
+
161
+
162
+ async def rerun(
163
+ io: Io, loaded: _Loaded, item_id: str, from_step: str | None, lock_hash: str | None
164
+ ) -> int:
165
+ """Run one item again as a new run; with `--from`, steps before that step are replayed."""
166
+ db_path = Path(loaded.cwd) / ".whiletrue" / "state.db"
167
+ if not db_path.exists():
168
+ io.err("No runs yet (.whiletrue/state.db not found)\n")
169
+ return EXIT.failed
170
+ store = SqliteStore(db_path)
171
+ try:
172
+ latest = store.latest_run()
173
+ if latest is None:
174
+ io.err("No runs yet\n")
175
+ return EXIT.failed
176
+ if latest.status == "running":
177
+ io.err("The latest run is still running; cancel it or wait\n")
178
+ return EXIT.failed
179
+ recording = load_recording(store, latest.id)
180
+ item = next((i for i in recording.items if i.id == item_id), None)
181
+ if item is None:
182
+ io.err(f'Run {latest.id} has no item "{item_id}"\n')
183
+ return EXIT.failed
184
+ replay: dict[tuple[str, str, int], tuple[str, Any]] = {}
185
+ if from_step is not None:
186
+ steps = store.list_steps(latest.id, item_id)
187
+ cut = next((s.id for s in steps if s.node_path == from_step), None)
188
+ if cut is None:
189
+ io.err(f'Item "{item_id}" has no step "{from_step}" in run {latest.id}\n')
190
+ return EXIT.failed
191
+ replay = {
192
+ (s.item_id, s.node_path, s.attempt): (s.port, s.output)
193
+ for s in steps
194
+ if s.id < cut and s.port is not None and s.port != "needs_human"
195
+ }
196
+ # Its blockers already ran; only this item is run again.
197
+ again = RunItem(item.id, item.payload, [])
198
+ result = await run_pipeline(
199
+ registry=loaded.registry,
200
+ config=loaded.config,
201
+ store=store,
202
+ cwd=loaded.cwd,
203
+ signal=io.signal,
204
+ log=_log(io),
205
+ lockfile=lock_hash,
206
+ items=[again],
207
+ replay=replay if from_step is not None else None,
208
+ resume=True,
209
+ )
210
+ io.out(f"rerun of {item_id} as run {result.run_id}: {result.status}\n")
211
+ return _status_code(result.status)
212
+ finally:
213
+ store.close()
214
+
215
+
216
+ def cancel(io: Io, item_id: str | None) -> int:
217
+ """Cancel an item in the latest run. A running one stops before its next step."""
218
+ if item_id is None:
219
+ io.err("Usage: whiletrue cancel <item>\n")
220
+ return EXIT.usage
221
+ db_path = Path(io.cwd) / ".whiletrue" / "state.db"
222
+ if not db_path.exists():
223
+ io.err("No runs yet (.whiletrue/state.db not found)\n")
224
+ return EXIT.failed
225
+ store = SqliteStore(db_path)
226
+ try:
227
+ run = store.latest_run()
228
+ record = store.get_item(run.id, item_id) if run else None
229
+ if run is None or record is None:
230
+ io.err(f'No item "{item_id}" in the latest run\n')
231
+ return EXIT.failed
232
+ if record.state in ("done", "cancelled"):
233
+ io.err(f'"{item_id}" is already {record.state}\n')
234
+ return EXIT.failed
235
+ for park in store.list_parks(item_id, undecided_only=True):
236
+ store.decide_park(
237
+ park.id, "rejected", decided_by="cancel", decided_at=_now(), notes="cancelled"
238
+ )
239
+ store.set_item_state(run.id, item_id, "cancelled")
240
+ io.out(f"{item_id}: cancelled\n")
241
+ return EXIT.ok
242
+ finally:
243
+ store.close()
244
+
245
+
246
+ def _now() -> int:
247
+ return int(time.time() * 1000)
248
+
249
+
250
+ def _event_line(event: Any) -> str:
251
+ stamp = time.strftime("%H:%M:%S", time.gmtime(event.ts / 1000))
252
+ detail = " ".join(f"{k}={v}" for k, v in event.data.items() if k not in ("files",))
253
+ return f"{stamp} {event.type:<16} {detail}".rstrip()
254
+
255
+
256
+ async def logs(io: Io, item_id: str | None, follow: bool) -> int:
257
+ if item_id is None:
258
+ io.err("Usage: whiletrue logs <item> [--follow]\n")
259
+ return EXIT.usage
260
+ db_path = Path(io.cwd) / ".whiletrue" / "state.db"
261
+ if not db_path.exists():
262
+ io.err("No runs yet (.whiletrue/state.db not found)\n")
263
+ return EXIT.failed
264
+ store = SqliteStore(db_path)
265
+ try:
266
+ run = store.latest_run()
267
+ if run is None:
268
+ io.err("No runs yet\n")
269
+ return EXIT.failed
270
+ shown = 0
271
+ while True:
272
+ events = store.list_events(run.id, item_id=item_id)
273
+ for event in events[shown:]:
274
+ io.out(_event_line(event) + "\n")
275
+ shown = len(events)
276
+ current = store.get_run(run.id)
277
+ if not follow or current is None or current.status != "running":
278
+ break
279
+ if io.signal is not None and io.signal.aborted:
280
+ return EXIT.cancelled
281
+ await asyncio.sleep(0.5)
282
+ if shown == 0:
283
+ io.err(f'No events for "{item_id}" in run {run.id}\n')
284
+ return EXIT.failed
285
+ return EXIT.ok
286
+ finally:
287
+ store.close()
288
+
289
+
290
+ def _lineage_runs(store: SqliteStore, run_id: str) -> list[str]:
291
+ """The run and every other run of the same lineage (the resumes of one piece of work)."""
292
+
293
+ def lineage_of(rid: str) -> str:
294
+ started = store.list_events(rid, type="run.started")
295
+ return str(started[0].data.get("lineage", rid)) if started else rid
296
+
297
+ wanted = lineage_of(run_id)
298
+ return [r.id for r in store.list_runs() if lineage_of(r.id) == wanted]
299
+
300
+
301
+ def cost(io: Io, run_id: str | None) -> int:
302
+ """Spend by item, step and runner, over a run and all its resumes."""
303
+ db_path = Path(io.cwd) / ".whiletrue" / "state.db"
304
+ if not db_path.exists():
305
+ io.err("No runs yet (.whiletrue/state.db not found)\n")
306
+ return EXIT.failed
307
+ store = SqliteStore(db_path)
308
+ try:
309
+ run = store.get_run(run_id) if run_id else store.latest_run()
310
+ if run is None:
311
+ io.err("No runs yet\n")
312
+ return EXIT.failed
313
+ runs = _lineage_runs(store, run.id)
314
+ runner_of: dict[tuple[str, str], str] = {}
315
+ by_item: dict[str, float] = defaultdict(float)
316
+ by_step: dict[str, float] = defaultdict(float)
317
+ by_runner: dict[str, float] = defaultdict(float)
318
+ for rid in runs:
319
+ for event in store.list_events(rid, type="agent.event"):
320
+ if event.data.get("kind") == "runner" and event.item_id:
321
+ key = (event.item_id, str(event.data.get("node")))
322
+ runner_of[key] = str(event.data["runner"])
323
+ for step in store.list_steps(rid):
324
+ if step.cost_usd <= 0:
325
+ continue
326
+ by_item[step.item_id] += step.cost_usd
327
+ by_step[step.node_path] += step.cost_usd
328
+ runner = runner_of.get((step.item_id, step.node_path), "other")
329
+ by_runner[runner] += step.cost_usd
330
+ total = sum(by_item.values())
331
+ span = f" (over {len(runs)} runs)" if len(runs) > 1 else ""
332
+ io.out(f"run {run.id} total ${total:.4f}{span}\n")
333
+ for title, table in (("by item", by_item), ("by step", by_step), ("by runner", by_runner)):
334
+ io.out(f"{title}:\n")
335
+ for name, usd in sorted(table.items(), key=lambda kv: -kv[1]):
336
+ io.out(f" {name:<24} ${usd:.4f}\n")
337
+ return EXIT.ok
338
+ finally:
339
+ store.close()
whiletrue/cli/panel.py ADDED
@@ -0,0 +1,198 @@
1
+ """The tmux control panel (window 0): every item, where it is, and who it is waiting for."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ import time
8
+ from collections.abc import Awaitable, Callable
9
+ from dataclasses import dataclass
10
+
11
+ from whiletrue.cli.common import Io
12
+ from whiletrue.core import StateStore
13
+ from whiletrue.tmux import TmuxController
14
+
15
+ KEYS = "j/k move a approve r reject c cancel f focus pane R rerun (hint) q quit"
16
+
17
+
18
+ @dataclass
19
+ class Row:
20
+ item: str
21
+ state: str
22
+ step: str
23
+ waiting: str
24
+ cost: float
25
+ age_s: int
26
+
27
+
28
+ def rows(store: StateStore, now_ms: int) -> list[Row]:
29
+ run = store.latest_run()
30
+ if run is None:
31
+ return []
32
+ parks = store.list_parks(undecided_only=True)
33
+ out: list[Row] = []
34
+ for item in store.list_items(run.id):
35
+ steps = store.list_steps(run.id, item.id)
36
+ working = [s for s in steps if s.status == "running" and s.node_path != "root"]
37
+ last = working[-1] if working else (steps[-1] if steps else None)
38
+ mine = [p for p in parks if p.item_id == item.id]
39
+ waiting = f"{mine[-1].what} at {mine[-1].node_path}" if mine else ""
40
+ started = min((s.started_at for s in steps), default=run.started_at)
41
+ out.append(
42
+ Row(
43
+ item=item.id,
44
+ state=item.state,
45
+ step=last.node_path if last else "",
46
+ waiting=waiting,
47
+ cost=sum(s.cost_usd for s in steps),
48
+ age_s=max(0, (now_ms - started) // 1000),
49
+ )
50
+ )
51
+ return out
52
+
53
+
54
+ def _age(seconds: int) -> str:
55
+ if seconds >= 3600:
56
+ return f"{seconds // 3600}h{(seconds % 3600) // 60:02d}m"
57
+ return f"{seconds // 60}m{seconds % 60:02d}s"
58
+
59
+
60
+ def render(table: list[Row], selected: int, message: str = "") -> str:
61
+ lines = [f" {'ITEM':<10}{'STATE':<10}{'STEP':<18}{'WAITING FOR':<24}{'COST':>8} AGE"]
62
+ for index, row in enumerate(table):
63
+ mark = ">" if index == selected else " "
64
+ lines.append(
65
+ f"{mark} {row.item:<10}{row.state:<10}{row.step:<18}{row.waiting:<24}"
66
+ f"{'$' + format(row.cost, '.2f'):>8} {_age(row.age_s)}"
67
+ )
68
+ if not table:
69
+ lines.append(" (no items yet)")
70
+ lines += ["", KEYS]
71
+ if message:
72
+ lines.append(message)
73
+ return "\n".join(lines)
74
+
75
+
76
+ class Panel:
77
+ """Keys act on the selected item. Reads and writes go through the store, like the CLI."""
78
+
79
+ def __init__(
80
+ self,
81
+ store: StateStore,
82
+ tmux: TmuxController | None = None,
83
+ *,
84
+ now: Callable[[], int] = lambda: int(time.time() * 1000),
85
+ ask: Callable[[str], Awaitable[str]] | None = None,
86
+ who: str = "panel",
87
+ ) -> None:
88
+ self._store = store
89
+ self._tmux = tmux
90
+ self._now = now
91
+ self._ask = ask
92
+ self._who = who
93
+ self.selected = 0
94
+ self.message = ""
95
+
96
+ def view(self) -> str:
97
+ table = rows(self._store, self._now())
98
+ self.selected = min(self.selected, max(0, len(table) - 1))
99
+ return render(table, self.selected, self.message)
100
+
101
+ def _current(self) -> Row | None:
102
+ table = rows(self._store, self._now())
103
+ return table[self.selected] if table else None
104
+
105
+ async def key(self, key: str) -> bool:
106
+ """Handles one key; False means quit."""
107
+ self.message = ""
108
+ if key == "q":
109
+ return False
110
+ if key in ("j", "k"):
111
+ size = len(rows(self._store, self._now()))
112
+ self.selected = max(0, min(size - 1, self.selected + (1 if key == "j" else -1)))
113
+ return True
114
+ row = self._current()
115
+ if row is None:
116
+ return True
117
+ if key in ("a", "r"):
118
+ await self._decide(row, approve=key == "a")
119
+ elif key == "f":
120
+ await self._focus(row)
121
+ elif key == "c":
122
+ self._cancel(row)
123
+ elif key == "R":
124
+ self.message = f"run: whiletrue rerun {row.item} [--from <step>]"
125
+ return True
126
+
127
+ def _cancel(self, row: Row) -> None:
128
+ run = self._store.latest_run()
129
+ if run is None or row.state in ("done", "cancelled"):
130
+ self.message = f"{row.item} is already {row.state}"
131
+ return
132
+ for park in self._store.list_parks(row.item, undecided_only=True):
133
+ self._store.decide_park(
134
+ park.id, "rejected", decided_by=self._who, decided_at=self._now(), notes="cancelled"
135
+ )
136
+ self._store.set_item_state(run.id, row.item, "cancelled")
137
+ self.message = f"{row.item}: cancelled"
138
+
139
+ async def _decide(self, row: Row, *, approve: bool) -> None:
140
+ waiting = self._store.list_parks(row.item, undecided_only=True)
141
+ if not waiting:
142
+ self.message = f"{row.item} is not waiting for a decision"
143
+ return
144
+ note: str | None = None
145
+ if not approve:
146
+ note = (await self._ask("rejection note: ")).strip() if self._ask else ""
147
+ if not note:
148
+ self.message = "a rejection needs a note"
149
+ return
150
+ self._store.decide_park(
151
+ waiting[-1].id,
152
+ "approved" if approve else "rejected",
153
+ decided_by=self._who,
154
+ decided_at=self._now(),
155
+ notes=note,
156
+ )
157
+ self.message = f"{row.item}: {'approved' if approve else 'rejected'}"
158
+
159
+ async def _focus(self, row: Row) -> None:
160
+ if self._tmux is None:
161
+ self.message = "no tmux session to focus"
162
+ return
163
+ pane = await self._tmux.find_pane(row.item)
164
+ if pane is None:
165
+ self.message = f"no live pane for {row.item}"
166
+ return
167
+ await self._tmux.focus(pane)
168
+
169
+
170
+ async def run_panel(
171
+ io: Io, store: StateStore, tmux: TmuxController | None
172
+ ) -> int: # pragma: no cover
173
+ """The interactive loop: redraw every second, act on single key presses."""
174
+ import getpass # noqa: PLC0415
175
+ import select # noqa: PLC0415
176
+ import termios # noqa: PLC0415
177
+ import tty # noqa: PLC0415
178
+
179
+ fd = sys.stdin.fileno()
180
+ saved = termios.tcgetattr(fd)
181
+
182
+ async def ask(prompt: str) -> str:
183
+ termios.tcsetattr(fd, termios.TCSADRAIN, saved)
184
+ try:
185
+ return await asyncio.to_thread(input, prompt)
186
+ finally:
187
+ tty.setcbreak(fd)
188
+
189
+ panel = Panel(store, tmux, ask=ask, who=getpass.getuser())
190
+ tty.setcbreak(fd)
191
+ try:
192
+ while True:
193
+ io.out("\x1b[2J\x1b[H" + panel.view() + "\n")
194
+ ready, _, _ = await asyncio.to_thread(select.select, [sys.stdin], [], [], 1.0)
195
+ if ready and not await panel.key(sys.stdin.read(1)):
196
+ return 0
197
+ finally:
198
+ termios.tcsetattr(fd, termios.TCSADRAIN, saved)