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/queue.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Pure helpers for selecting the active batch, counting waiters, and healing it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from cgate.db.commands import all_terminal
|
|
8
|
+
from cgate.db.types import CommandStatus
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from cgate.db.batches import BatchesRepo
|
|
12
|
+
from cgate.db.commands import CommandsRepo
|
|
13
|
+
from cgate.db.types import Batch, BatchId, Command
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def active_batch(batches: BatchesRepo) -> Batch | None:
|
|
17
|
+
"""Return the next pending batch in FIFO order, or None."""
|
|
18
|
+
pending = batches.list_pending()
|
|
19
|
+
return pending[0] if pending else None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def select_active_batch(pending: list[Batch], pinned_id: BatchId | None) -> Batch | None:
|
|
23
|
+
"""Return the pinned batch if it's still pending, else the FIFO-oldest one.
|
|
24
|
+
|
|
25
|
+
Lets a human jump the queue and prioritize a specific batch instead
|
|
26
|
+
of being forced through strict FIFO order. Self-correcting: once the
|
|
27
|
+
pinned batch resolves (or otherwise drops out of `pending`), this
|
|
28
|
+
falls straight back to FIFO without needing the caller to notice and
|
|
29
|
+
clear the pin.
|
|
30
|
+
"""
|
|
31
|
+
if pinned_id is not None:
|
|
32
|
+
for batch in pending:
|
|
33
|
+
if batch.id == pinned_id:
|
|
34
|
+
return batch
|
|
35
|
+
return pending[0] if pending else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def count_waiting(batches: BatchesRepo) -> int:
|
|
39
|
+
"""Count batches queued behind the active one."""
|
|
40
|
+
return max(0, len(batches.list_pending()) - 1)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def count_pending_commands(pending_batches: list[Batch], commands: CommandsRepo) -> int:
|
|
44
|
+
"""Total PENDING commands across every batch still in the queue."""
|
|
45
|
+
return sum(
|
|
46
|
+
len(pending_commands_in_batch(commands.list_for_batch(batch.id)))
|
|
47
|
+
for batch in pending_batches
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def pending_commands_in_batch(commands_for_batch: list[Command]) -> list[Command]:
|
|
52
|
+
"""Filter pending commands while preserving their repository order."""
|
|
53
|
+
return [
|
|
54
|
+
command
|
|
55
|
+
for command in commands_for_batch
|
|
56
|
+
if command.status is CommandStatus.PENDING
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_batch_resolved(commands_for_batch: list[Command]) -> bool:
|
|
61
|
+
"""Return whether every command in the batch has a terminal status."""
|
|
62
|
+
return all_terminal(commands_for_batch)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def resolve_stale_batches(batches: BatchesRepo, commands: CommandsRepo) -> None:
|
|
66
|
+
"""Stamp `resolved_at` on any pending batch whose commands are all terminal.
|
|
67
|
+
|
|
68
|
+
A command can turn terminal without going through the TUI's own
|
|
69
|
+
approve/reject path -- the MCP AUTO-mode auto-execution path is one
|
|
70
|
+
example. Whenever that happens the batch's `resolved_at` must be
|
|
71
|
+
stamped too, or it sits at the head of the FIFO queue forever with
|
|
72
|
+
nothing left to approve or reject, silently blocking every batch
|
|
73
|
+
behind it. This sweeps up any batch left in that state.
|
|
74
|
+
"""
|
|
75
|
+
for batch in batches.list_pending():
|
|
76
|
+
commands_for_batch = commands.list_for_batch(batch.id)
|
|
77
|
+
if commands_for_batch and is_batch_resolved(commands_for_batch):
|
|
78
|
+
batches.mark_resolved(batch.id)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def fail_orphaned_approvals(commands: CommandsRepo) -> None:
|
|
82
|
+
"""Fail any command left `APPROVED` by a process that died mid-execution.
|
|
83
|
+
|
|
84
|
+
Approval runs synchronously end-to-end in one call: mark APPROVED, run
|
|
85
|
+
the command, stamp EXECUTED/FAILED. A command still APPROVED means that
|
|
86
|
+
call never got to finish -- it can never reach a terminal status on its
|
|
87
|
+
own, which keeps its batch stuck exactly like the `resolved_at` gap
|
|
88
|
+
`resolve_stale_batches` sweeps up.
|
|
89
|
+
"""
|
|
90
|
+
for command in commands.list_by_status(CommandStatus.APPROVED):
|
|
91
|
+
_ = commands.update_status(
|
|
92
|
+
command.id,
|
|
93
|
+
status=CommandStatus.FAILED,
|
|
94
|
+
approved_by=command.approved_by,
|
|
95
|
+
result="interrupted before completion (cgate restarted mid-execution)",
|
|
96
|
+
expected_status=CommandStatus.APPROVED,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def heal_queue(batches: BatchesRepo, commands: CommandsRepo) -> None:
|
|
101
|
+
"""Repair queue state left inconsistent by a crash or a since-fixed bug.
|
|
102
|
+
|
|
103
|
+
Runs once when `cgate watch` starts, before the dashboard is shown.
|
|
104
|
+
"""
|
|
105
|
+
fail_orphaned_approvals(commands)
|
|
106
|
+
resolve_stale_batches(batches, commands)
|
cgate/watch/render.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Pure markup helpers for the cgate watch TUI (spec §Colors in cgate watch)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Final
|
|
6
|
+
|
|
7
|
+
from rich.markup import escape as escape_markup
|
|
8
|
+
|
|
9
|
+
from cgate.db.types import CommandStatus, ServerType
|
|
10
|
+
from cgate.watch.theme import CGATE_THEME
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from cgate.db.types import Batch, Command
|
|
14
|
+
|
|
15
|
+
_RESULT_SNIPPET_MAX_CHARS: Final = 200
|
|
16
|
+
|
|
17
|
+
# Rich markup can't reference Textual's $warning/$error/$success CSS
|
|
18
|
+
# variables directly, so these pull the same hex values from CGATE_THEME
|
|
19
|
+
# instead of hardcoding Rich's generic "yellow"/"red"/"green" names --
|
|
20
|
+
# otherwise the two would drift apart the moment the theme's palette changes.
|
|
21
|
+
_STATUS_GLYPHS: Final[dict[CommandStatus, str]] = {
|
|
22
|
+
CommandStatus.PENDING: f"[{CGATE_THEME.warning}]●[/{CGATE_THEME.warning}]",
|
|
23
|
+
CommandStatus.APPROVED: f"[{CGATE_THEME.warning}]◐[/{CGATE_THEME.warning}]",
|
|
24
|
+
CommandStatus.EXECUTED: f"[{CGATE_THEME.success}]✓[/{CGATE_THEME.success}]",
|
|
25
|
+
CommandStatus.REJECTED: f"[{CGATE_THEME.error}]✗[/{CGATE_THEME.error}]",
|
|
26
|
+
CommandStatus.FAILED: f"[{CGATE_THEME.error}]✗[/{CGATE_THEME.error}]",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def status_glyph(status: CommandStatus) -> str:
|
|
31
|
+
"""Return the colored status symbol for one command."""
|
|
32
|
+
return _STATUS_GLYPHS[status]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def server_badge(server_type: ServerType) -> str:
|
|
36
|
+
"""Render a compact, colored server-type badge."""
|
|
37
|
+
match server_type:
|
|
38
|
+
case ServerType.WINDOWS:
|
|
39
|
+
return "[cyan]WIN[/cyan]"
|
|
40
|
+
case ServerType.LINUX:
|
|
41
|
+
return "[green]LNX[/green]"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def approver_badge(approved_by: str | None) -> str:
|
|
45
|
+
"""Render who or what approved a command.
|
|
46
|
+
|
|
47
|
+
``mcp_server/tools.py`` prefixes auto-executed approvals with
|
|
48
|
+
``auto:`` (see ``_auto_approve_by``) specifically so this can tell
|
|
49
|
+
them apart from a human's OS username at render time.
|
|
50
|
+
"""
|
|
51
|
+
if not approved_by:
|
|
52
|
+
return ""
|
|
53
|
+
if approved_by.startswith("auto:"):
|
|
54
|
+
return " [cyan]⚙ auto[/cyan]"
|
|
55
|
+
return f" [green]👤 {escape_markup(approved_by)}[/green]"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def risk_warning(risk_label: str | None) -> str:
|
|
59
|
+
"""Render the high-blast-radius warning line, or an empty string when unflagged.
|
|
60
|
+
|
|
61
|
+
Shown regardless of PROPOSE/AUTO mode -- a human scanning the queue
|
|
62
|
+
should see it either way, not just when it happened to be the reason
|
|
63
|
+
AUTO mode queued the command instead of running it.
|
|
64
|
+
"""
|
|
65
|
+
if not risk_label:
|
|
66
|
+
return ""
|
|
67
|
+
error = CGATE_THEME.error
|
|
68
|
+
return f"[bold {error}]⚠ RISKY:[/bold {error}] [{error}]{escape_markup(risk_label)}[/{error}]"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def format_batch_header(batch: Batch) -> str:
|
|
72
|
+
"""Render a bold, brand-colored title with an optional grey italic description below it."""
|
|
73
|
+
primary = CGATE_THEME.primary
|
|
74
|
+
lines = [f"[bold {primary}]{escape_markup(batch.title)}[/bold {primary}]"]
|
|
75
|
+
if batch.description:
|
|
76
|
+
lines.append(f"[grey50 italic]{escape_markup(batch.description)}[/grey50 italic]")
|
|
77
|
+
return "\n".join(lines)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def format_queue_summary(*, pending_commands: int, waiting_batches: int) -> str:
|
|
81
|
+
"""Render the always-on queue summary, or an empty string when it's empty.
|
|
82
|
+
|
|
83
|
+
Replaces the old "N batch(es) waiting" notice, which only ever
|
|
84
|
+
appeared once a *second* batch queued up -- with only one batch
|
|
85
|
+
pending (the common case) it showed nothing at all, even though
|
|
86
|
+
there could be several commands in it still needing a decision.
|
|
87
|
+
"""
|
|
88
|
+
if pending_commands <= 0:
|
|
89
|
+
return ""
|
|
90
|
+
parts = [f"{pending_commands} pending command(s)"]
|
|
91
|
+
if waiting_batches > 0:
|
|
92
|
+
parts.append(f"{waiting_batches} batch(es) waiting")
|
|
93
|
+
warning = CGATE_THEME.warning
|
|
94
|
+
return f"[{warning}]▲ {' · '.join(parts)}[/{warning}]"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _result_snippet(result: str, *, max_chars: int = _RESULT_SNIPPET_MAX_CHARS) -> str:
|
|
98
|
+
"""Trim a result blob to its first line, capped to a display-friendly length."""
|
|
99
|
+
stripped = result.strip()
|
|
100
|
+
if not stripped:
|
|
101
|
+
return ""
|
|
102
|
+
first_line = stripped.splitlines()[0]
|
|
103
|
+
if len(first_line) > max_chars:
|
|
104
|
+
return first_line[:max_chars] + "…"
|
|
105
|
+
return first_line
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def format_command_line(command: Command) -> str:
|
|
109
|
+
"""Render one command row: status glyph, text, badges, reason, result snippet."""
|
|
110
|
+
glyph = status_glyph(command.status)
|
|
111
|
+
badge = server_badge(command.server_type)
|
|
112
|
+
line = (
|
|
113
|
+
f"{glyph} {escape_markup(command.command)} {badge} "
|
|
114
|
+
f"[dim]{escape_markup(command.server_alias)}[/dim]"
|
|
115
|
+
f"{approver_badge(command.approved_by)}"
|
|
116
|
+
)
|
|
117
|
+
if command.risk_label:
|
|
118
|
+
line += f"\n {risk_warning(command.risk_label)}"
|
|
119
|
+
if command.reason:
|
|
120
|
+
line += f"\n [dim italic]↳ {escape_markup(command.reason)}[/dim italic]"
|
|
121
|
+
if command.status is CommandStatus.APPROVED:
|
|
122
|
+
line += "\n [dim]⏳ running…[/dim]"
|
|
123
|
+
elif command.status is CommandStatus.EXECUTED and command.result:
|
|
124
|
+
line += f"\n [dim]{escape_markup(_result_snippet(command.result))}[/dim]"
|
|
125
|
+
elif command.status is CommandStatus.FAILED and command.result:
|
|
126
|
+
error = CGATE_THEME.error
|
|
127
|
+
line += f"\n [{error} dim]{escape_markup(_result_snippet(command.result))}[/{error} dim]"
|
|
128
|
+
return line
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def format_command_detail(command: Command) -> str:
|
|
132
|
+
"""Render a command's full detail: status, server, reason, approver, full result.
|
|
133
|
+
|
|
134
|
+
Unlike `format_command_line`, the result is shown in full -- not
|
|
135
|
+
capped to `_RESULT_SNIPPET_MAX_CHARS` or truncated to one line.
|
|
136
|
+
"""
|
|
137
|
+
server_line = (
|
|
138
|
+
f"[dim]{escape_markup(command.server_alias)}[/dim] {server_badge(command.server_type)}"
|
|
139
|
+
f" [dim]status: {command.status.value}[/dim]"
|
|
140
|
+
)
|
|
141
|
+
lines = [
|
|
142
|
+
f"{status_glyph(command.status)} [bold]{escape_markup(command.command)}[/bold]",
|
|
143
|
+
server_line,
|
|
144
|
+
]
|
|
145
|
+
if command.risk_label:
|
|
146
|
+
lines.append(risk_warning(command.risk_label))
|
|
147
|
+
if command.reason:
|
|
148
|
+
lines.append(f"\n[italic]Reason:[/italic] {escape_markup(command.reason)}")
|
|
149
|
+
if command.approved_by:
|
|
150
|
+
lines.append(f"\n[italic]Approved by:[/italic]{approver_badge(command.approved_by)}")
|
|
151
|
+
if command.result:
|
|
152
|
+
stripped = command.result.strip()
|
|
153
|
+
if stripped:
|
|
154
|
+
lines.append("\n[bold]Result:[/bold]")
|
|
155
|
+
lines.append(escape_markup(stripped))
|
|
156
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Modal for editing per-server auto-approve opt-in flags (`s` key in cgate watch)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import sqlite3
|
|
7
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
8
|
+
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.containers import Vertical
|
|
11
|
+
from textual.screen import ModalScreen
|
|
12
|
+
from textual.widgets import ListItem, ListView, Static
|
|
13
|
+
from typing_extensions import override
|
|
14
|
+
|
|
15
|
+
from cgate.db.mode import AppModeNotSetError, Mode
|
|
16
|
+
from cgate.watch.mode_modal import mode_markup
|
|
17
|
+
from cgate.watch.render import server_badge
|
|
18
|
+
from cgate.watch.theme import CGATE_THEME
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from textual.app import ComposeResult
|
|
22
|
+
from textual.binding import BindingType
|
|
23
|
+
|
|
24
|
+
from cgate.connections.store import ConnectionsRepo
|
|
25
|
+
from cgate.db.mode import AppModeRepo
|
|
26
|
+
from cgate.db.server_settings import ServerSettingsRepo
|
|
27
|
+
from cgate.db.types import Connection
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _updated_by() -> str:
|
|
31
|
+
"""Return the OS username for the audit column, falling back to 'unknown'."""
|
|
32
|
+
try:
|
|
33
|
+
return getpass.getuser() or "unknown"
|
|
34
|
+
except (KeyError, OSError):
|
|
35
|
+
return "unknown"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _row_markup(connection: Connection, *, auto_allowed: bool) -> str:
|
|
39
|
+
"""Render one row: alias, server-type badge, and the auto-allowed checkbox."""
|
|
40
|
+
checkbox = "[✓]" if auto_allowed else "[ ]"
|
|
41
|
+
return f"{connection.alias} {server_badge(connection.server_type)} {checkbox}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ServerRow(ListItem):
|
|
45
|
+
"""One connection row in the settings list, carrying its alias for toggling."""
|
|
46
|
+
|
|
47
|
+
alias: str
|
|
48
|
+
_connection: Connection # class-level annotation required by strict mode
|
|
49
|
+
|
|
50
|
+
def __init__(self, connection: Connection, *, auto_allowed: bool) -> None:
|
|
51
|
+
"""Render the row and remember which connection it belongs to."""
|
|
52
|
+
super().__init__(Static(_row_markup(connection, auto_allowed=auto_allowed), markup=True))
|
|
53
|
+
self.alias = connection.alias
|
|
54
|
+
self._connection = connection
|
|
55
|
+
|
|
56
|
+
def set_auto_allowed(self, *, auto_allowed: bool) -> None:
|
|
57
|
+
"""Re-render the row's checkbox for the new in-memory state."""
|
|
58
|
+
_ = self.query_one(Static).update(_row_markup(self._connection, auto_allowed=auto_allowed))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ServerSettingsModal(ModalScreen[bool]):
|
|
62
|
+
"""Edit per-server auto-approve flags: Space toggles, Enter saves, Esc cancels.
|
|
63
|
+
|
|
64
|
+
Dismisses with ``True`` after a successful commit (even when nothing
|
|
65
|
+
changed); dismisses with ``False`` on Esc, with no writes at all.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
CSS: ClassVar[str] = """
|
|
69
|
+
ServerSettingsModal { align: center middle; }
|
|
70
|
+
#settings-dialog {
|
|
71
|
+
width: 72;
|
|
72
|
+
height: auto;
|
|
73
|
+
max-height: 80%;
|
|
74
|
+
border: round $primary;
|
|
75
|
+
background: $surface;
|
|
76
|
+
padding: 1 2;
|
|
77
|
+
}
|
|
78
|
+
#settings-list { height: auto; max-height: 16; margin-top: 1; }
|
|
79
|
+
#settings-error { color: $error; }
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
# Priority bindings: checked before the focused ListView's own bindings,
|
|
83
|
+
# so Enter commits instead of triggering ListView's row-select.
|
|
84
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
85
|
+
Binding("space", "toggle_row", "Toggle", show=False, priority=True),
|
|
86
|
+
Binding("enter", "commit", "Save", show=False, priority=True),
|
|
87
|
+
Binding("escape", "cancel", "Cancel", show=False, priority=True),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
_connections: ConnectionsRepo # class-level annotation required by strict mode
|
|
91
|
+
_settings: ServerSettingsRepo # class-level annotation required by strict mode
|
|
92
|
+
_mode_repo: AppModeRepo # class-level annotation required by strict mode
|
|
93
|
+
_state: dict[str, bool]
|
|
94
|
+
_initial: dict[str, bool]
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
*,
|
|
99
|
+
connections: ConnectionsRepo,
|
|
100
|
+
settings: ServerSettingsRepo,
|
|
101
|
+
mode_repo: AppModeRepo,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Store the repositories used to list connections and persist flag changes."""
|
|
104
|
+
super().__init__()
|
|
105
|
+
self._connections = connections
|
|
106
|
+
self._settings = settings
|
|
107
|
+
self._mode_repo = mode_repo
|
|
108
|
+
self._state = {}
|
|
109
|
+
self._initial = {}
|
|
110
|
+
|
|
111
|
+
@override
|
|
112
|
+
def compose(self) -> ComposeResult:
|
|
113
|
+
"""Show the title, the current global mode, the hint line, and the list."""
|
|
114
|
+
try:
|
|
115
|
+
current = self._mode_repo.get().mode
|
|
116
|
+
except AppModeNotSetError:
|
|
117
|
+
# Safe-by-default: an unset mode behaves as PROPOSE everywhere else.
|
|
118
|
+
current = Mode.PROPOSE
|
|
119
|
+
with Vertical(id="settings-dialog"):
|
|
120
|
+
yield Static("[bold]Auto-approve per server[/bold]")
|
|
121
|
+
yield Static(f"Global mode: {mode_markup(current)}")
|
|
122
|
+
yield Static("[dim]Space = toggle — Enter = save — Esc = cancel[/dim]")
|
|
123
|
+
yield ListView(id="settings-list")
|
|
124
|
+
yield Static("", id="settings-error")
|
|
125
|
+
|
|
126
|
+
def on_mount(self) -> None:
|
|
127
|
+
"""Populate the list with every connection and its effective flag."""
|
|
128
|
+
list_view = self.query_one("#settings-list", ListView)
|
|
129
|
+
try:
|
|
130
|
+
connections = self._connections.list_all()
|
|
131
|
+
for connection in connections:
|
|
132
|
+
allowed = self._settings.get_or_default(connection.alias).auto_allowed
|
|
133
|
+
self._state[connection.alias] = allowed
|
|
134
|
+
self._initial[connection.alias] = allowed
|
|
135
|
+
_ = list_view.append(ServerRow(connection, auto_allowed=allowed))
|
|
136
|
+
except sqlite3.Error as exc:
|
|
137
|
+
error = CGATE_THEME.error
|
|
138
|
+
_ = self.query_one("#settings-error", Static).update(
|
|
139
|
+
f"[{error}]Could not read the servers:[/{error}] {exc}"
|
|
140
|
+
)
|
|
141
|
+
return
|
|
142
|
+
if self._state:
|
|
143
|
+
list_view.index = 0
|
|
144
|
+
list_view.focus()
|
|
145
|
+
|
|
146
|
+
def action_toggle_row(self) -> None:
|
|
147
|
+
"""Flip the highlighted row's in-memory flag (no writes until Enter)."""
|
|
148
|
+
list_view = self.query_one("#settings-list", ListView)
|
|
149
|
+
row = list_view.highlighted_child
|
|
150
|
+
if not isinstance(row, ServerRow):
|
|
151
|
+
return
|
|
152
|
+
new_value = not self._state[row.alias]
|
|
153
|
+
self._state[row.alias] = new_value
|
|
154
|
+
row.set_auto_allowed(auto_allowed=new_value)
|
|
155
|
+
|
|
156
|
+
def action_commit(self) -> None:
|
|
157
|
+
"""Persist every alias whose flag changed since the modal opened."""
|
|
158
|
+
try:
|
|
159
|
+
for alias, allowed in self._state.items():
|
|
160
|
+
if allowed != self._initial[alias]:
|
|
161
|
+
_ = self._settings.set(
|
|
162
|
+
alias=alias,
|
|
163
|
+
auto_allowed=allowed,
|
|
164
|
+
updated_by=_updated_by(),
|
|
165
|
+
)
|
|
166
|
+
except sqlite3.Error as exc:
|
|
167
|
+
error = CGATE_THEME.error
|
|
168
|
+
_ = self.query_one("#settings-error", Static).update(
|
|
169
|
+
f"[{error}]Could not save the changes:[/{error}] {exc}"
|
|
170
|
+
)
|
|
171
|
+
return
|
|
172
|
+
self.dismiss(True) # noqa: FBT003 - ModalScreen[bool].dismiss takes the result positionally
|
|
173
|
+
|
|
174
|
+
def action_cancel(self) -> None:
|
|
175
|
+
"""Dismiss without writing anything."""
|
|
176
|
+
self.dismiss(False) # noqa: FBT003 - ModalScreen[bool].dismiss takes the result positionally
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
__all__ = ["ServerSettingsModal"]
|
cgate/watch/session.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Entry point for `cgate watch`: always launches the live approval dashboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from cgate.connections.store import ConnectionsRepo
|
|
8
|
+
from cgate.db.batches import BatchesRepo
|
|
9
|
+
from cgate.db.commands import CommandsRepo
|
|
10
|
+
from cgate.db.mode import AppModeRepo
|
|
11
|
+
from cgate.db.server_settings import ServerSettingsRepo
|
|
12
|
+
from cgate.watch.app import WatchApp
|
|
13
|
+
from cgate.watch.queue import heal_queue
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from cgate.db.connection import Database
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_watch_session(db: Database) -> None:
|
|
20
|
+
"""Launch the live approval dashboard, starting idle if the queue is empty.
|
|
21
|
+
|
|
22
|
+
The dashboard never exits on its own: it polls the database for new
|
|
23
|
+
batches and idles when the queue is empty or drains, so it survives
|
|
24
|
+
proposals that arrive at any point while it's open, not only ones
|
|
25
|
+
already pending when it was launched.
|
|
26
|
+
"""
|
|
27
|
+
batches = BatchesRepo(db)
|
|
28
|
+
commands = CommandsRepo(db)
|
|
29
|
+
connections = ConnectionsRepo(db)
|
|
30
|
+
mode = AppModeRepo(db)
|
|
31
|
+
server_settings = ServerSettingsRepo(db)
|
|
32
|
+
heal_queue(batches, commands)
|
|
33
|
+
WatchApp(
|
|
34
|
+
db=db,
|
|
35
|
+
batches=batches,
|
|
36
|
+
commands=commands,
|
|
37
|
+
connections=connections,
|
|
38
|
+
mode=mode,
|
|
39
|
+
server_settings=server_settings,
|
|
40
|
+
).run()
|
cgate/watch/theme.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""The `cgate watch` dashboard's Textual theme -- single source for its palette.
|
|
2
|
+
|
|
3
|
+
Lives in its own module (rather than `watch/app.py`, where it's activated)
|
|
4
|
+
so `watch/render.py`'s pure markup helpers can pull the same hex values for
|
|
5
|
+
status glyphs and warnings, instead of Rich's generic named colors drifting
|
|
6
|
+
out of sync with whatever the app's theme actually is.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from textual.theme import Theme
|
|
12
|
+
|
|
13
|
+
# Deep, near-black surfaces with a teal primary give the dashboard its own
|
|
14
|
+
# identity instead of Textual's generic default. warning/error/success map
|
|
15
|
+
# directly onto the semantics already baked into render.py's status glyphs
|
|
16
|
+
# (pending/running=warning, executed=success, rejected/failed/risky=error).
|
|
17
|
+
CGATE_THEME: Theme = Theme(
|
|
18
|
+
name="cgate",
|
|
19
|
+
primary="#2DD4BF",
|
|
20
|
+
secondary="#60A5FA",
|
|
21
|
+
warning="#FBBF24",
|
|
22
|
+
error="#F87171",
|
|
23
|
+
success="#4ADE80",
|
|
24
|
+
accent="#FB923C",
|
|
25
|
+
foreground="#E5E7EB",
|
|
26
|
+
background="#0B0E14",
|
|
27
|
+
surface="#12151C",
|
|
28
|
+
panel="#1E2330",
|
|
29
|
+
dark=True,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = ["CGATE_THEME"]
|
cgate/watch/widgets.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Shared Textual widgets used by both the live dashboard and the history browser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from textual.widgets import Static
|
|
8
|
+
|
|
9
|
+
from cgate.watch.render import format_command_line
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from cgate.db.types import Command, CommandId
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CommandRow(Static):
|
|
16
|
+
"""One command line, updatable in place.
|
|
17
|
+
|
|
18
|
+
Lives in its own module (rather than `watch/app.py`, where it used
|
|
19
|
+
to be defined) so `watch/history_modal.py` can reuse it without
|
|
20
|
+
creating an import cycle back into `app.py`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
command_id: CommandId
|
|
24
|
+
|
|
25
|
+
def __init__(self, command: Command) -> None:
|
|
26
|
+
"""Render the initial line for this command and remember its id."""
|
|
27
|
+
super().__init__(format_command_line(command), markup=True)
|
|
28
|
+
self.command_id = command.id
|
|
29
|
+
|
|
30
|
+
def update_command(self, command: Command) -> None:
|
|
31
|
+
"""Refresh this row's text for the command's current state."""
|
|
32
|
+
_ = self.update(format_command_line(command))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
__all__ = ["CommandRow"]
|