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/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)
|