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.
Files changed (63) hide show
  1. cgate/__init__.py +26 -0
  2. cgate/__main__.py +8 -0
  3. cgate/_version.py +24 -0
  4. cgate/cli/__init__.py +1 -0
  5. cgate/cli/_console.py +17 -0
  6. cgate/cli/connections.py +212 -0
  7. cgate/cli/history.py +191 -0
  8. cgate/cli/install.py +182 -0
  9. cgate/cli/main.py +115 -0
  10. cgate/cli/mcp.py +197 -0
  11. cgate/cli/uninstall.py +403 -0
  12. cgate/cli/update.py +538 -0
  13. cgate/cli/watch.py +20 -0
  14. cgate/connections/__init__.py +1 -0
  15. cgate/connections/auth.py +93 -0
  16. cgate/connections/detect.py +78 -0
  17. cgate/connections/store.py +88 -0
  18. cgate/core/__init__.py +1 -0
  19. cgate/core/path_env.py +218 -0
  20. cgate/core/paths.py +35 -0
  21. cgate/core/update_log.py +36 -0
  22. cgate/db/__init__.py +1 -0
  23. cgate/db/batches.py +111 -0
  24. cgate/db/commands.py +191 -0
  25. cgate/db/connection.py +104 -0
  26. cgate/db/mode.py +74 -0
  27. cgate/db/rows.py +99 -0
  28. cgate/db/schema.py +54 -0
  29. cgate/db/server_settings.py +105 -0
  30. cgate/db/types.py +77 -0
  31. cgate/executor/__init__.py +7 -0
  32. cgate/executor/base.py +71 -0
  33. cgate/executor/selector.py +61 -0
  34. cgate/executor/ssh.py +157 -0
  35. cgate/executor/winrm.py +129 -0
  36. cgate/helper/__init__.py +10 -0
  37. cgate/helper/__main__.py +112 -0
  38. cgate/helper/waiter.py +123 -0
  39. cgate/mcp_installer.py +161 -0
  40. cgate/mcp_server/__init__.py +6 -0
  41. cgate/mcp_server/__main__.py +6 -0
  42. cgate/mcp_server/auto_resolution.py +80 -0
  43. cgate/mcp_server/server.py +271 -0
  44. cgate/mcp_server/tools.py +351 -0
  45. cgate/risk.py +129 -0
  46. cgate/update.py +713 -0
  47. cgate/watch/__init__.py +7 -0
  48. cgate/watch/app.py +560 -0
  49. cgate/watch/approval.py +237 -0
  50. cgate/watch/command_detail_modal.py +68 -0
  51. cgate/watch/history_modal.py +242 -0
  52. cgate/watch/mode_modal.py +110 -0
  53. cgate/watch/queue.py +106 -0
  54. cgate/watch/render.py +156 -0
  55. cgate/watch/server_settings_modal.py +179 -0
  56. cgate/watch/session.py +40 -0
  57. cgate/watch/theme.py +32 -0
  58. cgate/watch/widgets.py +35 -0
  59. command_gate-0.2.4.dist-info/METADATA +204 -0
  60. command_gate-0.2.4.dist-info/RECORD +63 -0
  61. command_gate-0.2.4.dist-info/WHEEL +4 -0
  62. command_gate-0.2.4.dist-info/entry_points.txt +2 -0
  63. command_gate-0.2.4.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,78 @@
1
+ """TCP probe to detect whether a remote host is Windows (WinRM) or Linux (SSH).
2
+
3
+ Pure logic, no DB access, no keyring. Tests can mock socket.create_connection.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import socket
8
+ from dataclasses import dataclass
9
+
10
+ from cgate.db.types import ServerType
11
+
12
+ SSH_PORT = 22
13
+ WINRM_HTTP_PORT = 5985
14
+ WINRM_HTTPS_PORT = 5986
15
+ DEFAULT_PROBE_TIMEOUT = 2.0
16
+
17
+
18
+ class AmbiguousHostError(Exception):
19
+ """Host responded to BOTH SSH and WinRM; user must choose."""
20
+
21
+
22
+ class UnknownHostError(Exception):
23
+ """Host did not respond to either SSH or WinRM."""
24
+
25
+
26
+ def _probe(hostname: str, port: int, timeout: float) -> bool:
27
+ """Try TCP connect and report whether the port is open within the timeout."""
28
+ try:
29
+ with socket.create_connection((hostname, port), timeout=timeout):
30
+ return True
31
+ except OSError:
32
+ return False
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class DetectionProbe:
37
+ """Result of probing a host for SSH and WinRM."""
38
+
39
+ hostname: str
40
+ ssh: bool
41
+ winrm: bool
42
+
43
+ @property
44
+ def server_type(self) -> ServerType:
45
+ """Resolve the result or raise when the host is ambiguous or unknown."""
46
+ if self.ssh and self.winrm:
47
+ message = " ".join(
48
+ (
49
+ f"{self.hostname} responded to both SSH (22) and WinRM",
50
+ "(5985/5986). Specify which to use.",
51
+ )
52
+ )
53
+ raise AmbiguousHostError(message)
54
+ if self.ssh:
55
+ return ServerType.LINUX
56
+ if self.winrm:
57
+ return ServerType.WINDOWS
58
+ message = " ".join(
59
+ (
60
+ f"{self.hostname} did not respond to SSH (22) or WinRM",
61
+ "(5985/5986).",
62
+ )
63
+ )
64
+ raise UnknownHostError(message)
65
+
66
+
67
+ def probe_host(
68
+ hostname: str, *, timeout: float = DEFAULT_PROBE_TIMEOUT
69
+ ) -> DetectionProbe:
70
+ """Probe the host for SSH and WinRM and return both binary signals."""
71
+ ssh = _probe(hostname, SSH_PORT, timeout)
72
+ winrm_https = _probe(hostname, WINRM_HTTPS_PORT, timeout)
73
+ winrm_http = _probe(hostname, WINRM_HTTP_PORT, timeout)
74
+ return DetectionProbe(
75
+ hostname=hostname,
76
+ ssh=ssh,
77
+ winrm=winrm_https or winrm_http,
78
+ )
@@ -0,0 +1,88 @@
1
+ """Persistence for saved connection aliases — parallels detect.py and auth.py."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import UTC, datetime
5
+
6
+ from cgate.db.connection import Database, connect
7
+ from cgate.db.rows import iso, row_to_connection
8
+ from cgate.db.types import Connection, ServerType
9
+
10
+
11
+ class ConnectionsRepo:
12
+ """Saved connection CRUD keyed by a user-defined alias."""
13
+
14
+ _db: Database # class-level annotation required by strict mode
15
+
16
+ def __init__(self, db: Database) -> None:
17
+ """Store the database handle for subsequent operations."""
18
+ self._db = db
19
+
20
+ def add(
21
+ self,
22
+ *,
23
+ alias: str,
24
+ hostname: str,
25
+ server_type: ServerType,
26
+ detection_ssh: bool,
27
+ detection_winrm: bool,
28
+ ) -> Connection:
29
+ """Save a connection with its detection signals and current UTC timestamp."""
30
+ now = datetime.now(UTC)
31
+ with connect(self._db) as conn:
32
+ _ = conn.execute(
33
+ """
34
+ INSERT INTO connections (alias, hostname, server_type,
35
+ detection_ssh, detection_winrm, created_at)
36
+ VALUES (?, ?, ?, ?, ?, ?)
37
+ """,
38
+ (
39
+ alias,
40
+ hostname,
41
+ server_type.value,
42
+ detection_ssh,
43
+ detection_winrm,
44
+ iso(now),
45
+ ),
46
+ )
47
+ return Connection(
48
+ alias=alias,
49
+ hostname=hostname,
50
+ server_type=server_type,
51
+ detection_ssh=detection_ssh,
52
+ detection_winrm=detection_winrm,
53
+ created_at=now,
54
+ )
55
+
56
+ def get(self, alias: str) -> Connection | None:
57
+ """Fetch a connection by alias, or None if it is absent."""
58
+ with connect(self._db) as conn:
59
+ row = conn.execute(
60
+ """
61
+ SELECT alias, hostname, server_type, detection_ssh, detection_winrm,
62
+ created_at
63
+ FROM connections WHERE alias = ?
64
+ """,
65
+ (alias,),
66
+ ).fetchone()
67
+ return row_to_connection(row) if row is not None else None
68
+
69
+ def list_all(self) -> list[Connection]:
70
+ """Return every saved connection ordered by alias."""
71
+ with connect(self._db) as conn:
72
+ rows = conn.execute(
73
+ """
74
+ SELECT alias, hostname, server_type, detection_ssh, detection_winrm,
75
+ created_at
76
+ FROM connections ORDER BY alias ASC
77
+ """
78
+ ).fetchall()
79
+ return [row_to_connection(row) for row in rows]
80
+
81
+ def remove(self, alias: str) -> bool:
82
+ """Delete a connection and report whether a row was removed."""
83
+ with connect(self._db) as conn:
84
+ cursor = conn.execute(
85
+ "DELETE FROM connections WHERE alias = ?",
86
+ (alias,),
87
+ )
88
+ return cursor.rowcount > 0
cgate/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from __future__ import annotations
cgate/core/path_env.py ADDED
@@ -0,0 +1,218 @@
1
+ r"""Per-user install directory and PATH registration mechanics.
2
+
3
+ Pure logic, no typer/rich -- testable without CliRunner. On Windows, every
4
+ registry call goes through the module-level ``winreg`` name (never a
5
+ function-local ``import winreg``) specifically so tests can replace it
6
+ with a fake and never touch the real ``HKEY_CURRENT_USER\Environment``
7
+ key on the machine running the test suite.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ctypes
13
+ import os
14
+ import shutil
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import Final
18
+
19
+ try:
20
+ import winreg
21
+ except ImportError: # non-Windows -- module must still import cleanly
22
+ winreg = None # type: ignore[assignment]
23
+
24
+ _ENV_SUBKEY: Final = "Environment"
25
+ _PATH_VALUE_NAME: Final = "Path"
26
+ _MARKER: Final = "# Added by cgate install"
27
+
28
+ # WM_SETTINGCHANGE broadcast constants (winuser.h).
29
+ _HWND_BROADCAST: Final = 0xFFFF
30
+ _WM_SETTINGCHANGE: Final = 0x001A
31
+ _SMTO_ABORTIFHUNG: Final = 0x0002
32
+ _BROADCAST_TIMEOUT_MS: Final = 5000
33
+
34
+
35
+ def install_dir() -> Path:
36
+ r"""Per-user directory ``cgate install`` copies the binary into.
37
+
38
+ Windows: ``%USERPROFILE%\bin`` (already the directory named in
39
+ TECHNICAL.md's manual instructions). macOS/Linux: ``~/.local/bin``.
40
+ No admin/sudo path exists or is planned -- user-level only.
41
+ """
42
+ if sys.platform == "win32":
43
+ return Path.home() / "bin"
44
+ return Path.home() / ".local" / "bin"
45
+
46
+
47
+ def install_target_path() -> Path:
48
+ """Where the installed binary itself should live.
49
+
50
+ A fixed filename ("cgate.exe" / "cgate"), not whatever the source
51
+ file happens to be named (e.g. ``cgate-windows-amd64.exe`` as
52
+ downloaded), so `cgate` on PATH resolves regardless of the download's
53
+ original name.
54
+ """
55
+ name = "cgate.exe" if sys.platform == "win32" else "cgate"
56
+ return install_dir() / name
57
+
58
+
59
+ def copy_binary(source: Path, target: Path) -> None:
60
+ """Copy ``source`` to ``target``, preserving the executable bit.
61
+
62
+ Uses ``shutil.copy2`` -- NOT ``copyfile``, which does not preserve
63
+ permission bits and would silently produce a non-executable copy on
64
+ macOS/Linux.
65
+ """
66
+ target.parent.mkdir(parents=True, exist_ok=True)
67
+ shutil.copy2(source, target)
68
+
69
+
70
+ # ---- Windows: HKCU\Environment\Path ----------------------------------
71
+
72
+
73
+ def ensure_windows_user_path(directory: Path) -> str:
74
+ r"""Ensure ``directory`` is present in ``HKCU\Environment\Path``.
75
+
76
+ Returns ``"already_present"`` or ``"added"``. Raises ``OSError`` on a
77
+ genuine registry failure. Windows-only -- the function has no hive
78
+ parameter, so it is structurally impossible for it to target
79
+ anything but ``HKEY_CURRENT_USER``; ``HKEY_LOCAL_MACHINE`` (which
80
+ would require admin rights) is never referenced anywhere in this
81
+ module.
82
+ """
83
+ assert winreg is not None, "ensure_windows_user_path must only be called on win32"
84
+ target = str(directory)
85
+
86
+ # CreateKeyEx (not OpenKey) so a brand-new user profile where the
87
+ # Environment key itself doesn't exist yet still works -- it opens or
88
+ # creates atomically instead of needing a separate existence check.
89
+ with winreg.CreateKeyEx(
90
+ winreg.HKEY_CURRENT_USER, _ENV_SUBKEY, 0, winreg.KEY_READ | winreg.KEY_WRITE
91
+ ) as key:
92
+ try:
93
+ current_value, value_type = winreg.QueryValueEx(key, _PATH_VALUE_NAME)
94
+ except FileNotFoundError:
95
+ # Rare but real: brand-new profile, Path value never created.
96
+ # REG_EXPAND_SZ is the type Windows itself uses for a
97
+ # freshly-created user Path value.
98
+ current_value, value_type = "", winreg.REG_EXPAND_SZ
99
+
100
+ if _path_dir_present(current_value, target):
101
+ return "already_present"
102
+
103
+ new_value = f"{current_value};{target}" if current_value else target
104
+ # value_type is exactly what was read above (or the default) --
105
+ # never assumed, never upgraded. We only ever append a
106
+ # fully-resolved literal directory (no "%...%" tokens of our
107
+ # own), so appending into a REG_EXPAND_SZ value is always safe.
108
+ winreg.SetValueEx(key, _PATH_VALUE_NAME, 0, value_type, new_value)
109
+
110
+ _broadcast_environment_change()
111
+ return "added"
112
+
113
+
114
+ def _path_dir_present(current_value: str, target: str) -> bool:
115
+ """Case-insensitive, ``;``-split, ``%VAR%``-tolerant membership check.
116
+
117
+ Deliberately does not call ``Path.resolve()`` (which touches the
118
+ filesystem and requires the path to exist) -- pure string
119
+ normalization, so it works identically whether or not the directory
120
+ exists yet.
121
+ """
122
+ target_norm = os.path.normcase(os.path.normpath(target))
123
+ for raw_entry in current_value.split(";"):
124
+ entry = raw_entry.strip()
125
+ if not entry:
126
+ continue
127
+ expanded = os.path.expandvars(entry) # handles %USERPROFILE%\bin etc.
128
+ if os.path.normcase(os.path.normpath(expanded)) == target_norm:
129
+ return True
130
+ return False
131
+
132
+
133
+ def _broadcast_environment_change() -> None:
134
+ """Best-effort WM_SETTINGCHANGE broadcast.
135
+
136
+ Lets newly-launched processes pick up the PATH change without a full
137
+ logoff (already-open shells still won't see it). Must never be
138
+ fatal -- wrapped broadly and always swallowed.
139
+
140
+ ``argtypes``/``restype`` are declared explicitly and are NOT
141
+ optional: without them ctypes defaults every argument to 32-bit
142
+ ``c_int`` on a 64-bit process, corrupting the HWND, the lParam
143
+ string pointer, and the output pointer -- silent memory corruption,
144
+ not just "might not work". The output parameter is ``DWORD_PTR``
145
+ (pointer-sized, 8 bytes on 64-bit Windows), so it uses ``c_size_t``,
146
+ not ``c_ulong`` (which stays 32-bit under Windows' LLP64 model and
147
+ would be undersized for the write).
148
+ """
149
+ if sys.platform != "win32":
150
+ return
151
+ try:
152
+ user32 = ctypes.windll.user32 # type: ignore[attr-defined]
153
+ send = user32.SendMessageTimeoutW
154
+ send.argtypes = [
155
+ ctypes.c_void_p, # HWND
156
+ ctypes.c_uint, # UINT Msg
157
+ ctypes.c_void_p, # WPARAM
158
+ ctypes.c_wchar_p, # LPARAM ("Environment")
159
+ ctypes.c_uint, # UINT fuFlags
160
+ ctypes.c_uint, # UINT uTimeout
161
+ ctypes.POINTER(ctypes.c_size_t), # PDWORD_PTR lpdwResult
162
+ ]
163
+ send.restype = ctypes.c_ssize_t # LRESULT
164
+ result = ctypes.c_size_t()
165
+ send(
166
+ ctypes.c_void_p(_HWND_BROADCAST),
167
+ _WM_SETTINGCHANGE,
168
+ None,
169
+ "Environment",
170
+ _SMTO_ABORTIFHUNG,
171
+ _BROADCAST_TIMEOUT_MS,
172
+ ctypes.byref(result),
173
+ )
174
+ except (OSError, AttributeError, TypeError):
175
+ pass
176
+
177
+
178
+ # ---- POSIX: shell rc file ---------------------------------------------
179
+
180
+
181
+ def detect_shell_rc() -> Path:
182
+ """Best-effort ``$SHELL``-based rc file detection.
183
+
184
+ A heuristic, not a guarantee: ``$SHELL`` reflects the login shell,
185
+ not necessarily whatever shell actually launched ``cgate install``,
186
+ and classic macOS bash setups that source ``.bash_profile`` instead
187
+ of ``.bashrc`` won't be picked up. Falls back to ``~/.profile`` (a
188
+ portable POSIX default) for anything unrecognized.
189
+ """
190
+ name = Path(os.environ.get("SHELL", "")).name
191
+ home = Path.home()
192
+ if name == "zsh":
193
+ return home / ".zshrc"
194
+ if name == "fish":
195
+ return home / ".config" / "fish" / "config.fish"
196
+ if name == "bash":
197
+ return home / ".bashrc"
198
+ return home / ".profile"
199
+
200
+
201
+ def ensure_posix_shell_path(directory: Path, rc_path: Path | None = None) -> tuple[str, Path]:
202
+ """Ensure ``directory`` is exported on PATH via a shell rc file.
203
+
204
+ Idempotent via ``_MARKER``. ``rc_path`` is injectable so tests never
205
+ touch a real home-directory dotfile. Returns ``(status, rc_path)``
206
+ where status is ``"already_present"`` or ``"added"``. Cannot affect
207
+ the currently-running parent shell -- the caller must tell the user
208
+ to restart their terminal or ``source`` the file.
209
+ """
210
+ rc_path = rc_path if rc_path is not None else detect_shell_rc()
211
+ existing = rc_path.read_text(encoding="utf-8") if rc_path.exists() else ""
212
+ if _MARKER in existing:
213
+ return "already_present", rc_path
214
+ rc_path.parent.mkdir(parents=True, exist_ok=True)
215
+ block = f'\n{_MARKER}\nexport PATH="{directory}:$PATH"\n'
216
+ with rc_path.open("a", encoding="utf-8") as f:
217
+ f.write(block)
218
+ return "added", rc_path
cgate/core/paths.py ADDED
@@ -0,0 +1,35 @@
1
+ """Per-user storage paths for command-gate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+
10
+ def data_dir() -> Path:
11
+ """Return the OS-appropriate per-user data directory for command-gate."""
12
+ app = "command-gate"
13
+ if os.name == "nt":
14
+ base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA")
15
+ if base is None:
16
+ base = str(Path.home() / "AppData" / "Local")
17
+ return Path(base) / app
18
+ if sys.platform == "darwin":
19
+ return Path.home() / "Library" / "Application Support" / app
20
+ base = os.environ.get("XDG_DATA_HOME")
21
+ if base:
22
+ return Path(base) / app
23
+ return Path.home() / ".local" / "share" / app
24
+
25
+
26
+ def db_path() -> Path:
27
+ """Return the path to the SQLite database.
28
+
29
+ Overridable via the CGATE_DB_PATH environment variable (used by tests
30
+ and by operators who want to point at an alternate location).
31
+ """
32
+ override = os.environ.get("CGATE_DB_PATH")
33
+ if override:
34
+ return Path(override)
35
+ return data_dir() / "cgate.db"
@@ -0,0 +1,36 @@
1
+ """Best-effort append-only logging for background update/uninstall operations.
2
+
3
+ Lives in ``cgate.core`` (not ``cgate.cli``) because it's shared by the
4
+ interactive CLI and the non-interactive ``cgate.helper`` binary, which must
5
+ not import anything from ``cgate.cli`` (typer/rich/mcp/paramiko/pywinrm).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import UTC, datetime
11
+
12
+ import cgate.core.paths
13
+
14
+ _LOG_FILENAME = "update.log"
15
+
16
+
17
+ def append_log(message: str) -> None:
18
+ """Append a timestamped line to ``<data_dir>/update.log``. Never raises.
19
+
20
+ Best-effort: any failure (permission denied, missing dir, encoding
21
+ errors) is silently swallowed. Logging must never crash the caller --
22
+ in particular, the helper process that runs after the main cgate
23
+ process has exited has no other way to surface failure.
24
+
25
+ Uses ``cgate.core.paths.data_dir`` (attribute lookup at call time)
26
+ rather than a module-level import so tests can monkeypatch the data
27
+ directory location.
28
+ """
29
+ try:
30
+ log_path = cgate.core.paths.data_dir() / _LOG_FILENAME
31
+ log_path.parent.mkdir(parents=True, exist_ok=True)
32
+ timestamp = datetime.now(UTC).isoformat(timespec="seconds")
33
+ with log_path.open("a", encoding="utf-8") as f:
34
+ f.write(f"[{timestamp}] {message}\n")
35
+ except Exception:
36
+ pass
cgate/db/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from __future__ import annotations
cgate/db/batches.py ADDED
@@ -0,0 +1,111 @@
1
+ """BatchesRepo: CRUD + FIFO queue queries for the `batches` table."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import UTC, datetime
5
+ from uuid import uuid4
6
+
7
+ from cgate.db.connection import Database, connect
8
+ from cgate.db.rows import iso, row_to_batch
9
+ from cgate.db.types import Batch, BatchId
10
+
11
+
12
+ class BatchesRepo:
13
+ """Batch CRUD + queue queries (FIFO pending list, mark resolved)."""
14
+
15
+ _db: Database # class-level annotation required by strict mode
16
+
17
+ def __init__(self, db: Database) -> None:
18
+ """Store the database handle for subsequent operations."""
19
+ self._db = db
20
+
21
+ def create(
22
+ self,
23
+ *,
24
+ title: str,
25
+ description: str | None,
26
+ requested_by_agent: str | None,
27
+ ) -> Batch:
28
+ """Create a new batch with a fresh UUID and the current UTC timestamp."""
29
+ batch_id = BatchId(str(uuid4()))
30
+ now = datetime.now(UTC)
31
+ with connect(self._db) as conn:
32
+ _ = conn.execute(
33
+ """
34
+ INSERT INTO batches
35
+ (id, title, description, requested_by_agent, created_at)
36
+ VALUES (?, ?, ?, ?, ?)
37
+ """,
38
+ (batch_id, title, description, requested_by_agent, iso(now)),
39
+ )
40
+ return Batch(
41
+ id=batch_id,
42
+ title=title,
43
+ description=description,
44
+ requested_by_agent=requested_by_agent,
45
+ created_at=now,
46
+ resolved_at=None,
47
+ )
48
+
49
+ def get(self, batch_id: BatchId) -> Batch | None:
50
+ """Fetch a batch by its ID, or None if not found."""
51
+ with connect(self._db) as conn:
52
+ row = conn.execute(
53
+ """
54
+ SELECT id, title, description, requested_by_agent, created_at, resolved_at
55
+ FROM batches WHERE id = ?
56
+ """,
57
+ (batch_id,),
58
+ ).fetchone()
59
+ return row_to_batch(row) if row is not None else None
60
+
61
+ def list_pending(self) -> list[Batch]:
62
+ """Return pending batches (resolved_at IS NULL), oldest first (FIFO)."""
63
+ with connect(self._db) as conn:
64
+ rows = conn.execute(
65
+ """
66
+ SELECT id, title, description, requested_by_agent, created_at, resolved_at
67
+ FROM batches
68
+ WHERE resolved_at IS NULL
69
+ ORDER BY created_at ASC, id ASC
70
+ """
71
+ ).fetchall()
72
+ return [row_to_batch(r) for r in rows]
73
+
74
+ def list_resolved(self, *, limit: int | None = 50) -> list[Batch]:
75
+ """Return resolved batches, most recently resolved first.
76
+
77
+ The live queue drops a batch the instant it resolves (`list_pending`
78
+ filters it out), so this is the only way to look back at anything
79
+ that already went through -- approved, rejected, or auto-executed.
80
+
81
+ ``limit=None`` returns every resolved batch (SQLite's own
82
+ ``LIMIT -1`` means "unlimited") -- used by ``cgate history export``,
83
+ where the whole audit trail is the point, unlike the History
84
+ screen's bounded browse list.
85
+ """
86
+ with connect(self._db) as conn:
87
+ rows = conn.execute(
88
+ """
89
+ SELECT id, title, description, requested_by_agent, created_at, resolved_at
90
+ FROM batches
91
+ WHERE resolved_at IS NOT NULL
92
+ ORDER BY resolved_at DESC
93
+ LIMIT ?
94
+ """,
95
+ (limit if limit is not None else -1,),
96
+ ).fetchall()
97
+ return [row_to_batch(r) for r in rows]
98
+
99
+ def mark_resolved(
100
+ self, batch_id: BatchId, *, resolved_at: datetime | None = None
101
+ ) -> None:
102
+ """Stamp resolved_at; no-op if the batch is already resolved."""
103
+ when = resolved_at if resolved_at is not None else datetime.now(UTC)
104
+ with connect(self._db) as conn:
105
+ _ = conn.execute(
106
+ """
107
+ UPDATE batches SET resolved_at = ?
108
+ WHERE id = ? AND resolved_at IS NULL
109
+ """,
110
+ (iso(when), batch_id),
111
+ )