command-gate 0.2.4__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.
- cgate/__init__.py +26 -0
- cgate/__main__.py +8 -0
- cgate/_version.py +24 -0
- cgate/cli/__init__.py +1 -0
- cgate/cli/_console.py +17 -0
- cgate/cli/connections.py +212 -0
- cgate/cli/history.py +191 -0
- cgate/cli/install.py +182 -0
- cgate/cli/main.py +115 -0
- cgate/cli/mcp.py +197 -0
- cgate/cli/uninstall.py +403 -0
- cgate/cli/update.py +538 -0
- cgate/cli/watch.py +20 -0
- cgate/connections/__init__.py +1 -0
- cgate/connections/auth.py +93 -0
- cgate/connections/detect.py +78 -0
- cgate/connections/store.py +88 -0
- cgate/core/__init__.py +1 -0
- cgate/core/path_env.py +218 -0
- cgate/core/paths.py +35 -0
- cgate/core/update_log.py +36 -0
- cgate/db/__init__.py +1 -0
- cgate/db/batches.py +111 -0
- cgate/db/commands.py +191 -0
- cgate/db/connection.py +104 -0
- cgate/db/mode.py +74 -0
- cgate/db/rows.py +99 -0
- cgate/db/schema.py +54 -0
- cgate/db/server_settings.py +105 -0
- cgate/db/types.py +77 -0
- cgate/executor/__init__.py +7 -0
- cgate/executor/base.py +71 -0
- cgate/executor/selector.py +61 -0
- cgate/executor/ssh.py +157 -0
- cgate/executor/winrm.py +129 -0
- cgate/helper/__init__.py +10 -0
- cgate/helper/__main__.py +112 -0
- cgate/helper/waiter.py +123 -0
- cgate/mcp_installer.py +161 -0
- cgate/mcp_server/__init__.py +6 -0
- cgate/mcp_server/__main__.py +6 -0
- cgate/mcp_server/auto_resolution.py +80 -0
- cgate/mcp_server/server.py +271 -0
- cgate/mcp_server/tools.py +351 -0
- cgate/risk.py +129 -0
- cgate/update.py +713 -0
- cgate/watch/__init__.py +7 -0
- cgate/watch/app.py +560 -0
- cgate/watch/approval.py +237 -0
- cgate/watch/command_detail_modal.py +68 -0
- cgate/watch/history_modal.py +242 -0
- cgate/watch/mode_modal.py +110 -0
- cgate/watch/queue.py +106 -0
- cgate/watch/render.py +156 -0
- cgate/watch/server_settings_modal.py +179 -0
- cgate/watch/session.py +40 -0
- cgate/watch/theme.py +32 -0
- cgate/watch/widgets.py +35 -0
- command_gate-0.2.4.dist-info/METADATA +204 -0
- command_gate-0.2.4.dist-info/RECORD +63 -0
- command_gate-0.2.4.dist-info/WHEEL +4 -0
- command_gate-0.2.4.dist-info/entry_points.txt +2 -0
- command_gate-0.2.4.dist-info/licenses/LICENSE +21 -0
cgate/watch/__init__.py
ADDED
cgate/watch/app.py
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
"""Full-screen Textual dashboard for `cgate watch` (spec §Approval queue).
|
|
2
|
+
|
|
3
|
+
Replaces the earlier linear y/n/a/r prompt with a live view: a sidebar shows
|
|
4
|
+
the FIFO batch queue, the main panel shows the active batch's commands, and
|
|
5
|
+
approvals run in a worker thread so the UI stays responsive during exec.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import sqlite3
|
|
12
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
13
|
+
|
|
14
|
+
from rich.markup import escape as escape_markup
|
|
15
|
+
from textual.app import App, ComposeResult
|
|
16
|
+
from textual.binding import Binding
|
|
17
|
+
from textual.containers import Horizontal, Vertical
|
|
18
|
+
from textual.widgets import Footer, ListItem, ListView, Static
|
|
19
|
+
from typing_extensions import override
|
|
20
|
+
|
|
21
|
+
from cgate import __version__
|
|
22
|
+
from cgate.db.mode import AppModeNotSetError, Mode
|
|
23
|
+
from cgate.watch.approval import (
|
|
24
|
+
CommandDisappearedError,
|
|
25
|
+
ConnectionNotFoundError,
|
|
26
|
+
execute_and_finalize,
|
|
27
|
+
mark_approved,
|
|
28
|
+
reject_one,
|
|
29
|
+
)
|
|
30
|
+
from cgate.watch.command_detail_modal import CommandDetailModal
|
|
31
|
+
from cgate.watch.history_modal import HistoryModal
|
|
32
|
+
from cgate.watch.mode_modal import ModeModal, mode_markup
|
|
33
|
+
from cgate.watch.queue import (
|
|
34
|
+
count_pending_commands,
|
|
35
|
+
count_waiting,
|
|
36
|
+
pending_commands_in_batch,
|
|
37
|
+
select_active_batch,
|
|
38
|
+
)
|
|
39
|
+
from cgate.watch.render import format_batch_header, format_queue_summary, server_badge
|
|
40
|
+
from cgate.watch.server_settings_modal import ServerSettingsModal
|
|
41
|
+
from cgate.watch.theme import CGATE_THEME
|
|
42
|
+
from cgate.watch.widgets import CommandRow
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from textual.binding import BindingType
|
|
46
|
+
|
|
47
|
+
from cgate.connections.store import ConnectionsRepo
|
|
48
|
+
from cgate.db.batches import BatchesRepo
|
|
49
|
+
from cgate.db.commands import CommandsRepo
|
|
50
|
+
from cgate.db.connection import Database
|
|
51
|
+
from cgate.db.mode import AppModeRepo
|
|
52
|
+
from cgate.db.server_settings import ServerSettingsRepo
|
|
53
|
+
from cgate.db.types import Batch, BatchId, Command, CommandId, Connection
|
|
54
|
+
|
|
55
|
+
_POLL_INTERVAL_SECONDS: float = 1.5
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class BatchRow(ListItem):
|
|
59
|
+
"""One pending batch in the queue sidebar, carrying its id for selection."""
|
|
60
|
+
|
|
61
|
+
batch_id: BatchId
|
|
62
|
+
|
|
63
|
+
def __init__(self, batch: Batch, *, active: bool) -> None:
|
|
64
|
+
"""Render the marker/title and remember which batch this row is."""
|
|
65
|
+
marker = "▶" if active else " "
|
|
66
|
+
style = f"bold {CGATE_THEME.primary}" if active else "dim"
|
|
67
|
+
super().__init__(
|
|
68
|
+
Static(f"{marker} [{style}]{escape_markup(batch.title)}[/{style}]", markup=True)
|
|
69
|
+
)
|
|
70
|
+
self.batch_id = batch.id
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class QueueSidebar(Vertical):
|
|
74
|
+
"""Left rail listing pending batches, FIFO order, active one marked.
|
|
75
|
+
|
|
76
|
+
Enter on a highlighted row pins that batch as active regardless of
|
|
77
|
+
FIFO order (`WatchApp.on_list_view_selected`), so a human can jump
|
|
78
|
+
the queue and prioritize approving something further down.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
_rendered: tuple[tuple[BatchId, str, bool], ...] = ()
|
|
82
|
+
|
|
83
|
+
@override
|
|
84
|
+
def compose(self) -> ComposeResult:
|
|
85
|
+
"""Build the static title, a usage hint, and the batch list view."""
|
|
86
|
+
yield Static("[bold]Queue[/bold]", classes="sidebar-title")
|
|
87
|
+
yield Static("[dim]↑/↓ Enter = jump to a batch[/dim]", classes="sidebar-hint")
|
|
88
|
+
yield ListView(id="queue-list")
|
|
89
|
+
|
|
90
|
+
def refresh_queue(self, pending: list[Batch], active_id: BatchId | None) -> None:
|
|
91
|
+
"""Rebuild the list view, keeping the same row highlighted if it still exists.
|
|
92
|
+
|
|
93
|
+
Runs on every poll tick (every `_POLL_INTERVAL_SECONDS`), not just
|
|
94
|
+
on user action. Two things follow from that:
|
|
95
|
+
|
|
96
|
+
- It must not reset a human's cursor mid-navigation -- restoring
|
|
97
|
+
the highlighted batch by id (falling back to the first row)
|
|
98
|
+
keeps ↑/↓ usable even while the queue is refreshing live.
|
|
99
|
+
- The overwhelmingly common tick is "nothing changed"; clearing
|
|
100
|
+
and rebuilding the list view anyway made every row visibly
|
|
101
|
+
flash every `_POLL_INTERVAL_SECONDS` for no reason. Skipping
|
|
102
|
+
the rebuild whenever the rendered snapshot is identical fixes
|
|
103
|
+
that without touching the polling itself.
|
|
104
|
+
"""
|
|
105
|
+
snapshot = tuple((batch.id, batch.title, batch.id == active_id) for batch in pending)
|
|
106
|
+
if snapshot == self._rendered:
|
|
107
|
+
return
|
|
108
|
+
self._rendered = snapshot
|
|
109
|
+
list_view = self.query_one("#queue-list", ListView)
|
|
110
|
+
highlighted = list_view.highlighted_child
|
|
111
|
+
highlighted_id = highlighted.batch_id if isinstance(highlighted, BatchRow) else None
|
|
112
|
+
_ = list_view.clear()
|
|
113
|
+
for batch in pending:
|
|
114
|
+
_ = list_view.append(BatchRow(batch, active=batch.id == active_id))
|
|
115
|
+
if pending:
|
|
116
|
+
restore_index = next(
|
|
117
|
+
(i for i, batch in enumerate(pending) if batch.id == highlighted_id), 0
|
|
118
|
+
)
|
|
119
|
+
list_view.index = restore_index
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ModeHeader(Horizontal):
|
|
123
|
+
"""Top bar: app title on the left, global mode indicator on the right."""
|
|
124
|
+
|
|
125
|
+
@override
|
|
126
|
+
def compose(self) -> ComposeResult:
|
|
127
|
+
"""Build the title static and the right-aligned mode indicator."""
|
|
128
|
+
yield Static(f"🔐 [bold]cgate watch[/bold] [dim]v{__version__}[/dim]", id="mode-title")
|
|
129
|
+
yield Static(id="mode-indicator")
|
|
130
|
+
|
|
131
|
+
def show_mode(self, mode: Mode, *, auto_allowed: int, total: int) -> None:
|
|
132
|
+
"""Render the mode label; only AUTO also shows the auto-allowed count."""
|
|
133
|
+
text = f"MODE: {mode_markup(mode)}"
|
|
134
|
+
if mode is Mode.AUTO:
|
|
135
|
+
text += f" [dim]|[/dim] {auto_allowed} servers auto-allowed (of {total})"
|
|
136
|
+
_ = self.query_one("#mode-indicator", Static).update(text)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ServersSidebar(Vertical):
|
|
140
|
+
"""Left-rail section listing every connection with its auto-approve flag."""
|
|
141
|
+
|
|
142
|
+
_rendered: tuple[tuple[str, bool], ...] = ()
|
|
143
|
+
|
|
144
|
+
@override
|
|
145
|
+
def compose(self) -> ComposeResult:
|
|
146
|
+
"""Build the static title and the servers list view."""
|
|
147
|
+
yield Static("[bold]Servers[/bold]", classes="sidebar-title")
|
|
148
|
+
yield ListView(id="servers-list")
|
|
149
|
+
|
|
150
|
+
def refresh_servers(self, rows: list[tuple[Connection, bool]]) -> None:
|
|
151
|
+
"""Replace the list view contents with the current connections and flags.
|
|
152
|
+
|
|
153
|
+
Skips the rebuild when nothing changed since the last poll tick --
|
|
154
|
+
see `QueueSidebar.refresh_queue` for why that matters.
|
|
155
|
+
"""
|
|
156
|
+
snapshot = tuple((connection.alias, auto_allowed) for connection, auto_allowed in rows)
|
|
157
|
+
if snapshot == self._rendered:
|
|
158
|
+
return
|
|
159
|
+
self._rendered = snapshot
|
|
160
|
+
list_view = self.query_one("#servers-list", ListView)
|
|
161
|
+
_ = list_view.clear()
|
|
162
|
+
for connection, auto_allowed in rows:
|
|
163
|
+
checkbox = "[✓]" if auto_allowed else "[ ]"
|
|
164
|
+
badge = server_badge(connection.server_type)
|
|
165
|
+
_ = list_view.append(
|
|
166
|
+
ListItem(Static(f"{connection.alias} {badge} {checkbox}", markup=True))
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class ActivePanel(Vertical):
|
|
171
|
+
"""Main pane: the active batch's header and its command rows.
|
|
172
|
+
|
|
173
|
+
Enter on a highlighted command row opens its full-detail modal
|
|
174
|
+
(`WatchApp.on_list_view_selected`) -- the untruncated result, the
|
|
175
|
+
agent's `reason`, and who/what approved it.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
_shown_batch_id: BatchId | None = None
|
|
179
|
+
|
|
180
|
+
@override
|
|
181
|
+
def compose(self) -> ComposeResult:
|
|
182
|
+
"""Build the header static, a usage hint, and the row list view."""
|
|
183
|
+
yield Static(id="active-header")
|
|
184
|
+
yield Static("[dim]Enter on a command = view full result[/dim]", classes="sidebar-hint")
|
|
185
|
+
yield ListView(id="rows")
|
|
186
|
+
|
|
187
|
+
def show_idle(self) -> None:
|
|
188
|
+
"""Show the empty-queue placeholder and drop any stale rows."""
|
|
189
|
+
self._shown_batch_id = None
|
|
190
|
+
_ = self.query_one("#active-header", Static).update(
|
|
191
|
+
"[dim]No pending batches — waiting for new proposals…[/dim]"
|
|
192
|
+
)
|
|
193
|
+
_ = self.query_one("#rows", ListView).clear()
|
|
194
|
+
|
|
195
|
+
def show_batch(self, batch: Batch, commands_in_batch: list[Command]) -> None:
|
|
196
|
+
"""Render one batch's header, updating existing rows in place when possible."""
|
|
197
|
+
_ = self.query_one("#active-header", Static).update(format_batch_header(batch))
|
|
198
|
+
rows = self.query_one("#rows", ListView)
|
|
199
|
+
if batch.id != self._shown_batch_id:
|
|
200
|
+
self._shown_batch_id = batch.id
|
|
201
|
+
_ = rows.clear()
|
|
202
|
+
for command in commands_in_batch:
|
|
203
|
+
_ = rows.append(ListItem(CommandRow(command)))
|
|
204
|
+
if commands_in_batch:
|
|
205
|
+
rows.index = 0
|
|
206
|
+
return
|
|
207
|
+
existing = {row.command_id: row for row in rows.query(CommandRow)}
|
|
208
|
+
for command in commands_in_batch:
|
|
209
|
+
row = existing.get(command.id)
|
|
210
|
+
if row is not None:
|
|
211
|
+
row.update_command(command)
|
|
212
|
+
else:
|
|
213
|
+
_ = rows.append(ListItem(CommandRow(command)))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class WatchApp(App[None]):
|
|
217
|
+
"""Approval dashboard: sidebar queue + active-batch panel, keys y/n/a/r/h/q."""
|
|
218
|
+
|
|
219
|
+
CSS: ClassVar[str] = """
|
|
220
|
+
Screen { background: $surface; }
|
|
221
|
+
ModeHeader { dock: top; height: 1; background: $panel; padding: 0 1; }
|
|
222
|
+
#mode-title { width: auto; color: $primary; }
|
|
223
|
+
#mode-indicator { width: 1fr; text-align: right; }
|
|
224
|
+
#body { height: 1fr; }
|
|
225
|
+
#sidebar { width: 36; border-right: solid $panel; }
|
|
226
|
+
QueueSidebar { padding: 1; height: 1fr; }
|
|
227
|
+
ServersSidebar { padding: 1; height: auto; max-height: 45%; border-top: solid $panel; }
|
|
228
|
+
.sidebar-title { margin-bottom: 1; }
|
|
229
|
+
.sidebar-hint { margin-bottom: 1; }
|
|
230
|
+
ActivePanel { padding: 1 2; }
|
|
231
|
+
#active-header { margin-bottom: 1; }
|
|
232
|
+
#rows { height: 1fr; }
|
|
233
|
+
CommandRow { margin-bottom: 1; }
|
|
234
|
+
#waiting-notice { padding: 0 2; }
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
238
|
+
Binding("y", "approve_one", "Approve"),
|
|
239
|
+
Binding("n", "reject_one", "Reject"),
|
|
240
|
+
Binding("a", "approve_all", "Approve all"),
|
|
241
|
+
Binding("r", "reject_all", "Reject all"),
|
|
242
|
+
Binding("m", "toggle_mode", "Mode"),
|
|
243
|
+
Binding("s", "server_settings", "Servers"),
|
|
244
|
+
Binding("h", "history", "History"),
|
|
245
|
+
Binding("q", "quit", "Quit"),
|
|
246
|
+
]
|
|
247
|
+
|
|
248
|
+
_db: Database # class-level annotation required by strict mode
|
|
249
|
+
_batches: BatchesRepo # class-level annotation required by strict mode
|
|
250
|
+
_commands: CommandsRepo # class-level annotation required by strict mode
|
|
251
|
+
_connections: ConnectionsRepo # class-level annotation required by strict mode
|
|
252
|
+
_mode: AppModeRepo # class-level annotation required by strict mode
|
|
253
|
+
_server_settings: ServerSettingsRepo # class-level annotation required by strict mode
|
|
254
|
+
_busy: bool
|
|
255
|
+
_pinned_batch_id: BatchId | None
|
|
256
|
+
|
|
257
|
+
def __init__( # noqa: PLR0913 - signature follows the required repository DI boundary
|
|
258
|
+
self,
|
|
259
|
+
*,
|
|
260
|
+
db: Database,
|
|
261
|
+
batches: BatchesRepo,
|
|
262
|
+
commands: CommandsRepo,
|
|
263
|
+
connections: ConnectionsRepo,
|
|
264
|
+
mode: AppModeRepo,
|
|
265
|
+
server_settings: ServerSettingsRepo,
|
|
266
|
+
) -> None:
|
|
267
|
+
"""Store the repository collaborators used to read and mutate the queue."""
|
|
268
|
+
super().__init__()
|
|
269
|
+
self.register_theme(CGATE_THEME)
|
|
270
|
+
self.theme = CGATE_THEME.name # pyright: ignore[reportUnannotatedClassAttribute]
|
|
271
|
+
self._db = db
|
|
272
|
+
self._batches = batches
|
|
273
|
+
self._commands = commands
|
|
274
|
+
self._connections = connections
|
|
275
|
+
self._mode = mode
|
|
276
|
+
self._server_settings = server_settings
|
|
277
|
+
self._busy = False
|
|
278
|
+
self._pinned_batch_id = None
|
|
279
|
+
|
|
280
|
+
@override
|
|
281
|
+
def compose(self) -> ComposeResult:
|
|
282
|
+
"""Lay out mode header, waiting notice, sidebar + active panel, and footer."""
|
|
283
|
+
yield ModeHeader()
|
|
284
|
+
yield Static(id="waiting-notice")
|
|
285
|
+
with Horizontal(id="body"):
|
|
286
|
+
with Vertical(id="sidebar"):
|
|
287
|
+
yield QueueSidebar()
|
|
288
|
+
yield ServersSidebar()
|
|
289
|
+
yield ActivePanel()
|
|
290
|
+
yield Footer()
|
|
291
|
+
|
|
292
|
+
def on_mount(self) -> None:
|
|
293
|
+
"""Render the initial state and start polling for external queue changes."""
|
|
294
|
+
self._refresh()
|
|
295
|
+
_ = self.set_interval(_POLL_INTERVAL_SECONDS, self._refresh)
|
|
296
|
+
|
|
297
|
+
def _render_notice(self, text: str, sub_title: str) -> None:
|
|
298
|
+
"""Update the waiting-notice and sub_title without ever raising.
|
|
299
|
+
|
|
300
|
+
Best-effort: if the widget tree is already gone (e.g. `query_one`
|
|
301
|
+
raises during teardown), this swallows rather than raising, since
|
|
302
|
+
Textual would otherwise print its own traceback on top of the one
|
|
303
|
+
being reported.
|
|
304
|
+
"""
|
|
305
|
+
try:
|
|
306
|
+
notice = self.query_one("#waiting-notice", Static)
|
|
307
|
+
_ = notice.update(text)
|
|
308
|
+
self.sub_title = sub_title # pyright: ignore[reportUnannotatedClassAttribute]
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
|
|
312
|
+
def _render_db_error(self, exc: sqlite3.Error) -> None:
|
|
313
|
+
"""Render a database error on the TUI without raising (issue #12).
|
|
314
|
+
|
|
315
|
+
Textual's widget-exception handler runs `_handle_exception` →
|
|
316
|
+
`_fatal_error` → prints a raw
|
|
317
|
+
`rich.traceback.Traceback(show_locals=True)` on `_shutdown()`,
|
|
318
|
+
bypassing the `sqlite3.Error` boundary in `cli/main.py`. Every
|
|
319
|
+
DB-touching method on this app must funnel errors through this
|
|
320
|
+
helper so the user sees a clean in-UI message and stays in the
|
|
321
|
+
dashboard; the next polling tick (or the user's next keypress)
|
|
322
|
+
will retry the read automatically.
|
|
323
|
+
"""
|
|
324
|
+
error = CGATE_THEME.error
|
|
325
|
+
self._render_notice(
|
|
326
|
+
(
|
|
327
|
+
f"[{error}]Could not read the database:[/{error}] {exc}\n"
|
|
328
|
+
"[dim]Check that no other cgate process is locking "
|
|
329
|
+
"cgate.db. The next read will retry automatically.[/dim]"
|
|
330
|
+
),
|
|
331
|
+
"database error",
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def _render_approval_error(self, exc: Exception) -> None:
|
|
335
|
+
"""Render an approval failure on the TUI without raising or crashing.
|
|
336
|
+
|
|
337
|
+
`approve_one` can also raise `ConnectionNotFoundError` (the
|
|
338
|
+
connection was removed after the command was queued) or
|
|
339
|
+
`CommandDisappearedError`, on top of `sqlite3.Error`. Left
|
|
340
|
+
uncaught inside a Textual action, either would crash the whole
|
|
341
|
+
dashboard the same way issue #12 did -- over a single bad
|
|
342
|
+
command. The busy flag is still released by `_approve`'s
|
|
343
|
+
`finally`, so the rest of the queue stays fully usable.
|
|
344
|
+
"""
|
|
345
|
+
error = CGATE_THEME.error
|
|
346
|
+
self._render_notice(
|
|
347
|
+
f"[{error}]Could not approve this command:[/{error}] {exc}", "approval error"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def _global_mode(self) -> Mode:
|
|
351
|
+
"""Return the persisted global mode, defaulting to PROPOSE when unset."""
|
|
352
|
+
try:
|
|
353
|
+
return self._mode.get().mode
|
|
354
|
+
except AppModeNotSetError:
|
|
355
|
+
return Mode.PROPOSE
|
|
356
|
+
|
|
357
|
+
def _refresh_mode_and_servers(self) -> None:
|
|
358
|
+
"""Update the mode header and the servers sidebar from the database."""
|
|
359
|
+
mode = self._global_mode()
|
|
360
|
+
connections = self._connections.list_all()
|
|
361
|
+
rows = [
|
|
362
|
+
(connection, self._server_settings.get_or_default(connection.alias).auto_allowed)
|
|
363
|
+
for connection in connections
|
|
364
|
+
]
|
|
365
|
+
auto_allowed = sum(1 for _, allowed in rows if allowed)
|
|
366
|
+
self.query_one(ModeHeader).show_mode(mode, auto_allowed=auto_allowed, total=len(rows))
|
|
367
|
+
self.query_one(ServersSidebar).refresh_servers(rows)
|
|
368
|
+
|
|
369
|
+
def _refresh(self) -> None:
|
|
370
|
+
"""Re-read the queue from the database and update every widget from it."""
|
|
371
|
+
try:
|
|
372
|
+
self._refresh_mode_and_servers()
|
|
373
|
+
pending = self._batches.list_pending()
|
|
374
|
+
active = select_active_batch(pending, self._pinned_batch_id)
|
|
375
|
+
self.query_one(QueueSidebar).refresh_queue(pending, active.id if active else None)
|
|
376
|
+
pending_total = count_pending_commands(pending, self._commands)
|
|
377
|
+
notice = self.query_one("#waiting-notice", Static)
|
|
378
|
+
_ = notice.update(
|
|
379
|
+
format_queue_summary(
|
|
380
|
+
pending_commands=pending_total,
|
|
381
|
+
waiting_batches=count_waiting(self._batches),
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
panel = self.query_one(ActivePanel)
|
|
385
|
+
if active is None:
|
|
386
|
+
panel.show_idle()
|
|
387
|
+
# Reactive[str] on the base class; reassigning it is the documented
|
|
388
|
+
# Textual pattern, but basedpyright wants a same-interval annotation.
|
|
389
|
+
self.sub_title = "no pending batches" # pyright: ignore[reportUnannotatedClassAttribute]
|
|
390
|
+
return
|
|
391
|
+
commands_in_batch = self._commands.list_for_batch(active.id)
|
|
392
|
+
panel.show_batch(active, commands_in_batch)
|
|
393
|
+
pending_here = len(pending_commands_in_batch(commands_in_batch))
|
|
394
|
+
self.sub_title = f"{pending_here} pending"
|
|
395
|
+
except sqlite3.Error as exc:
|
|
396
|
+
self._render_db_error(exc)
|
|
397
|
+
|
|
398
|
+
def _first_pending(self) -> Command | None:
|
|
399
|
+
"""Return the active batch's next pending command, or None.
|
|
400
|
+
|
|
401
|
+
"Active" honors a pinned batch (`select_active_batch`) the same
|
|
402
|
+
way `_refresh` does, so y/n/a/r act on whatever the human picked
|
|
403
|
+
in the queue sidebar rather than always the FIFO-oldest one.
|
|
404
|
+
|
|
405
|
+
Returns ``None`` on a DB error too -- action handlers already
|
|
406
|
+
treat ``None`` as "no-op", so the user's keypress becomes a
|
|
407
|
+
silent skip while the error message stays on screen.
|
|
408
|
+
"""
|
|
409
|
+
try:
|
|
410
|
+
pending = self._batches.list_pending()
|
|
411
|
+
active = select_active_batch(pending, self._pinned_batch_id)
|
|
412
|
+
if active is None:
|
|
413
|
+
return None
|
|
414
|
+
commands_in_batch = self._commands.list_for_batch(active.id)
|
|
415
|
+
remaining = pending_commands_in_batch(commands_in_batch)
|
|
416
|
+
return remaining[0] if remaining else None
|
|
417
|
+
except sqlite3.Error as exc:
|
|
418
|
+
self._render_db_error(exc)
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
422
|
+
"""Pin a batch chosen in the queue sidebar, or open a command's full detail."""
|
|
423
|
+
if event.list_view.id == "queue-list" and isinstance(event.item, BatchRow):
|
|
424
|
+
self._pinned_batch_id = event.item.batch_id
|
|
425
|
+
self._refresh()
|
|
426
|
+
elif event.list_view.id == "rows":
|
|
427
|
+
row = event.item.query_one(CommandRow)
|
|
428
|
+
self._show_command_detail(row.command_id)
|
|
429
|
+
|
|
430
|
+
def _show_command_detail(self, command_id: CommandId) -> None:
|
|
431
|
+
"""Open the full-detail modal for one command; best-effort on a DB error."""
|
|
432
|
+
try:
|
|
433
|
+
command = self._commands.get(command_id)
|
|
434
|
+
except sqlite3.Error as exc:
|
|
435
|
+
self._render_db_error(exc)
|
|
436
|
+
return
|
|
437
|
+
if command is not None:
|
|
438
|
+
_ = self.push_screen(CommandDetailModal(command))
|
|
439
|
+
|
|
440
|
+
async def action_approve_one(self) -> None:
|
|
441
|
+
"""Approve and execute the active batch's next pending command."""
|
|
442
|
+
if self._busy:
|
|
443
|
+
return
|
|
444
|
+
command = self._first_pending()
|
|
445
|
+
if command is None:
|
|
446
|
+
return
|
|
447
|
+
await self._approve(command.id)
|
|
448
|
+
|
|
449
|
+
def action_reject_one(self) -> None:
|
|
450
|
+
"""Reject the active batch's next pending command."""
|
|
451
|
+
if self._busy:
|
|
452
|
+
return
|
|
453
|
+
command = self._first_pending()
|
|
454
|
+
if command is None:
|
|
455
|
+
return
|
|
456
|
+
try:
|
|
457
|
+
_ = reject_one(commands=self._commands, batches=self._batches, command_id=command.id)
|
|
458
|
+
except sqlite3.Error as exc:
|
|
459
|
+
self._render_db_error(exc)
|
|
460
|
+
return
|
|
461
|
+
self._refresh()
|
|
462
|
+
|
|
463
|
+
async def action_approve_all(self) -> None:
|
|
464
|
+
"""Approve and execute every pending command in the active batch, in order."""
|
|
465
|
+
if self._busy:
|
|
466
|
+
return
|
|
467
|
+
while (command := self._first_pending()) is not None:
|
|
468
|
+
await self._approve(command.id)
|
|
469
|
+
|
|
470
|
+
def action_reject_all(self) -> None:
|
|
471
|
+
"""Reject every pending command in the active batch."""
|
|
472
|
+
if self._busy:
|
|
473
|
+
return
|
|
474
|
+
while (command := self._first_pending()) is not None:
|
|
475
|
+
try:
|
|
476
|
+
_ = reject_one(
|
|
477
|
+
commands=self._commands,
|
|
478
|
+
batches=self._batches,
|
|
479
|
+
command_id=command.id,
|
|
480
|
+
)
|
|
481
|
+
except sqlite3.Error as exc:
|
|
482
|
+
self._render_db_error(exc)
|
|
483
|
+
return
|
|
484
|
+
self._refresh()
|
|
485
|
+
|
|
486
|
+
def action_toggle_mode(self) -> None:
|
|
487
|
+
"""Open the confirmation modal and flip the global mode on confirm."""
|
|
488
|
+
|
|
489
|
+
def _after(confirmed: bool | None) -> None: # noqa: FBT001 - Textual push_screen callback signature
|
|
490
|
+
if confirmed:
|
|
491
|
+
self._refresh()
|
|
492
|
+
|
|
493
|
+
self.push_screen(ModeModal(mode_repo=self._mode), _after)
|
|
494
|
+
|
|
495
|
+
def action_server_settings(self) -> None:
|
|
496
|
+
"""Open the per-server auto-approve editor and refresh on save."""
|
|
497
|
+
|
|
498
|
+
def _after(saved: bool | None) -> None: # noqa: FBT001 - Textual push_screen callback signature
|
|
499
|
+
if saved:
|
|
500
|
+
self._refresh()
|
|
501
|
+
|
|
502
|
+
self.push_screen(
|
|
503
|
+
ServerSettingsModal(
|
|
504
|
+
connections=self._connections,
|
|
505
|
+
settings=self._server_settings,
|
|
506
|
+
mode_repo=self._mode,
|
|
507
|
+
),
|
|
508
|
+
_after,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
def action_history(self) -> None:
|
|
512
|
+
"""Open the read-only browser for resolved batches."""
|
|
513
|
+
_ = self.push_screen(HistoryModal(batches=self._batches, commands=self._commands))
|
|
514
|
+
|
|
515
|
+
async def _approve(self, command_id: CommandId) -> None:
|
|
516
|
+
"""Mark approved, refresh (so the queue shows it's running), then execute.
|
|
517
|
+
|
|
518
|
+
Split into two off-thread calls instead of one so the human sees
|
|
519
|
+
the "approved, running" state (status glyph ◐, plus the
|
|
520
|
+
"running…" hint in `format_command_line`) the moment `y` is
|
|
521
|
+
pressed -- not just silence for however long the executor's
|
|
522
|
+
timeout allows, which reads as the dashboard having frozen.
|
|
523
|
+
"""
|
|
524
|
+
self._busy = True
|
|
525
|
+
try:
|
|
526
|
+
try:
|
|
527
|
+
_ = await asyncio.to_thread(
|
|
528
|
+
mark_approved,
|
|
529
|
+
commands=self._commands,
|
|
530
|
+
connections=self._connections,
|
|
531
|
+
command_id=command_id,
|
|
532
|
+
)
|
|
533
|
+
except sqlite3.Error as exc:
|
|
534
|
+
self._render_db_error(exc)
|
|
535
|
+
return
|
|
536
|
+
except ConnectionNotFoundError as exc:
|
|
537
|
+
self._render_approval_error(exc)
|
|
538
|
+
return
|
|
539
|
+
self._refresh()
|
|
540
|
+
try:
|
|
541
|
+
_ = await asyncio.to_thread(
|
|
542
|
+
execute_and_finalize,
|
|
543
|
+
db=self._db,
|
|
544
|
+
commands=self._commands,
|
|
545
|
+
connections=self._connections,
|
|
546
|
+
batches=self._batches,
|
|
547
|
+
command_id=command_id,
|
|
548
|
+
)
|
|
549
|
+
except sqlite3.Error as exc:
|
|
550
|
+
self._render_db_error(exc)
|
|
551
|
+
return
|
|
552
|
+
except CommandDisappearedError as exc:
|
|
553
|
+
self._render_approval_error(exc)
|
|
554
|
+
return
|
|
555
|
+
finally:
|
|
556
|
+
self._busy = False
|
|
557
|
+
self._refresh()
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
__all__ = ["WatchApp"]
|