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.
- whiletrue/__init__.py +0 -0
- whiletrue/__main__.py +3 -0
- whiletrue/abort.py +79 -0
- whiletrue/basics/__init__.py +99 -0
- whiletrue/basics/agent.py +249 -0
- whiletrue/basics/comments.py +113 -0
- whiletrue/basics/diff_size.py +80 -0
- whiletrue/basics/exec_.py +84 -0
- whiletrue/basics/history.py +117 -0
- whiletrue/basics/human.py +201 -0
- whiletrue/basics/json_source.py +35 -0
- whiletrue/basics/markdown_source.py +55 -0
- whiletrue/basics/process.py +99 -0
- whiletrue/basics/sessions.py +62 -0
- whiletrue/basics/set_.py +29 -0
- whiletrue/basics/shell.py +98 -0
- whiletrue/basics/wake.py +27 -0
- whiletrue/claude_code/__init__.py +58 -0
- whiletrue/claude_code/endpoint.py +143 -0
- whiletrue/claude_code/headless.py +181 -0
- whiletrue/claude_code/interactive.py +344 -0
- whiletrue/claude_code/learnings.py +32 -0
- whiletrue/claude_code/panes.py +164 -0
- whiletrue/claude_code/plugin/.claude-plugin/plugin.json +5 -0
- whiletrue/claude_code/plugin/hook.py +44 -0
- whiletrue/claude_code/plugin/hooks/hooks.json +81 -0
- whiletrue/claude_code/plugin/mcp_server.py +98 -0
- whiletrue/claude_code/session.py +345 -0
- whiletrue/claude_code/transcript.py +123 -0
- whiletrue/cli/__init__.py +15 -0
- whiletrue/cli/__main__.py +3 -0
- whiletrue/cli/app.py +550 -0
- whiletrue/cli/common.py +36 -0
- whiletrue/cli/gates.py +156 -0
- whiletrue/cli/ops.py +339 -0
- whiletrue/cli/panel.py +198 -0
- whiletrue/cli/registry.py +143 -0
- whiletrue/cli/templates.py +24 -0
- whiletrue/codex/__init__.py +14 -0
- whiletrue/codex/runner.py +243 -0
- whiletrue/control/__init__.py +38 -0
- whiletrue/control/chain.py +61 -0
- whiletrue/control/loop.py +139 -0
- whiletrue/control/map_.py +101 -0
- whiletrue/control/parallel.py +32 -0
- whiletrue/control/retry.py +100 -0
- whiletrue/control/sequence.py +40 -0
- whiletrue/control/switch.py +67 -0
- whiletrue/core/__init__.py +260 -0
- whiletrue/core/caps.py +139 -0
- whiletrue/core/comments.py +80 -0
- whiletrue/core/composite.py +140 -0
- whiletrue/core/config.py +461 -0
- whiletrue/core/context.py +177 -0
- whiletrue/core/duration.py +19 -0
- whiletrue/core/events.py +94 -0
- whiletrue/core/expr.py +85 -0
- whiletrue/core/history.py +83 -0
- whiletrue/core/lock.py +86 -0
- whiletrue/core/memory_store.py +352 -0
- whiletrue/core/module.py +146 -0
- whiletrue/core/outcomes.py +82 -0
- whiletrue/core/registry.py +129 -0
- whiletrue/core/replay.py +58 -0
- whiletrue/core/runner.py +98 -0
- whiletrue/core/runtime.py +538 -0
- whiletrue/core/source.py +45 -0
- whiletrue/core/store.py +207 -0
- whiletrue/core/template.py +60 -0
- whiletrue/core/validate.py +142 -0
- whiletrue/core/wake.py +117 -0
- whiletrue/core/workspace.py +55 -0
- whiletrue/git/__init__.py +31 -0
- whiletrue/git/modules.py +128 -0
- whiletrue/git/workspaces.py +77 -0
- whiletrue/github/__init__.py +30 -0
- whiletrue/github/client.py +269 -0
- whiletrue/github/filebacked.py +87 -0
- whiletrue/github/memory.py +96 -0
- whiletrue/github/plugin.py +343 -0
- whiletrue/github/specs.py +39 -0
- whiletrue/opencode/__init__.py +25 -0
- whiletrue/opencode/runner.py +186 -0
- whiletrue/py.typed +0 -0
- whiletrue/recipes/issue-loop.yaml +59 -0
- whiletrue/recipes/ralph.yaml +14 -0
- whiletrue/recipes/review-pass.yaml +27 -0
- whiletrue/recipes/review-respond.yaml +22 -0
- whiletrue/store_sqlite/__init__.py +6 -0
- whiletrue/store_sqlite/migrations.py +132 -0
- whiletrue/store_sqlite/sqlite_store.py +537 -0
- whiletrue/tmux/__init__.py +11 -0
- whiletrue/tmux/controller.py +197 -0
- whiletrue-0.1.0.dist-info/METADATA +48 -0
- whiletrue-0.1.0.dist-info/RECORD +98 -0
- whiletrue-0.1.0.dist-info/WHEEL +4 -0
- whiletrue-0.1.0.dist-info/entry_points.txt +2 -0
- whiletrue-0.1.0.dist-info/licenses/LICENSE +21 -0
whiletrue/cli/app.py
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import signal as signal_module
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from whiletrue.abort import AbortSignal
|
|
14
|
+
from whiletrue.cli import gates, ops
|
|
15
|
+
from whiletrue.cli.common import EXIT, Io
|
|
16
|
+
from whiletrue.cli.registry import RegistryOptions, create_default_registry, load_plugins
|
|
17
|
+
from whiletrue.cli.templates import RALPH_AGENT, RALPH_PIPELINE
|
|
18
|
+
from whiletrue.core import (
|
|
19
|
+
LOCK_FILE,
|
|
20
|
+
ConfigError,
|
|
21
|
+
LockError,
|
|
22
|
+
PipelineConfig,
|
|
23
|
+
PipelineNode,
|
|
24
|
+
Registry,
|
|
25
|
+
ReplayError,
|
|
26
|
+
check_lock,
|
|
27
|
+
format_diagnostic,
|
|
28
|
+
has_errors,
|
|
29
|
+
hash_pipeline,
|
|
30
|
+
load_pipeline,
|
|
31
|
+
load_recording,
|
|
32
|
+
read_lock,
|
|
33
|
+
run_pipeline,
|
|
34
|
+
sha256_file,
|
|
35
|
+
validate_pipeline,
|
|
36
|
+
write_lock,
|
|
37
|
+
)
|
|
38
|
+
from whiletrue.store_sqlite import SqliteStore
|
|
39
|
+
from whiletrue.tmux import TmuxController, session_name
|
|
40
|
+
|
|
41
|
+
USAGE = """whiletrue — run configurable loops around coding agents
|
|
42
|
+
|
|
43
|
+
Usage: whiletrue <command> [options]
|
|
44
|
+
|
|
45
|
+
Commands:
|
|
46
|
+
init Scaffold whiletrue.yaml and agents/ralph.md
|
|
47
|
+
validate Check config, wiring and safety rules (no tokens spent)
|
|
48
|
+
plan Print the expanded pipeline tree (no tokens spent)
|
|
49
|
+
run Run the pipeline
|
|
50
|
+
status [run] Show the latest (or given) run
|
|
51
|
+
replay [run] Re-run a recorded run's logic without calling agents or tools
|
|
52
|
+
lock Pin the plugin versions in use to whiletrue.lock
|
|
53
|
+
attach Attach to the project's tmux session (live agent panes)
|
|
54
|
+
panel The control panel (tmux window 0): items, state, who they wait for
|
|
55
|
+
resume [run] Continue a parked run (a decided gate lets its item through)
|
|
56
|
+
approve <item> Approve the gate an item is parked at [--note, --step]
|
|
57
|
+
reject <item> Reject it with a reason --note "what to change"
|
|
58
|
+
comment <item> "text" Leave a comment the pipeline must answer
|
|
59
|
+
rerun <item> Run one item again as a new run [--from <step>]
|
|
60
|
+
cancel <item> Cancel an item (a running one stops before its next step)
|
|
61
|
+
logs <item> The item's event log [--follow]
|
|
62
|
+
cost [run] Spend by item, step and runner
|
|
63
|
+
history <item> The item's timeline: parks, decisions, comments, replies, steers
|
|
64
|
+
learnings [approve|reject <id>] Review lessons proposed by history.distill
|
|
65
|
+
|
|
66
|
+
Options:
|
|
67
|
+
-f, --file <path> Pipeline file (default: whiletrue.yaml)
|
|
68
|
+
--force init: overwrite existing files
|
|
69
|
+
-m, --note <text> approve/reject: the reason
|
|
70
|
+
--step <id> approve/reject: which gate, when an item waits at several
|
|
71
|
+
-h, --help Show this help
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class _Loaded:
|
|
77
|
+
config: PipelineConfig
|
|
78
|
+
registry: Registry
|
|
79
|
+
cwd: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load(io: Io, file: str, *, check_pins: bool = True) -> _Loaded | int:
|
|
83
|
+
path = (Path(io.cwd) / file).resolve()
|
|
84
|
+
try:
|
|
85
|
+
config = load_pipeline(path)
|
|
86
|
+
except ConfigError as error:
|
|
87
|
+
io.err(f"{error}\n")
|
|
88
|
+
return EXIT.failed
|
|
89
|
+
except OSError as error:
|
|
90
|
+
io.err(f"Cannot load {file}: {error}\n")
|
|
91
|
+
return EXIT.failed
|
|
92
|
+
registry = create_default_registry(
|
|
93
|
+
RegistryOptions(project_dir=str(path.parent), pane_host=io.pane_host, github=io.github)
|
|
94
|
+
)
|
|
95
|
+
try:
|
|
96
|
+
load_plugins(registry, config.uses, str(path.parent))
|
|
97
|
+
except Exception as error:
|
|
98
|
+
io.err(f"{error}\n")
|
|
99
|
+
return EXIT.failed
|
|
100
|
+
cwd = str(path.parent)
|
|
101
|
+
if not check_pins:
|
|
102
|
+
return _Loaded(config, registry, cwd)
|
|
103
|
+
try:
|
|
104
|
+
pinned = read_lock(Path(cwd) / LOCK_FILE)
|
|
105
|
+
except LockError as error:
|
|
106
|
+
io.err(f"{error}\n")
|
|
107
|
+
return EXIT.failed
|
|
108
|
+
if pinned is not None:
|
|
109
|
+
problems = check_lock(pinned, registry.plugin_versions())
|
|
110
|
+
if problems:
|
|
111
|
+
io.err("".join(f"{p}\n" for p in problems) + "Run `whiletrue lock` to update it.\n")
|
|
112
|
+
return EXIT.failed
|
|
113
|
+
return _Loaded(config, registry, cwd)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _validated(io: Io, loaded: _Loaded) -> bool:
|
|
117
|
+
diagnostics = validate_pipeline(loaded.config, loaded.registry, cwd=loaded.cwd)
|
|
118
|
+
for d in diagnostics:
|
|
119
|
+
io.err(f"{format_diagnostic(d)}\n")
|
|
120
|
+
return not has_errors(diagnostics)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _render_tree(
|
|
124
|
+
node: PipelineNode, prefix: str = "", last: bool = True, root: bool = True
|
|
125
|
+
) -> list[str]:
|
|
126
|
+
summary = f" {json.dumps(node.config, separators=(',', ':'))}" if node.config else ""
|
|
127
|
+
connector = "" if root else prefix + ("└─ " if last else "├─ ")
|
|
128
|
+
line = f"{connector}{node.use}{summary} [{node.id}]"
|
|
129
|
+
child_prefix = "" if root else prefix + (" " if last else "│ ")
|
|
130
|
+
lines = [line]
|
|
131
|
+
for i, child in enumerate(node.children):
|
|
132
|
+
lines += _render_tree(child, child_prefix, i == len(node.children) - 1, False)
|
|
133
|
+
return lines
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _init(io: Io, force: bool) -> int:
|
|
137
|
+
files = [("whiletrue.yaml", RALPH_PIPELINE), ("agents/ralph.md", RALPH_AGENT)]
|
|
138
|
+
existing = [name for name, _ in files if (Path(io.cwd) / name).exists()]
|
|
139
|
+
if existing and not force:
|
|
140
|
+
io.err(f"Refusing to overwrite {', '.join(existing)} (use --force)\n")
|
|
141
|
+
return EXIT.failed
|
|
142
|
+
for name, content in files:
|
|
143
|
+
target = Path(io.cwd) / name
|
|
144
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
target.write_text(content, encoding="utf-8")
|
|
146
|
+
io.out(f"created {name}\n")
|
|
147
|
+
io.out("Next: whiletrue validate && whiletrue plan && whiletrue run\n")
|
|
148
|
+
return EXIT.ok
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def _run_loaded(io: Io, loaded: _Loaded) -> int:
|
|
152
|
+
store = SqliteStore(Path(loaded.cwd) / ".whiletrue" / "state.db")
|
|
153
|
+
|
|
154
|
+
def log(level: str, message: str, data: object = None) -> None:
|
|
155
|
+
if level != "debug":
|
|
156
|
+
io.out(f"[{level}] {message}\n")
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
try:
|
|
160
|
+
items = await ops.source_items(loaded)
|
|
161
|
+
except Exception as error:
|
|
162
|
+
io.err(f"Cannot fetch items: {error}\n")
|
|
163
|
+
return EXIT.failed
|
|
164
|
+
problem = ops.cycle_error(items)
|
|
165
|
+
if problem is not None:
|
|
166
|
+
io.err(f"{problem}\n")
|
|
167
|
+
return EXIT.failed
|
|
168
|
+
result = await run_pipeline(
|
|
169
|
+
registry=loaded.registry,
|
|
170
|
+
config=loaded.config,
|
|
171
|
+
store=store,
|
|
172
|
+
cwd=loaded.cwd,
|
|
173
|
+
signal=io.signal,
|
|
174
|
+
log=log,
|
|
175
|
+
lockfile=_lock_hash(loaded.cwd),
|
|
176
|
+
items=items,
|
|
177
|
+
)
|
|
178
|
+
io.out(f"run {result.run_id}: {result.status}\n")
|
|
179
|
+
for item in result.items:
|
|
180
|
+
io.out(ops.item_line(item))
|
|
181
|
+
return {
|
|
182
|
+
"done": EXIT.ok,
|
|
183
|
+
"parked": EXIT.parked,
|
|
184
|
+
"cancelled": EXIT.cancelled,
|
|
185
|
+
}.get(result.status, EXIT.failed)
|
|
186
|
+
finally:
|
|
187
|
+
store.close()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def _replay(io: Io, file: str, run_id: str | None) -> int:
|
|
191
|
+
db_path = Path(io.cwd) / ".whiletrue" / "state.db"
|
|
192
|
+
if not db_path.exists():
|
|
193
|
+
io.err("No runs yet (.whiletrue/state.db not found)\n")
|
|
194
|
+
return EXIT.failed
|
|
195
|
+
loaded = _load(io, file)
|
|
196
|
+
if isinstance(loaded, int):
|
|
197
|
+
return loaded
|
|
198
|
+
if not _validated(io, loaded):
|
|
199
|
+
return EXIT.failed
|
|
200
|
+
store = SqliteStore(db_path)
|
|
201
|
+
try:
|
|
202
|
+
if run_id is None:
|
|
203
|
+
latest = store.latest_run()
|
|
204
|
+
run_id = latest.id if latest else ""
|
|
205
|
+
try:
|
|
206
|
+
recording = load_recording(store, run_id)
|
|
207
|
+
except ReplayError as error:
|
|
208
|
+
io.err(f"{error}\n")
|
|
209
|
+
return EXIT.failed
|
|
210
|
+
if recording.pipeline_hash != hash_pipeline(loaded.config):
|
|
211
|
+
io.out("note: the pipeline changed since the recorded run\n")
|
|
212
|
+
result = await run_pipeline(
|
|
213
|
+
registry=loaded.registry,
|
|
214
|
+
config=loaded.config,
|
|
215
|
+
store=store,
|
|
216
|
+
cwd=loaded.cwd,
|
|
217
|
+
signal=io.signal,
|
|
218
|
+
items=recording.items,
|
|
219
|
+
replay=recording.steps,
|
|
220
|
+
)
|
|
221
|
+
io.out(f"replay of {recording.run_id}: run {result.run_id}: {result.status}\n")
|
|
222
|
+
differences = [
|
|
223
|
+
f" {o.item_id}: recorded {recording.item_states.get(o.item_id)}, replayed {o.state}"
|
|
224
|
+
for o in result.items
|
|
225
|
+
if recording.item_states.get(o.item_id) != o.state
|
|
226
|
+
]
|
|
227
|
+
if result.status != recording.status or differences:
|
|
228
|
+
io.err(
|
|
229
|
+
f"replay differs: recorded {recording.status}, replayed {result.status}\n"
|
|
230
|
+
+ "".join(f"{d}\n" for d in differences)
|
|
231
|
+
)
|
|
232
|
+
return EXIT.failed
|
|
233
|
+
io.out(f"replay matches the recorded run ({recording.status})\n")
|
|
234
|
+
return EXIT.ok
|
|
235
|
+
finally:
|
|
236
|
+
store.close()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
async def _attach(io: Io) -> int:
|
|
240
|
+
tmux = io.tmux or TmuxController(
|
|
241
|
+
session_name(io.cwd), binary=os.environ.get("WHILETRUE_TMUX_BIN", "tmux")
|
|
242
|
+
)
|
|
243
|
+
if not await tmux.has_session():
|
|
244
|
+
io.err(
|
|
245
|
+
f"No tmux session {tmux.session!r}: it exists while a run uses an interactive agent\n"
|
|
246
|
+
)
|
|
247
|
+
return EXIT.failed
|
|
248
|
+
argv = tmux.attach_argv()
|
|
249
|
+
if io.attach is not None:
|
|
250
|
+
io.attach(argv)
|
|
251
|
+
else: # pragma: no cover (replaces this process)
|
|
252
|
+
os.execvp(argv[0], argv)
|
|
253
|
+
return EXIT.ok
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
async def _locked(io: Io, loaded: _Loaded, action: Callable[[], Awaitable[int]]) -> int:
|
|
257
|
+
"""Runs an action that changes a run while holding the project's run lock."""
|
|
258
|
+
lock = ops.RunLock(loaded.cwd)
|
|
259
|
+
if not lock.acquire():
|
|
260
|
+
io.err(f"Another whiletrue run is active in this project (pid {lock.holder()})\n")
|
|
261
|
+
return EXIT.failed
|
|
262
|
+
try:
|
|
263
|
+
return await action()
|
|
264
|
+
finally:
|
|
265
|
+
lock.release()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
async def _plan_items(io: Io, loaded: _Loaded) -> int:
|
|
269
|
+
"""With a source, `plan` also lists the items and rejects dependency cycles."""
|
|
270
|
+
if "source" not in loaded.config.extra:
|
|
271
|
+
return EXIT.ok
|
|
272
|
+
try:
|
|
273
|
+
items = await ops.source_items(loaded)
|
|
274
|
+
except Exception as error:
|
|
275
|
+
io.err(f"Cannot fetch items: {error}\n")
|
|
276
|
+
return EXIT.failed
|
|
277
|
+
items = items or []
|
|
278
|
+
io.out(f"items ({len(items)}, up to {loaded.config.parallel} at once):\n")
|
|
279
|
+
for item in items:
|
|
280
|
+
waits = f" after {', '.join(item.depends_on)}" if item.depends_on else ""
|
|
281
|
+
io.out(f" {item.id}{waits}\n")
|
|
282
|
+
problem = ops.cycle_error(items)
|
|
283
|
+
if problem is not None:
|
|
284
|
+
io.err(f"{problem}\n")
|
|
285
|
+
return EXIT.failed
|
|
286
|
+
return EXIT.ok
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
async def _with_loaded(io: Io, file: str, action: Callable[[_Loaded], Awaitable[int]]) -> int:
|
|
290
|
+
loaded = _load(io, file)
|
|
291
|
+
if isinstance(loaded, int):
|
|
292
|
+
return loaded
|
|
293
|
+
if not _validated(io, loaded):
|
|
294
|
+
return EXIT.failed
|
|
295
|
+
return await action(loaded)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
async def _run_locked(io: Io, loaded: _Loaded) -> int:
|
|
299
|
+
return await _locked(io, loaded, lambda: _run_loaded(io, loaded))
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
async def _panel(io: Io) -> int: # pragma: no cover (interactive terminal loop)
|
|
303
|
+
from whiletrue.cli.panel import run_panel # noqa: PLC0415
|
|
304
|
+
|
|
305
|
+
store = gates.open_store(io)
|
|
306
|
+
if store is None:
|
|
307
|
+
return EXIT.failed
|
|
308
|
+
tmux = io.tmux or TmuxController(
|
|
309
|
+
session_name(io.cwd), binary=os.environ.get("WHILETRUE_TMUX_BIN", "tmux")
|
|
310
|
+
)
|
|
311
|
+
try:
|
|
312
|
+
return await run_panel(io, store, tmux)
|
|
313
|
+
finally:
|
|
314
|
+
store.close()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _lock_hash(cwd: str) -> str | None:
|
|
318
|
+
path = Path(cwd) / LOCK_FILE
|
|
319
|
+
return sha256_file(path) if path.is_file() else None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _lock(io: Io, file: str) -> int:
|
|
323
|
+
loaded = _load(io, file, check_pins=False)
|
|
324
|
+
if isinstance(loaded, int):
|
|
325
|
+
return loaded
|
|
326
|
+
write_lock(Path(loaded.cwd) / LOCK_FILE, loaded.registry.plugin_versions())
|
|
327
|
+
io.out(f"wrote {LOCK_FILE}\n")
|
|
328
|
+
return EXIT.ok
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _iso(ms: int) -> str:
|
|
332
|
+
return (
|
|
333
|
+
datetime.fromtimestamp(ms / 1000, UTC)
|
|
334
|
+
.isoformat(timespec="milliseconds")
|
|
335
|
+
.replace("+00:00", "Z")
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _status(io: Io, run_id: str | None) -> int:
|
|
340
|
+
db_path = Path(io.cwd) / ".whiletrue" / "state.db"
|
|
341
|
+
if not db_path.exists():
|
|
342
|
+
io.err("No runs yet (.whiletrue/state.db not found)\n")
|
|
343
|
+
return EXIT.failed
|
|
344
|
+
store = SqliteStore(db_path)
|
|
345
|
+
try:
|
|
346
|
+
run = store.get_run(run_id) if run_id else store.latest_run()
|
|
347
|
+
if run is None:
|
|
348
|
+
io.err(f'No run "{run_id}"\n' if run_id else "No runs yet\n")
|
|
349
|
+
return EXIT.failed
|
|
350
|
+
io.out(f"run {run.id} {run.status} started {_iso(run.started_at)}\n")
|
|
351
|
+
steps = store.list_steps(run.id)
|
|
352
|
+
for item in store.list_items(run.id):
|
|
353
|
+
item_steps = [s for s in steps if s.item_id == item.id]
|
|
354
|
+
cost = sum(s.cost_usd for s in item_steps)
|
|
355
|
+
blocked = ""
|
|
356
|
+
if item.state == "pending":
|
|
357
|
+
states = store.list_events(run.id, item_id=item.id, type="item.state")
|
|
358
|
+
by = states[-1].data.get("blocked_by") if states else None
|
|
359
|
+
blocked = f" — blocked by {', '.join(by)}" if by else ""
|
|
360
|
+
io.out(
|
|
361
|
+
f" {item.id} {item.state} steps={len(item_steps)} cost=${cost:.4f}{blocked}\n"
|
|
362
|
+
)
|
|
363
|
+
for s in item_steps:
|
|
364
|
+
port = f" {s.port}" if s.port else ""
|
|
365
|
+
error = f" — {s.error}" if s.error else ""
|
|
366
|
+
io.out(f" {s.node_path}#{s.attempt} {s.status}{port}{error}\n")
|
|
367
|
+
waiting = store.list_parks(undecided_only=True)
|
|
368
|
+
if waiting:
|
|
369
|
+
io.out("waiting for a person:\n")
|
|
370
|
+
for park in waiting:
|
|
371
|
+
io.out(
|
|
372
|
+
f" {park.item_id} {park.what} at {park.node_path} "
|
|
373
|
+
f"(whiletrue approve {park.item_id} | reject {park.item_id} --note ...)\n"
|
|
374
|
+
)
|
|
375
|
+
return EXIT.ok
|
|
376
|
+
finally:
|
|
377
|
+
store.close()
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@dataclass
|
|
381
|
+
class _Args:
|
|
382
|
+
positionals: list[str]
|
|
383
|
+
file: str = "whiletrue.yaml"
|
|
384
|
+
force: bool = False
|
|
385
|
+
help: bool = False
|
|
386
|
+
note: str | None = None
|
|
387
|
+
step: str | None = None
|
|
388
|
+
from_step: str | None = None
|
|
389
|
+
follow: bool = False
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _parse_args(argv: list[str]) -> _Args:
|
|
393
|
+
args = _Args(positionals=[])
|
|
394
|
+
it = iter(argv)
|
|
395
|
+
for arg in it:
|
|
396
|
+
if arg in ("-f", "--file"):
|
|
397
|
+
value = next(it, None)
|
|
398
|
+
if value is None:
|
|
399
|
+
raise ValueError(f"Option '{arg}' argument missing")
|
|
400
|
+
args.file = value
|
|
401
|
+
elif arg.startswith("--file="):
|
|
402
|
+
args.file = arg.split("=", 1)[1]
|
|
403
|
+
elif arg == "--follow":
|
|
404
|
+
args.follow = True
|
|
405
|
+
elif arg == "--from":
|
|
406
|
+
value = next(it, None)
|
|
407
|
+
if value is None:
|
|
408
|
+
raise ValueError(f"Option '{arg}' argument missing")
|
|
409
|
+
args.from_step = value
|
|
410
|
+
elif arg.startswith("--from="):
|
|
411
|
+
args.from_step = arg.split("=", 1)[1]
|
|
412
|
+
elif arg in ("-m", "--note", "--step"):
|
|
413
|
+
value = next(it, None)
|
|
414
|
+
if value is None:
|
|
415
|
+
raise ValueError(f"Option '{arg}' argument missing")
|
|
416
|
+
if arg == "--step":
|
|
417
|
+
args.step = value
|
|
418
|
+
else:
|
|
419
|
+
args.note = value
|
|
420
|
+
elif arg.startswith(("--note=", "--step=")):
|
|
421
|
+
name, _, value = arg.partition("=")
|
|
422
|
+
if name == "--step":
|
|
423
|
+
args.step = value
|
|
424
|
+
else:
|
|
425
|
+
args.note = value
|
|
426
|
+
elif arg == "--force":
|
|
427
|
+
args.force = True
|
|
428
|
+
elif arg in ("-h", "--help"):
|
|
429
|
+
args.help = True
|
|
430
|
+
elif arg.startswith("-") and arg != "-":
|
|
431
|
+
raise ValueError(f"Unknown option '{arg}'")
|
|
432
|
+
else:
|
|
433
|
+
args.positionals.append(arg)
|
|
434
|
+
return args
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
async def main(argv: list[str], io: Io) -> int:
|
|
438
|
+
try:
|
|
439
|
+
args = _parse_args(argv)
|
|
440
|
+
except ValueError as error:
|
|
441
|
+
io.err(f"{error}\n{USAGE}")
|
|
442
|
+
return EXIT.usage
|
|
443
|
+
if args.help:
|
|
444
|
+
io.out(USAGE)
|
|
445
|
+
return EXIT.ok
|
|
446
|
+
if not args.positionals:
|
|
447
|
+
io.err(USAGE)
|
|
448
|
+
return EXIT.usage
|
|
449
|
+
command, *rest = args.positionals
|
|
450
|
+
|
|
451
|
+
match command:
|
|
452
|
+
case "init":
|
|
453
|
+
return _init(io, args.force)
|
|
454
|
+
case "validate":
|
|
455
|
+
loaded = _load(io, args.file)
|
|
456
|
+
if isinstance(loaded, int):
|
|
457
|
+
return loaded
|
|
458
|
+
if not _validated(io, loaded):
|
|
459
|
+
return EXIT.failed
|
|
460
|
+
io.out(f"{loaded.config.file}: ok\n")
|
|
461
|
+
return EXIT.ok
|
|
462
|
+
case "plan":
|
|
463
|
+
loaded = _load(io, args.file)
|
|
464
|
+
if isinstance(loaded, int):
|
|
465
|
+
return loaded
|
|
466
|
+
if not _validated(io, loaded):
|
|
467
|
+
return EXIT.failed
|
|
468
|
+
io.out(f"{loaded.config.name}\n{chr(10).join(_render_tree(loaded.config.root))}\n")
|
|
469
|
+
return await _plan_items(io, loaded)
|
|
470
|
+
case "run":
|
|
471
|
+
return await _with_loaded(io, args.file, lambda loaded: _run_locked(io, loaded))
|
|
472
|
+
case "replay":
|
|
473
|
+
return await _replay(io, args.file, rest[0] if rest else None)
|
|
474
|
+
case "attach":
|
|
475
|
+
return await _attach(io)
|
|
476
|
+
case "panel":
|
|
477
|
+
return await _panel(io)
|
|
478
|
+
case "resume":
|
|
479
|
+
return await _with_loaded(
|
|
480
|
+
io,
|
|
481
|
+
args.file,
|
|
482
|
+
lambda loaded: _locked(
|
|
483
|
+
io,
|
|
484
|
+
loaded,
|
|
485
|
+
lambda: ops.resume(
|
|
486
|
+
io, loaded, rest[0] if rest else None, _lock_hash(loaded.cwd)
|
|
487
|
+
),
|
|
488
|
+
),
|
|
489
|
+
)
|
|
490
|
+
case "rerun":
|
|
491
|
+
if not rest:
|
|
492
|
+
io.err("Usage: whiletrue rerun <item> [--from <step>]\n")
|
|
493
|
+
return EXIT.usage
|
|
494
|
+
return await _with_loaded(
|
|
495
|
+
io,
|
|
496
|
+
args.file,
|
|
497
|
+
lambda loaded: _locked(
|
|
498
|
+
io,
|
|
499
|
+
loaded,
|
|
500
|
+
lambda: ops.rerun(io, loaded, rest[0], args.from_step, _lock_hash(loaded.cwd)),
|
|
501
|
+
),
|
|
502
|
+
)
|
|
503
|
+
case "cancel":
|
|
504
|
+
return ops.cancel(io, rest[0] if rest else None)
|
|
505
|
+
case "logs":
|
|
506
|
+
return await ops.logs(io, rest[0] if rest else None, args.follow)
|
|
507
|
+
case "cost":
|
|
508
|
+
return ops.cost(io, rest[0] if rest else None)
|
|
509
|
+
case "approve":
|
|
510
|
+
return gates.decide(io, "approved", rest[0] if rest else None, args.note, args.step)
|
|
511
|
+
case "reject":
|
|
512
|
+
return gates.decide(io, "rejected", rest[0] if rest else None, args.note, args.step)
|
|
513
|
+
case "comment":
|
|
514
|
+
return gates.comment(
|
|
515
|
+
io, rest[0] if rest else None, " ".join(rest[1:]) if len(rest) > 1 else None
|
|
516
|
+
)
|
|
517
|
+
case "history":
|
|
518
|
+
return gates.history(io, rest[0] if rest else None)
|
|
519
|
+
case "learnings":
|
|
520
|
+
return gates.learnings(
|
|
521
|
+
io, rest[0] if rest else None, rest[1] if len(rest) > 1 else None
|
|
522
|
+
)
|
|
523
|
+
case "lock":
|
|
524
|
+
return _lock(io, args.file)
|
|
525
|
+
case "status":
|
|
526
|
+
return _status(io, rest[0] if rest else None)
|
|
527
|
+
case _:
|
|
528
|
+
io.err(f'Unknown command "{command}"\n{USAGE}')
|
|
529
|
+
return EXIT.usage
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
async def _amain(argv: list[str]) -> int:
|
|
533
|
+
abort = AbortSignal()
|
|
534
|
+
loop = asyncio.get_running_loop()
|
|
535
|
+
for sig in (signal_module.SIGINT, signal_module.SIGTERM):
|
|
536
|
+
loop.add_signal_handler(sig, abort.abort)
|
|
537
|
+
return await main(
|
|
538
|
+
argv,
|
|
539
|
+
Io(
|
|
540
|
+
cwd=str(Path.cwd()),
|
|
541
|
+
out=sys.stdout.write,
|
|
542
|
+
err=sys.stderr.write,
|
|
543
|
+
signal=abort,
|
|
544
|
+
),
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def entrypoint() -> None:
|
|
549
|
+
"""Console-script entry point (`whiletrue`)."""
|
|
550
|
+
sys.exit(asyncio.run(_amain(sys.argv[1:])))
|
whiletrue/cli/common.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from whiletrue.abort import AbortSignal
|
|
7
|
+
from whiletrue.claude_code import PaneHost
|
|
8
|
+
from whiletrue.github import GitHubClient
|
|
9
|
+
from whiletrue.tmux import TmuxController
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Io:
|
|
14
|
+
cwd: str
|
|
15
|
+
out: Callable[[str], object]
|
|
16
|
+
err: Callable[[str], object]
|
|
17
|
+
signal: AbortSignal | None = None
|
|
18
|
+
#: The project's tmux controller; defaults to the real one (tests inject a fake).
|
|
19
|
+
tmux: TmuxController | None = None
|
|
20
|
+
#: Replaces this process with the given argv (tmux attach); defaults to `os.execvp`.
|
|
21
|
+
attach: Callable[[list[str]], object] | None = None
|
|
22
|
+
#: Where interactive agents run; defaults to tmux panes (tests inject a plain-process host).
|
|
23
|
+
pane_host: PaneHost | None = None
|
|
24
|
+
#: GitHub access; defaults to the `gh` CLI (tests inject an in-memory fake).
|
|
25
|
+
github: GitHubClient | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Exit:
|
|
29
|
+
ok = 0
|
|
30
|
+
failed = 1
|
|
31
|
+
parked = 2
|
|
32
|
+
usage = 64
|
|
33
|
+
cancelled = 130
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
EXIT = Exit()
|