roundtable-cli 0.4.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.
roundtable/errors.py ADDED
@@ -0,0 +1,20 @@
1
+ """Roundtable-specific exceptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class RoundtableError(Exception):
7
+ """Base class for expected, user-facing roundtable errors."""
8
+
9
+
10
+ class TaskFailed(RoundtableError):
11
+ """A task exhausted its retries or failed validation.
12
+
13
+ Raised inside ``_run_task`` and caught per-task in ``_schedule`` so that
14
+ dependent tasks can be skipped and the phase can be marked failed.
15
+ """
16
+
17
+ def __init__(self, task_id: str, detail: str = ""):
18
+ self.task_id = task_id
19
+ self.detail = detail
20
+ super().__init__(f"task {task_id!r} failed: {detail}" if detail else f"task {task_id!r} failed")
roundtable/insights.py ADDED
@@ -0,0 +1,210 @@
1
+ """Live state + insights derived from ``plan.json`` and the run event log.
2
+
3
+ ``build_state`` is a pure read over the on-disk source of truth, so any number of
4
+ viewers (the web dashboard, the terminal watch, a script hitting ``/api/state``)
5
+ can poll it without touching the running engine. ``render_text`` is the terminal
6
+ rendering. Insights: progress, what each agent is doing *now*, per-agent task
7
+ counts/time, and task durations paired from ``task_started``/``task_done`` events.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import datetime as _dt
13
+ from typing import Any
14
+
15
+ from .models import Status
16
+ from .store import Store
17
+
18
+ _MARK = {"done": "x", "in_progress": "~", "pending": " ", "failed": "!", "skipped": "-", "waiting": "?"}
19
+
20
+
21
+ def _parse(ts: str | None) -> _dt.datetime | None:
22
+ if not ts:
23
+ return None
24
+ try:
25
+ return _dt.datetime.fromisoformat(ts)
26
+ except ValueError:
27
+ return None
28
+
29
+
30
+ def fmt_dur(seconds: float | None) -> str:
31
+ if seconds is None:
32
+ return "—"
33
+ seconds = max(0, int(seconds))
34
+ h, rem = divmod(seconds, 3600)
35
+ m, s = divmod(rem, 60)
36
+ return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
37
+
38
+
39
+ def build_state(store: Store, *, event_limit: int = 40) -> dict[str, Any]:
40
+ """Snapshot of the run: totals, current activity, per-agent + timing insights."""
41
+ if not store.has_plan():
42
+ return {"exists": False, "events": []}
43
+
44
+ plan = store.load_plan()
45
+ events = store.read_events()
46
+ now = _dt.datetime.now(_dt.timezone.utc)
47
+
48
+ # Pair task_started/task_done by task_id for start times + durations.
49
+ started_at: dict[str, str] = {}
50
+ duration: dict[str, float] = {}
51
+ for e in events:
52
+ tid = e.get("task_id")
53
+ if not tid:
54
+ continue
55
+ if e.get("type") == "task_started":
56
+ started_at[tid] = e.get("ts", "")
57
+ elif e.get("type") == "task_done" and tid in started_at:
58
+ a, b = _parse(started_at[tid]), _parse(e.get("ts"))
59
+ if a and b:
60
+ duration[tid] = (b - a).total_seconds()
61
+
62
+ totals = {"phases": len(plan.phases), "tasks": 0,
63
+ "done": 0, "in_progress": 0, "pending": 0, "failed": 0, "skipped": 0, "waiting": 0}
64
+ by_agent: dict[str, dict[str, float]] = {}
65
+ durations: list[tuple[str, float]] = []
66
+ now_running: list[dict[str, Any]] = []
67
+ phases_out: list[dict[str, Any]] = []
68
+
69
+ for ph in plan.phases:
70
+ tasks_out = []
71
+ for t in ph.tasks:
72
+ totals["tasks"] += 1
73
+ totals[t.status.value] = totals.get(t.status.value, 0) + 1
74
+ dur = duration.get(t.id)
75
+ if dur is not None:
76
+ durations.append((t.id, dur))
77
+
78
+ key = t.runner.agent or t.runner.model or "?"
79
+ agg = by_agent.setdefault(key, {"tasks": 0, "total_s": 0.0})
80
+ if t.status == Status.done:
81
+ agg["tasks"] += 1
82
+ if dur is not None:
83
+ agg["total_s"] += dur
84
+
85
+ entry = {
86
+ "id": t.id, "title": t.title, "runner": str(t.runner),
87
+ "agent": t.runner.agent, "model": t.runner.model,
88
+ "status": t.status.value, "duration_s": dur,
89
+ "depends_on": list(t.depends_on),
90
+ }
91
+ if t.status == Status.in_progress:
92
+ start = _parse(started_at.get(t.id))
93
+ entry["elapsed_s"] = (now - start).total_seconds() if start else None
94
+ now_running.append({"phase_id": ph.id, **entry})
95
+ tasks_out.append(entry)
96
+
97
+ done_n = sum(1 for t in ph.tasks if t.status == Status.done)
98
+ phases_out.append({
99
+ "id": ph.id, "index": ph.index, "title": ph.title, "objective": ph.objective,
100
+ "runner": str(ph.runner), "status": ph.status.value,
101
+ "tasks": tasks_out, "done": done_n, "total": len(ph.tasks),
102
+ })
103
+
104
+ pct = round(100 * totals["done"] / totals["tasks"]) if totals["tasks"] else 0
105
+ avg = round(sum(d for _, d in durations) / len(durations), 1) if durations else None
106
+ slowest = max(durations, key=lambda x: x[1]) if durations else None
107
+
108
+ run_state = (
109
+ "done" if plan.status == Status.done
110
+ else "running" if plan.status == Status.in_progress
111
+ else "failed" if plan.status == Status.failed
112
+ else "pending"
113
+ )
114
+ for key, agg in by_agent.items():
115
+ agg["total_s"] = round(agg["total_s"], 1)
116
+
117
+ # Latest provider usage snapshot (emitted per task + at run end by the engine).
118
+ usage_evt = next((e for e in reversed(events) if e.get("type") == "usage"), None)
119
+ usage = None
120
+ if usage_evt:
121
+ usage = {
122
+ "calls": usage_evt.get("calls", 0),
123
+ "total_tokens": usage_evt.get("total_tokens", 0),
124
+ "prompt_tokens": usage_evt.get("prompt_tokens", 0),
125
+ "completion_tokens": usage_evt.get("completion_tokens", 0),
126
+ "total_duration_s": usage_evt.get("total_duration_s", 0.0),
127
+ "estimated": usage_evt.get("estimated", False),
128
+ "cost_usd": usage_evt.get("cost_usd", 0.0),
129
+ }
130
+ # Usage events feed the tile above; keep them out of the raw event stream.
131
+ feed = [e for e in events if e.get("type") != "usage"]
132
+
133
+ return {
134
+ "exists": True,
135
+ "goal": plan.goal,
136
+ "approved": plan.approved,
137
+ "status": run_state,
138
+ "started_at": next((e.get("ts") for e in events if e.get("type") == "run_started"), None),
139
+ "updated_at": events[-1].get("ts") if events else None,
140
+ "now": now_running,
141
+ "totals": {**totals, "percent": pct},
142
+ "phases": phases_out,
143
+ "by_agent": by_agent,
144
+ "timings": {
145
+ "avg_task_s": avg,
146
+ "slowest": ({"task_id": slowest[0], "duration_s": round(slowest[1], 1)}
147
+ if slowest else None),
148
+ },
149
+ "usage": usage,
150
+ "events": list(reversed(feed[-event_limit:])),
151
+ "generated_at": now.isoformat(timespec="seconds"),
152
+ }
153
+
154
+
155
+ def render_text(state: dict[str, Any], *, width: int = 64) -> str:
156
+ """Compact terminal rendering of a state snapshot."""
157
+ if not state.get("exists"):
158
+ return "no plan yet — run `roundtable plan` first"
159
+
160
+ t = state["totals"]
161
+ badge = {"running": "● running", "done": "✓ done",
162
+ "failed": "✗ failed", "pending": "· pending"}.get(state["status"], state["status"])
163
+ filled = round((width - 2) * t["percent"] / 100)
164
+ bar = "█" * filled + "░" * (width - 2 - filled)
165
+
166
+ lines = [
167
+ f"Roundtable {badge} {t['done']}/{t['tasks']} tasks · {t['percent']}%",
168
+ f"Goal: {state['goal']}",
169
+ f"[{bar}]",
170
+ "",
171
+ ]
172
+
173
+ if state["now"]:
174
+ for n in state["now"]:
175
+ lines.append(f"▶ NOW {n['id']} {n['title']!r}")
176
+ lines.append(f" {n['runner']} · running {fmt_dur(n.get('elapsed_s'))}")
177
+ else:
178
+ idle = "done" if state["status"] == "done" else "idle"
179
+ lines.append(f"▶ NOW ({idle})")
180
+ lines.append("")
181
+
182
+ lines.append("Phases")
183
+ for ph in state["phases"]:
184
+ mark = _MARK.get(ph["status"], " ")
185
+ lines.append(f" [{mark}] {ph['index']} {ph['title']} {ph['done']}/{ph['total']} ({ph['runner']})")
186
+ lines.append("")
187
+
188
+ if state["by_agent"]:
189
+ ba = " · ".join(
190
+ f"{k} {v['tasks']}" + (f"/{fmt_dur(v['total_s'])}" if v["total_s"] else "")
191
+ for k, v in sorted(state["by_agent"].items())
192
+ )
193
+ lines.append(f"by agent: {ba}")
194
+ tim = state["timings"]
195
+ if tim["avg_task_s"] is not None:
196
+ slow = tim["slowest"]
197
+ extra = f" · slowest {slow['task_id']} {fmt_dur(slow['duration_s'])}" if slow else ""
198
+ lines.append(f"avg task {fmt_dur(tim['avg_task_s'])}{extra}")
199
+
200
+ u = state.get("usage")
201
+ if u:
202
+ note = " (est)" if u.get("estimated") else ""
203
+ cost = f" · ${u['cost_usd']:.4f}" if u.get("cost_usd") else ""
204
+ lines.append(
205
+ f"usage: {u['total_tokens']:,} tokens"
206
+ f" ({u['prompt_tokens']:,} in / {u['completion_tokens']:,} out)"
207
+ f" · {u['calls']} calls{cost}{note}"
208
+ )
209
+
210
+ return "\n".join(lines)