simjecture 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.
- conjecture_solver/__init__.py +10 -0
- conjecture_solver/__main__.py +4 -0
- conjecture_solver/action_handlers.py +459 -0
- conjecture_solver/adapters/__init__.py +7 -0
- conjecture_solver/adapters/base.py +96 -0
- conjecture_solver/adapters/fake.py +155 -0
- conjecture_solver/adapters/pic.py +196 -0
- conjecture_solver/adapters/warpx.py +1375 -0
- conjecture_solver/autonomous_research.py +1071 -0
- conjecture_solver/benchmarks/__init__.py +6 -0
- conjecture_solver/benchmarks/electrostatic_pic.py +547 -0
- conjecture_solver/benchmarks/kinetic_sufficiency.py +318 -0
- conjecture_solver/builtin_skills/warpx/SKILL.md +151 -0
- conjecture_solver/builtin_skills/warpx/agents/openai.yaml +4 -0
- conjecture_solver/builtin_skills/warpx/examples/implicit_em_smoke.py +74 -0
- conjecture_solver/builtin_skills/warpx/examples/minimal_smoke.py +54 -0
- conjecture_solver/builtin_skills/warpx/examples/openpmd_field_smoke.py +87 -0
- conjecture_solver/builtin_skills/warpx/manifest.json +11 -0
- conjecture_solver/builtin_skills/warpx/references/2d-xz-commissioning.md +145 -0
- conjecture_solver/builtin_skills/warpx/references/cpu-launch-tuning.md +54 -0
- conjecture_solver/builtin_skills/warpx/references/diagnostics.md +139 -0
- conjecture_solver/builtin_skills/warpx/references/gpu-launch-tuning.md +79 -0
- conjecture_solver/builtin_skills/warpx/references/local-cuda-deployment.md +87 -0
- conjecture_solver/builtin_skills/warpx/references/numerical-risks.md +32 -0
- conjecture_solver/builtin_skills/warpx/references/picmi-interface.md +118 -0
- conjecture_solver/builtin_skills/warpx/references/resource-scaling.md +24 -0
- conjecture_solver/builtin_skills/warpx/references/time-integration.md +58 -0
- conjecture_solver/builtin_skills/warpx/scripts/benchmark_cpu_threads.py +207 -0
- conjecture_solver/builtin_skills/warpx/scripts/benchmark_gpu.py +242 -0
- conjecture_solver/builtin_skills/warpx/scripts/bootstrap_local_cuda.sh +82 -0
- conjecture_solver/builtin_skills/warpx/scripts/probe_local_cuda.py +110 -0
- conjecture_solver/builtin_skills/warpx/scripts/reduced_energy_budget.py +195 -0
- conjecture_solver/builtin_skills/warpx/scripts/run_local_cuda.sh +39 -0
- conjecture_solver/campaign.py +406 -0
- conjecture_solver/cli.py +963 -0
- conjecture_solver/confirmation.py +287 -0
- conjecture_solver/control.py +234 -0
- conjecture_solver/deployment.py +874 -0
- conjecture_solver/discovery.py +68 -0
- conjecture_solver/domains/__init__.py +40 -0
- conjecture_solver/domains/base.py +157 -0
- conjecture_solver/domains/kinetic_sufficiency.py +521 -0
- conjecture_solver/ledger.py +238 -0
- conjecture_solver/lifecycle.py +131 -0
- conjecture_solver/literature.py +494 -0
- conjecture_solver/llm.py +228 -0
- conjecture_solver/models.py +279 -0
- conjecture_solver/mvp_agent.py +3212 -0
- conjecture_solver/mvp_claims.py +1279 -0
- conjecture_solver/mvp_control.py +322 -0
- conjecture_solver/mvp_guidance.py +172 -0
- conjecture_solver/mvp_launch.py +1296 -0
- conjecture_solver/mvp_monitor.py +1487 -0
- conjecture_solver/mvp_skills.py +538 -0
- conjecture_solver/orchestration.py +578 -0
- conjecture_solver/outbox.py +526 -0
- conjecture_solver/parameters.py +394 -0
- conjecture_solver/proposals.py +427 -0
- conjecture_solver/research_tools.py +278 -0
- conjecture_solver/schema_export.py +246 -0
- conjecture_solver/search.py +1047 -0
- conjecture_solver/semantics.py +146 -0
- conjecture_solver/tui/__init__.py +7 -0
- conjecture_solver/tui/app.py +202 -0
- conjecture_solver/tui/claim_views.py +213 -0
- conjecture_solver/tui/screens.py +1014 -0
- conjecture_solver/warpx_analysis.py +184 -0
- conjecture_solver/warpx_campaign.py +148 -0
- conjecture_solver/warpx_confirmation.py +617 -0
- simjecture-0.1.0.dist-info/METADATA +400 -0
- simjecture-0.1.0.dist-info/RECORD +75 -0
- simjecture-0.1.0.dist-info/WHEEL +4 -0
- simjecture-0.1.0.dist-info/entry_points.txt +4 -0
- simjecture-0.1.0.dist-info/licenses/LICENSE +202 -0
- simjecture-0.1.0.dist-info/licenses/NOTICE +6 -0
|
@@ -0,0 +1,1014 @@
|
|
|
1
|
+
"""Screens for the optional Simjecture terminal UI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from textual import work
|
|
10
|
+
from textual.app import ComposeResult
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.containers import Horizontal, VerticalScroll
|
|
13
|
+
from textual.screen import ModalScreen, Screen
|
|
14
|
+
from textual.widgets import (
|
|
15
|
+
Button,
|
|
16
|
+
Footer,
|
|
17
|
+
Header,
|
|
18
|
+
Input,
|
|
19
|
+
ListItem,
|
|
20
|
+
ListView,
|
|
21
|
+
TextArea,
|
|
22
|
+
)
|
|
23
|
+
from textual.widgets import (
|
|
24
|
+
Label as TextualLabel,
|
|
25
|
+
)
|
|
26
|
+
from textual.widgets import (
|
|
27
|
+
Static as TextualStatic,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from ..mvp_launch import (
|
|
31
|
+
MVPLaunchRequest,
|
|
32
|
+
ResumeError,
|
|
33
|
+
load_supervisor_record,
|
|
34
|
+
materialize_operator_input,
|
|
35
|
+
prepare_resume,
|
|
36
|
+
process_identity_matches,
|
|
37
|
+
request_verified_pause,
|
|
38
|
+
start_managed_campaign,
|
|
39
|
+
validate_campaign_id,
|
|
40
|
+
)
|
|
41
|
+
from ..mvp_monitor import (
|
|
42
|
+
ClaimSummary,
|
|
43
|
+
MVPRunMonitor,
|
|
44
|
+
MVPRunSnapshot,
|
|
45
|
+
RunPhase,
|
|
46
|
+
contained_path,
|
|
47
|
+
discover_recent_runs,
|
|
48
|
+
format_age,
|
|
49
|
+
format_bytes,
|
|
50
|
+
format_duration,
|
|
51
|
+
installed_capability_descriptors,
|
|
52
|
+
list_contained_artifacts,
|
|
53
|
+
)
|
|
54
|
+
from .claim_views import (
|
|
55
|
+
ClaimTreeRow,
|
|
56
|
+
audit_row_label,
|
|
57
|
+
build_hypothesis_tree,
|
|
58
|
+
build_validation_tree,
|
|
59
|
+
hypothesis_row_label,
|
|
60
|
+
scientific_ancestor_id,
|
|
61
|
+
validation_row_label,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if TYPE_CHECKING:
|
|
65
|
+
from .app import ConjectureSolverApp
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Static(TextualStatic):
|
|
69
|
+
"""Static text that preserves scientific notation instead of parsing markup."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
72
|
+
kwargs.setdefault("markup", False)
|
|
73
|
+
super().__init__(*args, **kwargs)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Label(TextualLabel):
|
|
77
|
+
"""Plain label used for hypotheses, claims, paths, and audit records."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
80
|
+
kwargs.setdefault("markup", False)
|
|
81
|
+
super().__init__(*args, **kwargs)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _app(screen: Screen[object]) -> ConjectureSolverApp:
|
|
85
|
+
return screen.app # type: ignore[return-value]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _phase_class(label: str) -> str:
|
|
89
|
+
lowered = label.lower()
|
|
90
|
+
if lowered == "running":
|
|
91
|
+
return "phase-running"
|
|
92
|
+
if lowered == "completed":
|
|
93
|
+
return "phase-completed"
|
|
94
|
+
if "cancel" in lowered:
|
|
95
|
+
return "phase-cancelled"
|
|
96
|
+
if "fail" in lowered:
|
|
97
|
+
return "phase-failed"
|
|
98
|
+
if lowered == "initialized":
|
|
99
|
+
return "phase-initialized"
|
|
100
|
+
if "pause" in lowered:
|
|
101
|
+
return "phase-paused"
|
|
102
|
+
return "phase-incomplete"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _default_campaign_id(now: datetime | None = None) -> str:
|
|
106
|
+
stamp = (now or datetime.now(UTC)).strftime("%Y%m%d-%H%M%S")
|
|
107
|
+
return f"campaign-{stamp}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _claim_detail_body(claim: ClaimSummary) -> str:
|
|
111
|
+
parent = claim.parent_id or "(none)"
|
|
112
|
+
reason = claim.closed_reason or "(open)"
|
|
113
|
+
return "\n".join(
|
|
114
|
+
[
|
|
115
|
+
f"id: {claim.id}",
|
|
116
|
+
f"kind: {claim.kind or 'unknown'}",
|
|
117
|
+
f"relation: {claim.relation or 'unknown'}",
|
|
118
|
+
f"parent: {parent}",
|
|
119
|
+
f"status: {claim.status}",
|
|
120
|
+
f"evidence contracts: {claim.contract_count}",
|
|
121
|
+
f"linked evidence: {claim.evidence_count}",
|
|
122
|
+
f"closed reason: {reason}",
|
|
123
|
+
"",
|
|
124
|
+
claim.statement or "(empty statement)",
|
|
125
|
+
"",
|
|
126
|
+
"Status is read from the durable ledger, not model prose.",
|
|
127
|
+
]
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _sync_list_labels(
|
|
132
|
+
listing: ListView,
|
|
133
|
+
labels: tuple[str, ...],
|
|
134
|
+
previous: tuple[str, ...] | None,
|
|
135
|
+
) -> tuple[str, ...]:
|
|
136
|
+
"""Update claim rows without unmounting an unchanged list every timer tick."""
|
|
137
|
+
|
|
138
|
+
if labels == previous:
|
|
139
|
+
return labels
|
|
140
|
+
|
|
141
|
+
items = tuple(listing.children)
|
|
142
|
+
shared_count = min(len(items), len(labels))
|
|
143
|
+
for index in range(shared_count):
|
|
144
|
+
if (
|
|
145
|
+
previous is None
|
|
146
|
+
or index >= len(previous)
|
|
147
|
+
or previous[index] != labels[index]
|
|
148
|
+
):
|
|
149
|
+
items[index].query_one(Label).update(labels[index])
|
|
150
|
+
|
|
151
|
+
if len(items) > len(labels):
|
|
152
|
+
for item in items[len(labels) :]:
|
|
153
|
+
item.remove()
|
|
154
|
+
elif len(items) < len(labels):
|
|
155
|
+
listing.extend(
|
|
156
|
+
ListItem(Label(label)) for label in labels[len(items) :]
|
|
157
|
+
)
|
|
158
|
+
return labels
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class HomeScreen(Screen[None]):
|
|
162
|
+
BINDINGS = [
|
|
163
|
+
Binding("n", "new_run", "New run", priority=True),
|
|
164
|
+
Binding("enter", "attach", "Attach"),
|
|
165
|
+
Binding("r", "refresh", "Refresh", priority=True),
|
|
166
|
+
Binding("q", "leave", "Quit", priority=True),
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
def __init__(self, scan_roots: list[str | Path] | None = None) -> None:
|
|
170
|
+
super().__init__()
|
|
171
|
+
self.scan_roots = scan_roots
|
|
172
|
+
self.runs: list[tuple[str, Path]] = []
|
|
173
|
+
|
|
174
|
+
def compose(self) -> ComposeResult:
|
|
175
|
+
yield Header(show_clock=True)
|
|
176
|
+
yield Static(
|
|
177
|
+
"Attach to a durable run directory or start a new campaign. "
|
|
178
|
+
"Audit artifacts remain the source of truth.",
|
|
179
|
+
id="home-intro",
|
|
180
|
+
)
|
|
181
|
+
yield Label("Recent runs", classes="panel-title")
|
|
182
|
+
yield ListView(id="recent-runs")
|
|
183
|
+
yield Static("", id="home-status")
|
|
184
|
+
with Horizontal(classes="toolbar"):
|
|
185
|
+
yield Button("New run", id="new-run", variant="primary")
|
|
186
|
+
yield Button("Refresh", id="refresh")
|
|
187
|
+
yield Button("Quit", id="quit")
|
|
188
|
+
yield Footer()
|
|
189
|
+
|
|
190
|
+
def on_mount(self) -> None:
|
|
191
|
+
self.action_refresh()
|
|
192
|
+
|
|
193
|
+
def action_refresh(self) -> None:
|
|
194
|
+
listing = self.query_one("#recent-runs", ListView)
|
|
195
|
+
listing.clear()
|
|
196
|
+
self.runs = []
|
|
197
|
+
found = discover_recent_runs(self.scan_roots, limit=20)
|
|
198
|
+
if not found:
|
|
199
|
+
listing.append(ListItem(Label("(no recent run directories found)")))
|
|
200
|
+
self.query_one("#home-status", Static).update(
|
|
201
|
+
"No mvp_manifest.json files were found under the current artifacts tree."
|
|
202
|
+
)
|
|
203
|
+
return
|
|
204
|
+
for item in found:
|
|
205
|
+
path = Path(item.run_directory)
|
|
206
|
+
hypothesis = (item.hypothesis or "").replace("\n", " ")
|
|
207
|
+
if len(hypothesis) > 72:
|
|
208
|
+
hypothesis = hypothesis[:69] + "..."
|
|
209
|
+
record = load_supervisor_record(path)
|
|
210
|
+
live = bool(record and process_identity_matches(record))
|
|
211
|
+
badge = "live" if live else item.phase.value
|
|
212
|
+
label = f"{path.name} {badge} {hypothesis or '(no hypothesis)'}"
|
|
213
|
+
listing.append(ListItem(Label(label)))
|
|
214
|
+
self.runs.append((label, path))
|
|
215
|
+
self.query_one("#home-status", Static).update(
|
|
216
|
+
f"{len(self.runs)} recent run{'s' if len(self.runs) != 1 else ''} found."
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def action_new_run(self) -> None:
|
|
220
|
+
self.app.push_screen(NewRunScreen())
|
|
221
|
+
|
|
222
|
+
def action_attach(self) -> None:
|
|
223
|
+
listing = self.query_one("#recent-runs", ListView)
|
|
224
|
+
index = listing.index
|
|
225
|
+
if index is None or index >= len(self.runs):
|
|
226
|
+
self.query_one("#home-status", Static).update("Select a run to attach.")
|
|
227
|
+
return
|
|
228
|
+
self.app.push_screen(DashboardScreen(self.runs[index][1]))
|
|
229
|
+
|
|
230
|
+
def action_leave(self) -> None:
|
|
231
|
+
self.app.exit()
|
|
232
|
+
|
|
233
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
234
|
+
if event.button.id == "new-run":
|
|
235
|
+
self.action_new_run()
|
|
236
|
+
elif event.button.id == "refresh":
|
|
237
|
+
self.action_refresh()
|
|
238
|
+
elif event.button.id == "quit":
|
|
239
|
+
self.action_leave()
|
|
240
|
+
|
|
241
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
242
|
+
del event
|
|
243
|
+
self.action_attach()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class NewRunScreen(Screen[None]):
|
|
247
|
+
BINDINGS = [
|
|
248
|
+
Binding("escape", "back", "Back"),
|
|
249
|
+
Binding("ctrl+s", "review", "Review"),
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
def __init__(self) -> None:
|
|
253
|
+
super().__init__()
|
|
254
|
+
self.last_error = ""
|
|
255
|
+
self.campaign_default = _default_campaign_id()
|
|
256
|
+
self.output_follows_campaign = True
|
|
257
|
+
|
|
258
|
+
def compose(self) -> ComposeResult:
|
|
259
|
+
yield Header(show_clock=True)
|
|
260
|
+
with VerticalScroll():
|
|
261
|
+
yield Static("New campaign", classes="panel-title")
|
|
262
|
+
yield Static(
|
|
263
|
+
"The hypothesis is the scientific contract. The instruction is an "
|
|
264
|
+
"optional operational constraint and does not change claim identity.",
|
|
265
|
+
id="form-intro",
|
|
266
|
+
)
|
|
267
|
+
yield Label("Root hypothesis", classes="form-label")
|
|
268
|
+
yield TextArea(id="hypothesis-input")
|
|
269
|
+
yield Label("Optional operational instruction", classes="form-label")
|
|
270
|
+
yield TextArea(id="instruction-input")
|
|
271
|
+
yield Label("Campaign ID", classes="form-label")
|
|
272
|
+
yield Input(value=self.campaign_default, id="campaign-id")
|
|
273
|
+
yield Label("Output directory", classes="form-label")
|
|
274
|
+
yield Input(
|
|
275
|
+
value=str(Path.cwd() / "artifacts" / self.campaign_default),
|
|
276
|
+
id="output-directory",
|
|
277
|
+
)
|
|
278
|
+
yield Label("Maximum wall time (seconds)", classes="form-label")
|
|
279
|
+
yield Input(value="21600", id="max-wall-seconds", type="number")
|
|
280
|
+
yield Label("Maximum command time (seconds)", classes="form-label")
|
|
281
|
+
yield Input(value="600", id="max-command-seconds", type="number")
|
|
282
|
+
yield Label("Workspace limit (MB)", classes="form-label")
|
|
283
|
+
yield Input(value="512", id="max-workspace-mb", type="number")
|
|
284
|
+
yield Label("Memory limit (MB)", classes="form-label")
|
|
285
|
+
yield Input(value="4096", id="max-memory-mb", type="number")
|
|
286
|
+
yield Label("Installed capabilities", classes="form-label")
|
|
287
|
+
yield Static(self._capability_text(), id="capabilities", classes="panel")
|
|
288
|
+
yield Static("", id="form-error", classes="form-error")
|
|
289
|
+
with Horizontal(classes="toolbar"):
|
|
290
|
+
yield Button("Review contract", id="review", variant="primary")
|
|
291
|
+
yield Button("Back", id="back")
|
|
292
|
+
yield Footer()
|
|
293
|
+
|
|
294
|
+
def _capability_text(self) -> str:
|
|
295
|
+
try:
|
|
296
|
+
descriptors = installed_capability_descriptors()
|
|
297
|
+
except Exception as error: # noqa: BLE001 - operator display of discovery failure
|
|
298
|
+
return f"Capability discovery failed: {error}"
|
|
299
|
+
if not descriptors:
|
|
300
|
+
return "No installed capabilities detected. The Python sandbox remains available."
|
|
301
|
+
lines = []
|
|
302
|
+
for item in descriptors:
|
|
303
|
+
name = item.get("name") or "unnamed"
|
|
304
|
+
version = item.get("version") or ""
|
|
305
|
+
description = item.get("description") or ""
|
|
306
|
+
skill = item.get("skill") or ""
|
|
307
|
+
lines.append(f"• {name} {version}".rstrip())
|
|
308
|
+
if description:
|
|
309
|
+
lines.append(f" {description}")
|
|
310
|
+
if skill:
|
|
311
|
+
lines.append(f" skill: {skill}")
|
|
312
|
+
return "\n".join(lines)
|
|
313
|
+
|
|
314
|
+
def action_back(self) -> None:
|
|
315
|
+
self.app.pop_screen()
|
|
316
|
+
|
|
317
|
+
def action_review(self) -> None:
|
|
318
|
+
request = self.validate()
|
|
319
|
+
if request is None:
|
|
320
|
+
return
|
|
321
|
+
self.app.push_screen(ContractReviewScreen(request))
|
|
322
|
+
|
|
323
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
324
|
+
if event.button.id == "review":
|
|
325
|
+
self.action_review()
|
|
326
|
+
elif event.button.id == "back":
|
|
327
|
+
self.action_back()
|
|
328
|
+
|
|
329
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
330
|
+
if event.input.id == "campaign-id" and self.output_follows_campaign:
|
|
331
|
+
campaign = event.value.strip() or self.campaign_default
|
|
332
|
+
self.query_one("#output-directory", Input).value = str(
|
|
333
|
+
Path.cwd() / "artifacts" / campaign
|
|
334
|
+
)
|
|
335
|
+
elif event.input.id == "output-directory":
|
|
336
|
+
campaign = self.query_one("#campaign-id", Input).value.strip()
|
|
337
|
+
expected = str(Path.cwd() / "artifacts" / (campaign or self.campaign_default))
|
|
338
|
+
self.output_follows_campaign = event.value.strip() in {"", expected}
|
|
339
|
+
|
|
340
|
+
def validate(self) -> MVPLaunchRequest | None:
|
|
341
|
+
error_widget = self.query_one("#form-error", Static)
|
|
342
|
+
hypothesis = self.query_one("#hypothesis-input", TextArea).text.strip()
|
|
343
|
+
instruction = self.query_one("#instruction-input", TextArea).text.strip()
|
|
344
|
+
campaign = self.query_one("#campaign-id", Input).value.strip()
|
|
345
|
+
output = self.query_one("#output-directory", Input).value.strip()
|
|
346
|
+
try:
|
|
347
|
+
wall = float(self.query_one("#max-wall-seconds", Input).value)
|
|
348
|
+
command = float(self.query_one("#max-command-seconds", Input).value)
|
|
349
|
+
workspace = int(float(self.query_one("#max-workspace-mb", Input).value))
|
|
350
|
+
memory = int(float(self.query_one("#max-memory-mb", Input).value))
|
|
351
|
+
except ValueError:
|
|
352
|
+
self.last_error = "Resource fields must be positive numbers."
|
|
353
|
+
error_widget.update(self.last_error)
|
|
354
|
+
return None
|
|
355
|
+
if not hypothesis:
|
|
356
|
+
self.last_error = "A root hypothesis is required."
|
|
357
|
+
error_widget.update(self.last_error)
|
|
358
|
+
return None
|
|
359
|
+
if len(hypothesis) < 8:
|
|
360
|
+
self.last_error = "The hypothesis must be at least 8 characters."
|
|
361
|
+
error_widget.update(self.last_error)
|
|
362
|
+
return None
|
|
363
|
+
try:
|
|
364
|
+
campaign = validate_campaign_id(campaign)
|
|
365
|
+
except ValueError as error:
|
|
366
|
+
self.last_error = str(error)
|
|
367
|
+
error_widget.update(self.last_error)
|
|
368
|
+
return None
|
|
369
|
+
if not output:
|
|
370
|
+
self.last_error = "An output directory is required."
|
|
371
|
+
error_widget.update(self.last_error)
|
|
372
|
+
return None
|
|
373
|
+
if min(wall, command, workspace, memory) <= 0:
|
|
374
|
+
self.last_error = "Resource limits must be greater than zero."
|
|
375
|
+
error_widget.update(self.last_error)
|
|
376
|
+
return None
|
|
377
|
+
self.last_error = ""
|
|
378
|
+
error_widget.update("")
|
|
379
|
+
return MVPLaunchRequest(
|
|
380
|
+
hypothesis=hypothesis,
|
|
381
|
+
instruction=instruction or None,
|
|
382
|
+
campaign_id=campaign,
|
|
383
|
+
output_directory=str(Path(output).expanduser()),
|
|
384
|
+
max_wall_seconds=wall,
|
|
385
|
+
max_command_seconds=command,
|
|
386
|
+
max_workspace_mb=workspace,
|
|
387
|
+
max_memory_mb=memory,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class ContractReviewScreen(Screen[None]):
|
|
392
|
+
BINDINGS = [
|
|
393
|
+
Binding("escape", "back", "Back"),
|
|
394
|
+
Binding("enter", "launch", "Launch"),
|
|
395
|
+
]
|
|
396
|
+
|
|
397
|
+
def __init__(self, request: MVPLaunchRequest) -> None:
|
|
398
|
+
super().__init__()
|
|
399
|
+
self.request = request
|
|
400
|
+
self.last_error = ""
|
|
401
|
+
|
|
402
|
+
def compose(self) -> ComposeResult:
|
|
403
|
+
yield Header(show_clock=True)
|
|
404
|
+
yield Static("Contract review", classes="panel-title")
|
|
405
|
+
yield Static(self._body(), id="contract-body", classes="panel")
|
|
406
|
+
yield Static("", id="launch-error", classes="form-error")
|
|
407
|
+
with Horizontal(classes="toolbar"):
|
|
408
|
+
yield Button("Launch campaign", id="launch", variant="primary")
|
|
409
|
+
yield Button("Back", id="back")
|
|
410
|
+
yield Footer()
|
|
411
|
+
|
|
412
|
+
def _body(self) -> str:
|
|
413
|
+
request = self.request
|
|
414
|
+
instruction = request.instruction or "(none)"
|
|
415
|
+
return "\n".join(
|
|
416
|
+
[
|
|
417
|
+
f"Campaign: {request.campaign_id}",
|
|
418
|
+
f"Output: {Path(request.output_directory).expanduser()}",
|
|
419
|
+
(
|
|
420
|
+
f"Envelope: wall {format_duration(request.max_wall_seconds)}, "
|
|
421
|
+
f"command {int(request.max_command_seconds)} s, "
|
|
422
|
+
f"workspace {request.max_workspace_mb} MB, "
|
|
423
|
+
f"memory {request.max_memory_mb} MB"
|
|
424
|
+
),
|
|
425
|
+
"",
|
|
426
|
+
"Root hypothesis",
|
|
427
|
+
request.hypothesis,
|
|
428
|
+
"",
|
|
429
|
+
"Operational instruction",
|
|
430
|
+
instruction,
|
|
431
|
+
"",
|
|
432
|
+
"Launch will write operator_input/hypothesis.txt and invoke "
|
|
433
|
+
"--hypothesis-file. No shell string is constructed. The durable "
|
|
434
|
+
"manifest, transcript, ledger, and report remain authoritative.",
|
|
435
|
+
]
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
def action_back(self) -> None:
|
|
439
|
+
self.app.pop_screen()
|
|
440
|
+
|
|
441
|
+
def action_launch(self) -> None:
|
|
442
|
+
error_widget = self.query_one("#launch-error", Static)
|
|
443
|
+
try:
|
|
444
|
+
plan = materialize_operator_input(self.request)
|
|
445
|
+
campaign = start_managed_campaign(plan)
|
|
446
|
+
except Exception as error: # noqa: BLE001 - surface launch failure to operator
|
|
447
|
+
self.last_error = f"Launch failed: {error}"
|
|
448
|
+
error_widget.update(self.last_error)
|
|
449
|
+
return
|
|
450
|
+
_app(self).attach_managed(campaign)
|
|
451
|
+
while len(self.app.screen_stack) > 1:
|
|
452
|
+
self.app.pop_screen()
|
|
453
|
+
self.app.push_screen(DashboardScreen(plan.output_directory))
|
|
454
|
+
|
|
455
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
456
|
+
if event.button.id == "launch":
|
|
457
|
+
self.action_launch()
|
|
458
|
+
elif event.button.id == "back":
|
|
459
|
+
self.action_back()
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class DashboardScreen(Screen[None]):
|
|
463
|
+
BINDINGS = [
|
|
464
|
+
Binding("c", "cancel", "Cancel", priority=True),
|
|
465
|
+
Binding("p", "pause", "Pause", priority=True),
|
|
466
|
+
Binding("r", "resume", "Resume", priority=True),
|
|
467
|
+
Binding("l", "logs", "Logs", priority=True),
|
|
468
|
+
Binding("a", "artifacts", "Artifacts", priority=True),
|
|
469
|
+
Binding("v", "audit_ledger", "Audit ledger", priority=True),
|
|
470
|
+
Binding("enter", "claim_detail", "Claim"),
|
|
471
|
+
Binding("q", "leave", "Leave UI", priority=True),
|
|
472
|
+
]
|
|
473
|
+
|
|
474
|
+
def __init__(self, run_directory: str | Path) -> None:
|
|
475
|
+
super().__init__()
|
|
476
|
+
self.run_directory = Path(run_directory).expanduser().resolve()
|
|
477
|
+
self.monitor = MVPRunMonitor(self.run_directory)
|
|
478
|
+
self.snapshot: MVPRunSnapshot | None = None
|
|
479
|
+
self.last_cancel_message = ""
|
|
480
|
+
self.cancel_in_progress = False
|
|
481
|
+
self.last_control_message = ""
|
|
482
|
+
self.selected_claim_id: str | None = None
|
|
483
|
+
self.selected_hypothesis_id: str | None = None
|
|
484
|
+
self.selected_validation_id: str | None = None
|
|
485
|
+
self.hypothesis_rows: tuple[ClaimTreeRow, ...] = ()
|
|
486
|
+
self.validation_rows: tuple[ClaimTreeRow, ...] = ()
|
|
487
|
+
self._hypothesis_labels: tuple[str, ...] | None = None
|
|
488
|
+
self._validation_labels: tuple[str, ...] | None = None
|
|
489
|
+
self._rendering_claim_views = False
|
|
490
|
+
|
|
491
|
+
def compose(self) -> ComposeResult:
|
|
492
|
+
yield Header(show_clock=True)
|
|
493
|
+
with VerticalScroll():
|
|
494
|
+
yield Static("", id="status-line")
|
|
495
|
+
yield Static("", id="clock-line")
|
|
496
|
+
yield Static("", id="model-line")
|
|
497
|
+
yield Static("", id="usage-line")
|
|
498
|
+
yield Static("Hypothesis tree", classes="panel-title")
|
|
499
|
+
yield ListView(id="hypothesis-tree")
|
|
500
|
+
yield Static(
|
|
501
|
+
"Validation claims for selected hypothesis",
|
|
502
|
+
id="validation-title",
|
|
503
|
+
classes="panel-title",
|
|
504
|
+
)
|
|
505
|
+
yield ListView(id="validation-claims")
|
|
506
|
+
yield Static("Current action", classes="panel-title")
|
|
507
|
+
yield Static("", id="current-action", classes="panel")
|
|
508
|
+
yield Static("Recent activity", classes="panel-title")
|
|
509
|
+
yield Static("", id="activity", classes="panel")
|
|
510
|
+
yield Static("Terminal report", classes="panel-title")
|
|
511
|
+
yield Static("", id="final-answer", classes="panel")
|
|
512
|
+
yield Footer()
|
|
513
|
+
|
|
514
|
+
def on_mount(self) -> None:
|
|
515
|
+
self.refresh_snapshot()
|
|
516
|
+
self.set_interval(0.5, self.refresh_snapshot)
|
|
517
|
+
|
|
518
|
+
def refresh_snapshot(self) -> None:
|
|
519
|
+
try:
|
|
520
|
+
snapshot = self.monitor.snapshot()
|
|
521
|
+
except (FileNotFoundError, NotADirectoryError, OSError) as error:
|
|
522
|
+
self.query_one("#status-line", Static).update(f"Cannot read run directory: {error}")
|
|
523
|
+
return
|
|
524
|
+
self.snapshot = snapshot
|
|
525
|
+
live = _app(self).live_identity_for(self.run_directory)
|
|
526
|
+
phase_text = "RUNNING" if live else snapshot.phase_label
|
|
527
|
+
campaign = snapshot.identity.campaign_id or self.run_directory.name
|
|
528
|
+
status = self.query_one("#status-line", Static)
|
|
529
|
+
status.update(f"Campaign: {campaign} {phase_text}")
|
|
530
|
+
status.set_class(False, "phase-running")
|
|
531
|
+
status.set_classes(_phase_class(phase_text))
|
|
532
|
+
elapsed = format_duration(snapshot.elapsed_wall_seconds)
|
|
533
|
+
budget = format_duration(snapshot.configured_wall_seconds)
|
|
534
|
+
estimate = "~" if snapshot.elapsed_is_estimate else ""
|
|
535
|
+
heartbeat = "heartbeat --"
|
|
536
|
+
if snapshot.latest_heartbeat is not None:
|
|
537
|
+
heartbeat = f"heartbeat {format_age(snapshot.latest_heartbeat.age_seconds)}"
|
|
538
|
+
self.query_one("#clock-line", Static).update(
|
|
539
|
+
f"elapsed {estimate}{elapsed} / {budget} {heartbeat}"
|
|
540
|
+
)
|
|
541
|
+
model = snapshot.last_model or "-"
|
|
542
|
+
capability = snapshot.last_capability or "-"
|
|
543
|
+
if snapshot.current_action and snapshot.current_action.capability:
|
|
544
|
+
capability = snapshot.current_action.capability
|
|
545
|
+
self.query_one("#model-line", Static).update(
|
|
546
|
+
f"model: {model} capability: {capability}"
|
|
547
|
+
)
|
|
548
|
+
usage = snapshot.token_usage.label
|
|
549
|
+
if snapshot.pending_control:
|
|
550
|
+
usage = f"{usage} pending {snapshot.pending_control}"
|
|
551
|
+
self.query_one("#usage-line", Static).update(usage)
|
|
552
|
+
self._render_claim_views(snapshot)
|
|
553
|
+
self.query_one("#current-action", Static).update(self._current_action_text(snapshot, live))
|
|
554
|
+
events = snapshot.recent_events[-8:]
|
|
555
|
+
activity = "\n".join(event.summary for event in events) or "(no transcript events yet)"
|
|
556
|
+
self.query_one("#activity", Static).update(activity)
|
|
557
|
+
if snapshot.report is None:
|
|
558
|
+
self.query_one("#final-answer", Static).update(
|
|
559
|
+
"No terminal report yet. Incomplete is not a scientific result."
|
|
560
|
+
)
|
|
561
|
+
else:
|
|
562
|
+
notes = "\n".join(snapshot.report.finish_claim_notes)
|
|
563
|
+
answer = snapshot.report.final_answer.strip() or "(empty final answer)"
|
|
564
|
+
body = f"status: {snapshot.report.status}\n{answer}"
|
|
565
|
+
if notes:
|
|
566
|
+
body += f"\n\nFinish notes\n{notes}"
|
|
567
|
+
self.query_one("#final-answer", Static).update(body)
|
|
568
|
+
|
|
569
|
+
def _render_claim_views(self, snapshot: MVPRunSnapshot) -> None:
|
|
570
|
+
self._rendering_claim_views = True
|
|
571
|
+
try:
|
|
572
|
+
self.hypothesis_rows = build_hypothesis_tree(snapshot.claims)
|
|
573
|
+
selected = self.selected_hypothesis_id
|
|
574
|
+
available = {row.claim.id for row in self.hypothesis_rows}
|
|
575
|
+
if selected not in available:
|
|
576
|
+
active = next((claim for claim in snapshot.claims if claim.active), None)
|
|
577
|
+
selected = (
|
|
578
|
+
scientific_ancestor_id(snapshot.claims, active.id)
|
|
579
|
+
if active is not None
|
|
580
|
+
else None
|
|
581
|
+
)
|
|
582
|
+
if selected not in available:
|
|
583
|
+
selected = self.hypothesis_rows[0].claim.id if self.hypothesis_rows else None
|
|
584
|
+
self.selected_hypothesis_id = selected
|
|
585
|
+
|
|
586
|
+
tree = self.query_one("#hypothesis-tree", ListView)
|
|
587
|
+
if not self.hypothesis_rows:
|
|
588
|
+
hypothesis = snapshot.identity.hypothesis or "hypothesis not yet recorded"
|
|
589
|
+
labels = (f"(claim ledger not initialized) {hypothesis}",)
|
|
590
|
+
selected_index = 0
|
|
591
|
+
else:
|
|
592
|
+
labels = tuple(
|
|
593
|
+
hypothesis_row_label(row) for row in self.hypothesis_rows
|
|
594
|
+
)
|
|
595
|
+
selected_index = next(
|
|
596
|
+
(
|
|
597
|
+
index
|
|
598
|
+
for index, row in enumerate(self.hypothesis_rows)
|
|
599
|
+
if row.claim.id == selected
|
|
600
|
+
),
|
|
601
|
+
0,
|
|
602
|
+
)
|
|
603
|
+
self._hypothesis_labels = _sync_list_labels(
|
|
604
|
+
tree,
|
|
605
|
+
labels,
|
|
606
|
+
self._hypothesis_labels,
|
|
607
|
+
)
|
|
608
|
+
if tree.index != selected_index:
|
|
609
|
+
tree.index = selected_index
|
|
610
|
+
self._render_validation_claims(snapshot)
|
|
611
|
+
finally:
|
|
612
|
+
self._rendering_claim_views = False
|
|
613
|
+
|
|
614
|
+
def _render_validation_claims(self, snapshot: MVPRunSnapshot) -> None:
|
|
615
|
+
listing = self.query_one("#validation-claims", ListView)
|
|
616
|
+
selected = self.selected_hypothesis_id
|
|
617
|
+
title = "Validation claims for selected hypothesis"
|
|
618
|
+
if selected is None:
|
|
619
|
+
self.validation_rows = ()
|
|
620
|
+
labels = ("(select a hypothesis)",)
|
|
621
|
+
selected_index = 0
|
|
622
|
+
else:
|
|
623
|
+
title += f" · {selected}"
|
|
624
|
+
self.validation_rows = build_validation_tree(snapshot.claims, selected)
|
|
625
|
+
available = {row.claim.id for row in self.validation_rows}
|
|
626
|
+
if self.selected_validation_id not in available:
|
|
627
|
+
self.selected_validation_id = (
|
|
628
|
+
self.validation_rows[0].claim.id if self.validation_rows else None
|
|
629
|
+
)
|
|
630
|
+
if not self.validation_rows:
|
|
631
|
+
labels = ("(no linked validation claims)",)
|
|
632
|
+
selected_index = 0
|
|
633
|
+
else:
|
|
634
|
+
labels = tuple(
|
|
635
|
+
validation_row_label(row) for row in self.validation_rows
|
|
636
|
+
)
|
|
637
|
+
selected_index = next(
|
|
638
|
+
(
|
|
639
|
+
index
|
|
640
|
+
for index, row in enumerate(self.validation_rows)
|
|
641
|
+
if row.claim.id == self.selected_validation_id
|
|
642
|
+
),
|
|
643
|
+
0,
|
|
644
|
+
)
|
|
645
|
+
self._validation_labels = _sync_list_labels(
|
|
646
|
+
listing,
|
|
647
|
+
labels,
|
|
648
|
+
self._validation_labels,
|
|
649
|
+
)
|
|
650
|
+
if listing.index != selected_index:
|
|
651
|
+
listing.index = selected_index
|
|
652
|
+
self.query_one("#validation-title", Static).update(title)
|
|
653
|
+
|
|
654
|
+
def _current_action_text(self, snapshot: MVPRunSnapshot, live: bool) -> str:
|
|
655
|
+
if snapshot.current_action is None:
|
|
656
|
+
if snapshot.phase in {
|
|
657
|
+
RunPhase.COMPLETED,
|
|
658
|
+
RunPhase.CANCELLED,
|
|
659
|
+
RunPhase.PROVIDER_FAILED,
|
|
660
|
+
RunPhase.BUDGET_EXHAUSTED,
|
|
661
|
+
}:
|
|
662
|
+
return "Campaign has a terminal report."
|
|
663
|
+
if snapshot.phase is RunPhase.PAUSED:
|
|
664
|
+
return "Paused at an action boundary. Press r to resume the same contract."
|
|
665
|
+
if live:
|
|
666
|
+
return "Waiting for the next durable action."
|
|
667
|
+
return "No pending action. Process liveness is unknown unless this UI launched it."
|
|
668
|
+
action = snapshot.current_action
|
|
669
|
+
details = [f"iteration={action.iteration}"]
|
|
670
|
+
if action.stage:
|
|
671
|
+
details.append(f"stage={action.stage}")
|
|
672
|
+
if snapshot.latest_heartbeat and snapshot.latest_heartbeat.elapsed_wall_seconds is not None:
|
|
673
|
+
details.append(f"elapsed={int(snapshot.latest_heartbeat.elapsed_wall_seconds)} s")
|
|
674
|
+
details.append(f"workspace={format_bytes(snapshot.workspace_bytes)}")
|
|
675
|
+
return f"{action.description}\n" + ", ".join(details)
|
|
676
|
+
|
|
677
|
+
def action_leave(self) -> None:
|
|
678
|
+
_app(self).detach_managed()
|
|
679
|
+
if len(self.app.screen_stack) > 1:
|
|
680
|
+
self.app.pop_screen()
|
|
681
|
+
else:
|
|
682
|
+
self.app.exit()
|
|
683
|
+
|
|
684
|
+
def action_cancel(self) -> None:
|
|
685
|
+
if self.snapshot is not None and self.snapshot.phase in {
|
|
686
|
+
RunPhase.COMPLETED,
|
|
687
|
+
RunPhase.CANCELLED,
|
|
688
|
+
RunPhase.PROVIDER_FAILED,
|
|
689
|
+
RunPhase.BUDGET_EXHAUSTED,
|
|
690
|
+
}:
|
|
691
|
+
self.last_cancel_message = "campaign already has a terminal report"
|
|
692
|
+
self.notify(self.last_cancel_message)
|
|
693
|
+
return
|
|
694
|
+
if self.cancel_in_progress:
|
|
695
|
+
self.notify("cancellation is already in progress")
|
|
696
|
+
return
|
|
697
|
+
if not _app(self).live_identity_for(self.run_directory):
|
|
698
|
+
self.last_cancel_message = (
|
|
699
|
+
"cancel is only offered for a process this UI launched or a verified "
|
|
700
|
+
"supervisor record; this attach is read-only"
|
|
701
|
+
)
|
|
702
|
+
self.notify(self.last_cancel_message)
|
|
703
|
+
return
|
|
704
|
+
self.cancel_in_progress = True
|
|
705
|
+
self.last_cancel_message = "cancellation requested"
|
|
706
|
+
self.notify(self.last_cancel_message)
|
|
707
|
+
self._cancel_campaign()
|
|
708
|
+
|
|
709
|
+
@work(thread=True, exclusive=True, group="campaign-cancel")
|
|
710
|
+
def _cancel_campaign(self) -> None:
|
|
711
|
+
try:
|
|
712
|
+
message = _app(self).cancel_run(self.run_directory)
|
|
713
|
+
except Exception as error:
|
|
714
|
+
message = f"cancellation failed: {type(error).__name__}: {error}"
|
|
715
|
+
self.app.call_from_thread(self._finish_cancel, message)
|
|
716
|
+
|
|
717
|
+
def _finish_cancel(self, message: str) -> None:
|
|
718
|
+
self.cancel_in_progress = False
|
|
719
|
+
self.last_cancel_message = message
|
|
720
|
+
self.notify(message)
|
|
721
|
+
self.refresh_snapshot()
|
|
722
|
+
|
|
723
|
+
def action_pause(self) -> None:
|
|
724
|
+
if self.snapshot is not None and self.snapshot.phase in {
|
|
725
|
+
RunPhase.COMPLETED,
|
|
726
|
+
RunPhase.CANCELLED,
|
|
727
|
+
RunPhase.PROVIDER_FAILED,
|
|
728
|
+
RunPhase.BUDGET_EXHAUSTED,
|
|
729
|
+
RunPhase.PAUSED,
|
|
730
|
+
}:
|
|
731
|
+
self.last_control_message = "campaign is not running"
|
|
732
|
+
self.notify(self.last_control_message)
|
|
733
|
+
return
|
|
734
|
+
self.last_control_message = request_verified_pause(
|
|
735
|
+
self.run_directory,
|
|
736
|
+
source="tui",
|
|
737
|
+
)
|
|
738
|
+
self.notify(self.last_control_message)
|
|
739
|
+
self.refresh_snapshot()
|
|
740
|
+
|
|
741
|
+
def action_resume(self) -> None:
|
|
742
|
+
try:
|
|
743
|
+
plan = prepare_resume(self.run_directory)
|
|
744
|
+
except ResumeError as error:
|
|
745
|
+
self.last_control_message = str(error)
|
|
746
|
+
self.notify(self.last_control_message)
|
|
747
|
+
return
|
|
748
|
+
try:
|
|
749
|
+
campaign = start_managed_campaign(plan)
|
|
750
|
+
except Exception as error: # noqa: BLE001 - surface resume failure
|
|
751
|
+
self.last_control_message = f"resume failed: {error}"
|
|
752
|
+
self.notify(self.last_control_message)
|
|
753
|
+
return
|
|
754
|
+
_app(self).attach_managed(campaign)
|
|
755
|
+
self.last_control_message = f"resumed pid={campaign.identity.pid}"
|
|
756
|
+
self.notify(self.last_control_message)
|
|
757
|
+
self.monitor = MVPRunMonitor(self.run_directory)
|
|
758
|
+
self.refresh_snapshot()
|
|
759
|
+
|
|
760
|
+
def action_logs(self) -> None:
|
|
761
|
+
self.app.push_screen(LogsScreen(self.run_directory))
|
|
762
|
+
|
|
763
|
+
def action_artifacts(self) -> None:
|
|
764
|
+
self.app.push_screen(ArtifactsScreen(self.run_directory))
|
|
765
|
+
|
|
766
|
+
def action_audit_ledger(self) -> None:
|
|
767
|
+
claims = self.snapshot.claims if self.snapshot is not None else ()
|
|
768
|
+
self.app.push_screen(AuditLedgerScreen(claims))
|
|
769
|
+
|
|
770
|
+
def action_claim_detail(self) -> None:
|
|
771
|
+
focused = self.focused
|
|
772
|
+
if not isinstance(focused, ListView):
|
|
773
|
+
return
|
|
774
|
+
index = focused.index
|
|
775
|
+
if index is None:
|
|
776
|
+
return
|
|
777
|
+
claim = self._claim_at(focused.id, index)
|
|
778
|
+
if claim is None:
|
|
779
|
+
return
|
|
780
|
+
self._open_claim(claim)
|
|
781
|
+
|
|
782
|
+
def _claim_at(self, listing_id: str | None, index: int) -> ClaimSummary | None:
|
|
783
|
+
if listing_id == "hypothesis-tree" and index < len(self.hypothesis_rows):
|
|
784
|
+
return self.hypothesis_rows[index].claim
|
|
785
|
+
if listing_id == "validation-claims" and index < len(self.validation_rows):
|
|
786
|
+
return self.validation_rows[index].claim
|
|
787
|
+
return None
|
|
788
|
+
|
|
789
|
+
def _open_claim(self, claim: ClaimSummary) -> None:
|
|
790
|
+
self.selected_claim_id = claim.id
|
|
791
|
+
self.app.push_screen(ClaimDetailScreen(claim))
|
|
792
|
+
|
|
793
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
794
|
+
claim = self._claim_at(event.list_view.id, event.index)
|
|
795
|
+
if claim is not None:
|
|
796
|
+
self._open_claim(claim)
|
|
797
|
+
|
|
798
|
+
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
|
799
|
+
if self._rendering_claim_views or event.list_view.index is None:
|
|
800
|
+
return
|
|
801
|
+
index = event.list_view.index
|
|
802
|
+
if event.list_view.id == "hypothesis-tree" and index < len(
|
|
803
|
+
self.hypothesis_rows
|
|
804
|
+
):
|
|
805
|
+
selected = self.hypothesis_rows[index].claim.id
|
|
806
|
+
if selected != self.selected_hypothesis_id:
|
|
807
|
+
self.selected_hypothesis_id = selected
|
|
808
|
+
self.selected_validation_id = None
|
|
809
|
+
if self.snapshot is not None:
|
|
810
|
+
self._render_validation_claims(self.snapshot)
|
|
811
|
+
elif event.list_view.id == "validation-claims" and index < len(
|
|
812
|
+
self.validation_rows
|
|
813
|
+
):
|
|
814
|
+
self.selected_validation_id = self.validation_rows[index].claim.id
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
class HelpScreen(ModalScreen[None]):
|
|
818
|
+
BINDINGS = [Binding("escape", "close", "Close"), Binding("q", "close", "Close")]
|
|
819
|
+
|
|
820
|
+
def compose(self) -> ComposeResult:
|
|
821
|
+
yield Static("Keyboard help", classes="panel-title")
|
|
822
|
+
yield Static(
|
|
823
|
+
"\n".join(
|
|
824
|
+
[
|
|
825
|
+
"The terminal UI is a projection of durable records.",
|
|
826
|
+
"mvp_report.json, hypothesis_ledger.json, and transcript.jsonl "
|
|
827
|
+
"remain authoritative.",
|
|
828
|
+
"There is no fabricated scientific completion percentage.",
|
|
829
|
+
"",
|
|
830
|
+
"[n] new run [enter] attach or open claim",
|
|
831
|
+
"[c] cancel verified process; writes a cancelled report",
|
|
832
|
+
"[p] pause next action boundary; does not SIGSTOP",
|
|
833
|
+
"[r] resume repeat the stored launch contract",
|
|
834
|
+
"[l] controller log [a] artifacts inside the run directory",
|
|
835
|
+
"[v] audit ledger every scientific and validation claim",
|
|
836
|
+
"[q] leave UI detaches; does not cancel or pause",
|
|
837
|
+
"[?] help [esc] close this panel",
|
|
838
|
+
"",
|
|
839
|
+
"The hypothesis tree contains scientific claims only. Instrument,",
|
|
840
|
+
"diagnostic, and control claims follow the selected hypothesis.",
|
|
841
|
+
"status, watch, pause, and resume work without this optional extra.",
|
|
842
|
+
"Token counts come from provider usage on assistant transcript rows.",
|
|
843
|
+
"Install the extra with: uv sync --extra tui",
|
|
844
|
+
]
|
|
845
|
+
),
|
|
846
|
+
id="help-body",
|
|
847
|
+
classes="panel",
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
def action_close(self) -> None:
|
|
851
|
+
self.app.pop_screen()
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
class ClaimDetailScreen(ModalScreen[None]):
|
|
855
|
+
BINDINGS = [Binding("escape", "close", "Close"), Binding("q", "close", "Close")]
|
|
856
|
+
|
|
857
|
+
def __init__(self, claim: ClaimSummary) -> None:
|
|
858
|
+
super().__init__()
|
|
859
|
+
self.claim = claim
|
|
860
|
+
|
|
861
|
+
def compose(self) -> ComposeResult:
|
|
862
|
+
claim_type = "Hypothesis" if self.claim.kind == "scientific" else "Validation claim"
|
|
863
|
+
yield Static(f"{claim_type} · {self.claim.id}", classes="panel-title")
|
|
864
|
+
yield Static(
|
|
865
|
+
_claim_detail_body(self.claim),
|
|
866
|
+
id="claim-detail",
|
|
867
|
+
classes="panel",
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
def action_close(self) -> None:
|
|
871
|
+
self.app.pop_screen()
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
class AuditLedgerScreen(ModalScreen[None]):
|
|
875
|
+
"""Complete claim ledger retained alongside the human-first projections."""
|
|
876
|
+
|
|
877
|
+
BINDINGS = [
|
|
878
|
+
Binding("escape", "close", "Close"),
|
|
879
|
+
Binding("q", "close", "Close"),
|
|
880
|
+
Binding("enter", "preview", "Inspect"),
|
|
881
|
+
]
|
|
882
|
+
|
|
883
|
+
def __init__(self, claims: tuple[ClaimSummary, ...]) -> None:
|
|
884
|
+
super().__init__()
|
|
885
|
+
self.claims = claims
|
|
886
|
+
|
|
887
|
+
def compose(self) -> ComposeResult:
|
|
888
|
+
yield Static("Complete audit ledger", classes="panel-title")
|
|
889
|
+
items = (
|
|
890
|
+
[ListItem(Label(audit_row_label(claim))) for claim in self.claims]
|
|
891
|
+
if self.claims
|
|
892
|
+
else [ListItem(Label("(no claims recorded)"))]
|
|
893
|
+
)
|
|
894
|
+
yield ListView(*items, id="audit-claims")
|
|
895
|
+
yield Static(
|
|
896
|
+
"Select a claim to inspect its typed relation and evidence counts.",
|
|
897
|
+
id="audit-claim-detail",
|
|
898
|
+
classes="panel",
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
def action_preview(self) -> None:
|
|
902
|
+
listing = self.query_one("#audit-claims", ListView)
|
|
903
|
+
index = listing.index
|
|
904
|
+
if index is None or index >= len(self.claims):
|
|
905
|
+
return
|
|
906
|
+
self.query_one("#audit-claim-detail", Static).update(
|
|
907
|
+
_claim_detail_body(self.claims[index])
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
def action_close(self) -> None:
|
|
911
|
+
self.app.pop_screen()
|
|
912
|
+
|
|
913
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
914
|
+
del event
|
|
915
|
+
self.action_preview()
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
class LogsScreen(ModalScreen[None]):
|
|
919
|
+
BINDINGS = [Binding("escape", "close", "Close"), Binding("q", "close", "Close")]
|
|
920
|
+
|
|
921
|
+
def __init__(self, run_directory: Path) -> None:
|
|
922
|
+
super().__init__()
|
|
923
|
+
self.run_directory = run_directory
|
|
924
|
+
|
|
925
|
+
def compose(self) -> ComposeResult:
|
|
926
|
+
yield Static("Controller log", classes="panel-title")
|
|
927
|
+
yield Static(self._load(), id="log-body", classes="panel")
|
|
928
|
+
|
|
929
|
+
def _load(self) -> str:
|
|
930
|
+
path = contained_path(self.run_directory, "controller.log")
|
|
931
|
+
if path is None or not path.is_file():
|
|
932
|
+
return (
|
|
933
|
+
"No controller.log in this run directory. The TUI writes this file "
|
|
934
|
+
"only for campaigns it launches."
|
|
935
|
+
)
|
|
936
|
+
try:
|
|
937
|
+
data = path.read_bytes()[-16_384:]
|
|
938
|
+
except OSError as error:
|
|
939
|
+
return f"Unable to read controller.log: {error}"
|
|
940
|
+
return data.decode("utf-8", errors="replace") or "(empty log)"
|
|
941
|
+
|
|
942
|
+
def action_close(self) -> None:
|
|
943
|
+
self.app.pop_screen()
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
class ArtifactsScreen(ModalScreen[None]):
|
|
947
|
+
BINDINGS = [
|
|
948
|
+
Binding("escape", "close", "Close"),
|
|
949
|
+
Binding("q", "close", "Close"),
|
|
950
|
+
Binding("enter", "preview", "Preview"),
|
|
951
|
+
]
|
|
952
|
+
|
|
953
|
+
def __init__(self, run_directory: Path) -> None:
|
|
954
|
+
super().__init__()
|
|
955
|
+
self.run_directory = run_directory
|
|
956
|
+
self.entries = list_contained_artifacts(run_directory)
|
|
957
|
+
|
|
958
|
+
def compose(self) -> ComposeResult:
|
|
959
|
+
yield Static("Run artifacts (contained paths only)", classes="panel-title")
|
|
960
|
+
items = (
|
|
961
|
+
[
|
|
962
|
+
ListItem(
|
|
963
|
+
Label(
|
|
964
|
+
f"{entry.relative_path} {format_bytes(entry.bytes)} "
|
|
965
|
+
f"{entry.kind}"
|
|
966
|
+
)
|
|
967
|
+
)
|
|
968
|
+
for entry in self.entries
|
|
969
|
+
]
|
|
970
|
+
if self.entries
|
|
971
|
+
else [ListItem(Label("(no files)"))]
|
|
972
|
+
)
|
|
973
|
+
yield ListView(*items, id="artifact-list")
|
|
974
|
+
yield Static(
|
|
975
|
+
"Select a contained file to preview text.",
|
|
976
|
+
id="artifact-preview",
|
|
977
|
+
classes="panel",
|
|
978
|
+
)
|
|
979
|
+
|
|
980
|
+
def action_preview(self) -> None:
|
|
981
|
+
listing = self.query_one("#artifact-list", ListView)
|
|
982
|
+
index = listing.index
|
|
983
|
+
if index is None or index >= len(self.entries):
|
|
984
|
+
return
|
|
985
|
+
entry = self.entries[index]
|
|
986
|
+
preview = self.query_one("#artifact-preview", Static)
|
|
987
|
+
path = contained_path(self.run_directory, entry.relative_path)
|
|
988
|
+
if path is None:
|
|
989
|
+
preview.update("Refusing to open a path outside the run directory.")
|
|
990
|
+
return
|
|
991
|
+
if entry.kind == "symlink" or path.is_symlink():
|
|
992
|
+
preview.update("Symlink; content is not opened.")
|
|
993
|
+
return
|
|
994
|
+
if not path.is_file():
|
|
995
|
+
preview.update("Not a regular file.")
|
|
996
|
+
return
|
|
997
|
+
if entry.bytes > 32_768:
|
|
998
|
+
preview.update(
|
|
999
|
+
f"{entry.relative_path}: {format_bytes(entry.bytes)} (too large to preview)"
|
|
1000
|
+
)
|
|
1001
|
+
return
|
|
1002
|
+
try:
|
|
1003
|
+
text = path.read_text(errors="replace")
|
|
1004
|
+
except OSError as error:
|
|
1005
|
+
preview.update(f"Unable to read file: {error}")
|
|
1006
|
+
return
|
|
1007
|
+
preview.update(text[:4000] if text else "(empty file)")
|
|
1008
|
+
|
|
1009
|
+
def action_close(self) -> None:
|
|
1010
|
+
self.app.pop_screen()
|
|
1011
|
+
|
|
1012
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
1013
|
+
del event
|
|
1014
|
+
self.action_preview()
|