ags-cli 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.
Files changed (156) hide show
  1. ags_cli-0.1.0.dist-info/METADATA +332 -0
  2. ags_cli-0.1.0.dist-info/RECORD +156 -0
  3. ags_cli-0.1.0.dist-info/WHEEL +5 -0
  4. ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. ags_cli-0.1.0.dist-info/top_level.txt +2 -0
  6. cli/__init__.py +0 -0
  7. cli/__main__.py +4 -0
  8. cli/client.py +145 -0
  9. cli/commands/__init__.py +0 -0
  10. cli/commands/adapter.py +33 -0
  11. cli/commands/artifact.py +21 -0
  12. cli/commands/ask.py +212 -0
  13. cli/commands/chat.py +649 -0
  14. cli/commands/diff.py +49 -0
  15. cli/commands/doctor.py +93 -0
  16. cli/commands/gui.py +45 -0
  17. cli/commands/init.py +20 -0
  18. cli/commands/mcp.py +50 -0
  19. cli/commands/memory.py +65 -0
  20. cli/commands/model.py +73 -0
  21. cli/commands/policy.py +52 -0
  22. cli/commands/project.py +122 -0
  23. cli/commands/quota.py +107 -0
  24. cli/commands/run.py +84 -0
  25. cli/commands/serve.py +171 -0
  26. cli/commands/service.py +236 -0
  27. cli/commands/session.py +219 -0
  28. cli/commands/stats.py +96 -0
  29. cli/commands/status.py +36 -0
  30. cli/commands/task.py +37 -0
  31. cli/commands/usage.py +86 -0
  32. cli/local.py +84 -0
  33. cli/main.py +149 -0
  34. harness/__init__.py +4 -0
  35. harness/adapters/__init__.py +26 -0
  36. harness/adapters/antigravity.py +301 -0
  37. harness/adapters/claude_code.py +522 -0
  38. harness/adapters/local_openai.py +534 -0
  39. harness/adapters/mock.py +112 -0
  40. harness/adapters/probe.py +65 -0
  41. harness/adapters/registry.py +56 -0
  42. harness/adapters/warm_pool.py +255 -0
  43. harness/api/__init__.py +0 -0
  44. harness/api/router.py +38 -0
  45. harness/api/v1/__init__.py +0 -0
  46. harness/api/v1/adapters.py +105 -0
  47. harness/api/v1/approvals.py +173 -0
  48. harness/api/v1/artifacts.py +44 -0
  49. harness/api/v1/attachments.py +48 -0
  50. harness/api/v1/doctor.py +22 -0
  51. harness/api/v1/health.py +37 -0
  52. harness/api/v1/mcp.py +131 -0
  53. harness/api/v1/memory.py +80 -0
  54. harness/api/v1/policies.py +49 -0
  55. harness/api/v1/project.py +302 -0
  56. harness/api/v1/runs.py +98 -0
  57. harness/api/v1/sessions.py +248 -0
  58. harness/api/v1/stream.py +110 -0
  59. harness/api/v1/tasks.py +30 -0
  60. harness/api/v1/usage.py +58 -0
  61. harness/bootstrap.py +216 -0
  62. harness/db.py +164 -0
  63. harness/deps.py +58 -0
  64. harness/eventbus/__init__.py +20 -0
  65. harness/eventbus/inprocess_bus.py +85 -0
  66. harness/main.py +144 -0
  67. harness/mcp/__init__.py +22 -0
  68. harness/mcp/client.py +139 -0
  69. harness/mcp/config_gen.py +77 -0
  70. harness/mcp/registry.py +156 -0
  71. harness/mcp/tools.py +81 -0
  72. harness/memory/__init__.py +13 -0
  73. harness/memory/context_budget.py +116 -0
  74. harness/memory/embed.py +217 -0
  75. harness/memory/extract.py +74 -0
  76. harness/memory/ingest.py +106 -0
  77. harness/memory/namespaces.py +57 -0
  78. harness/memory/ranking.py +31 -0
  79. harness/memory/redact.py +37 -0
  80. harness/memory/service.py +320 -0
  81. harness/memory/sqlite_vec_backend.py +266 -0
  82. harness/memory/summarize.py +68 -0
  83. harness/models/__init__.py +35 -0
  84. harness/models/adapter_health.py +27 -0
  85. harness/models/approval.py +37 -0
  86. harness/models/artifact.py +35 -0
  87. harness/models/audit_log.py +33 -0
  88. harness/models/comparison.py +33 -0
  89. harness/models/enums.py +146 -0
  90. harness/models/mcp_server_config.py +49 -0
  91. harness/models/memory.py +76 -0
  92. harness/models/policy.py +29 -0
  93. harness/models/provider_config.py +35 -0
  94. harness/models/run.py +46 -0
  95. harness/models/run_event.py +35 -0
  96. harness/models/session.py +50 -0
  97. harness/models/task.py +30 -0
  98. harness/models/types.py +63 -0
  99. harness/models/workspace.py +27 -0
  100. harness/observability/__init__.py +4 -0
  101. harness/observability/audit.py +88 -0
  102. harness/observability/logging.py +47 -0
  103. harness/observability/metrics.py +43 -0
  104. harness/observability/tracing.py +38 -0
  105. harness/orchestrator/__init__.py +19 -0
  106. harness/orchestrator/engine.py +821 -0
  107. harness/orchestrator/handoff.py +33 -0
  108. harness/orchestrator/lifecycle.py +69 -0
  109. harness/orchestrator/native_resume.py +93 -0
  110. harness/orchestrator/policies.py +101 -0
  111. harness/orchestrator/reconcile.py +39 -0
  112. harness/orchestrator/run_graph.py +62 -0
  113. harness/orchestrator/snapshot.py +49 -0
  114. harness/schemas/__init__.py +1 -0
  115. harness/schemas/adapter.py +26 -0
  116. harness/schemas/approval.py +25 -0
  117. harness/schemas/artifact.py +17 -0
  118. harness/schemas/common.py +20 -0
  119. harness/schemas/memory.py +50 -0
  120. harness/schemas/policy.py +39 -0
  121. harness/schemas/run.py +116 -0
  122. harness/schemas/session.py +93 -0
  123. harness/schemas/task.py +24 -0
  124. harness/security/__init__.py +5 -0
  125. harness/security/approval_policy.py +107 -0
  126. harness/security/auth.py +106 -0
  127. harness/security/permissions.py +59 -0
  128. harness/security/risk.py +59 -0
  129. harness/security/secrets.py +49 -0
  130. harness/services/__init__.py +1 -0
  131. harness/services/adapters_health.py +212 -0
  132. harness/services/approvals.py +84 -0
  133. harness/services/artifacts.py +78 -0
  134. harness/services/attachments.py +228 -0
  135. harness/services/comparison.py +345 -0
  136. harness/services/doctor.py +156 -0
  137. harness/services/files.py +287 -0
  138. harness/services/mcp_servers.py +179 -0
  139. harness/services/project_git.py +101 -0
  140. harness/services/retention.py +97 -0
  141. harness/services/runs.py +155 -0
  142. harness/services/runs_diff.py +201 -0
  143. harness/services/sessions.py +242 -0
  144. harness/services/ssh.py +300 -0
  145. harness/services/stats.py +132 -0
  146. harness/services/tasks.py +41 -0
  147. harness/services/workspaces.py +184 -0
  148. harness/services/worktrees.py +439 -0
  149. harness/settings.py +186 -0
  150. harness/skills/__init__.py +11 -0
  151. harness/skills/manager.py +141 -0
  152. harness/tools/__init__.py +1 -0
  153. harness/tools/fs_tools.py +295 -0
  154. harness/workers/__init__.py +0 -0
  155. harness/workers/dispatch.py +26 -0
  156. harness/workers/inprocess.py +135 -0
@@ -0,0 +1,287 @@
1
+ """Project-scoped filesystem access for the GUI file explorer.
2
+
3
+ Every path is resolved (dereferencing symlinks) and asserted to live *inside* the
4
+ workspace's active ``project_dir`` before any read **or write** — the local server has
5
+ no auth in dev and may be bound to ``0.0.0.0``, so ``_safe_join`` is the only thing
6
+ standing between the LAN and the whole filesystem. That guard matters even more for the
7
+ mutating helpers below (write / create / rename / delete): every one of them routes its
8
+ path(s) through ``_safe_join`` first. Deletes are *soft* — entries move into a
9
+ ``.ags-trash/`` folder at the project root rather than being unlinked — so an accidental
10
+ call over an unauthenticated bind is recoverable. Sync helpers so they stay off the
11
+ async hot path.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import shutil
18
+ import subprocess
19
+ from pathlib import Path
20
+
21
+ # Files larger than this are not returned as text (the GUI shows a placeholder instead).
22
+ MAX_FILE_BYTES = 1_000_000
23
+
24
+ # Directory (relative to the project root) that soft-deleted entries are moved into.
25
+ TRASH_DIRNAME = ".ags-trash"
26
+
27
+ # Bounds for the search / quick-open walks so a huge tree can't hang the event loop.
28
+ _WALK_MAX_FILES = 20_000
29
+ _SEARCH_MAX_MATCHES = 500
30
+ _FIND_MAX_MATCHES = 200
31
+ # Directory names skipped when walking for search / quick-open.
32
+ _SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", TRASH_DIRNAME}
33
+
34
+
35
+ def _safe_join(project_dir: str, subpath: str) -> Path:
36
+ """Resolve ``subpath`` relative to ``project_dir`` and assert it stays within the
37
+ resolved root. Raises ``ValueError`` on traversal / symlink escape (the same
38
+ ``resolve() + relative_to`` check as ``PermissionContext.path_allowed``)."""
39
+ root = Path(project_dir).resolve()
40
+ target = (root / (subpath or "")).resolve()
41
+ try:
42
+ target.relative_to(root)
43
+ except ValueError as exc:
44
+ raise ValueError(f"path outside project folder: {subpath!r}") from exc
45
+ return target
46
+
47
+
48
+ def list_dir(project_dir: str, subpath: str = "") -> dict:
49
+ """List immediate entries of ``project_dir/subpath`` — dirs first, then files, each
50
+ sorted case-insensitively. Dotfiles are hidden (matches ``browse_dirs``). Returns
51
+ ``{ root, subpath, parent, entries: [{name, type, size}] }``."""
52
+ target = _safe_join(project_dir, subpath)
53
+ if not target.is_dir():
54
+ raise ValueError(f"not a directory: {subpath!r}")
55
+
56
+ dirs: list[dict] = []
57
+ files: list[dict] = []
58
+ try:
59
+ for entry in os.scandir(target):
60
+ if entry.name.startswith("."):
61
+ continue
62
+ if entry.is_dir():
63
+ dirs.append({"name": entry.name, "type": "dir", "size": 0})
64
+ elif entry.is_file():
65
+ try:
66
+ size = entry.stat().st_size
67
+ except OSError:
68
+ size = 0
69
+ files.append({"name": entry.name, "type": "file", "size": size})
70
+ except OSError:
71
+ pass
72
+ dirs.sort(key=lambda e: e["name"].lower())
73
+ files.sort(key=lambda e: e["name"].lower())
74
+
75
+ norm = "" if subpath in ("", ".") else os.path.normpath(subpath).strip("/")
76
+ parent = os.path.dirname(norm) if norm else None
77
+ return {
78
+ "root": str(Path(project_dir).resolve()),
79
+ "subpath": norm,
80
+ "parent": parent,
81
+ "entries": dirs + files,
82
+ }
83
+
84
+
85
+ def read_file(project_dir: str, subpath: str, max_bytes: int = MAX_FILE_BYTES) -> dict:
86
+ """Read ``project_dir/subpath`` as UTF-8 text. Returns
87
+ ``{ name, subpath, size, truncated, binary, text }``. Over the size cap →
88
+ ``truncated`` with no text; non-UTF-8 bytes → ``binary`` with no text."""
89
+ target = _safe_join(project_dir, subpath)
90
+ if not target.is_file():
91
+ raise ValueError(f"not a file: {subpath!r}")
92
+
93
+ size = target.stat().st_size
94
+ base = {"name": target.name, "subpath": os.path.normpath(subpath).strip("/"), "size": size}
95
+ if size > max_bytes:
96
+ return {**base, "truncated": True, "binary": False, "text": None}
97
+ raw = target.read_bytes()
98
+ try:
99
+ text = raw.decode("utf-8")
100
+ except UnicodeDecodeError:
101
+ return {**base, "truncated": False, "binary": True, "text": None}
102
+ return {**base, "truncated": False, "binary": False, "text": text}
103
+
104
+
105
+ def file_path(project_dir: str, subpath: str) -> Path:
106
+ """Resolve ``subpath`` to an existing file path inside the project, for raw byte
107
+ serving (images, downloads). Raises ``ValueError`` on traversal or non-file."""
108
+ target = _safe_join(project_dir, subpath)
109
+ if not target.is_file():
110
+ raise ValueError(f"not a file: {subpath!r}")
111
+ return target
112
+
113
+
114
+ def write_file(project_dir: str, subpath: str, text: str, max_bytes: int = MAX_FILE_BYTES) -> dict:
115
+ """Write UTF-8 ``text`` to ``project_dir/subpath``, creating the file if needed.
116
+ Refuses paths that resolve to a directory or exceed ``max_bytes``. Parent
117
+ directories must already exist. Returns the same shape as :func:`read_file`."""
118
+ encoded = text.encode("utf-8")
119
+ if len(encoded) > max_bytes:
120
+ raise ValueError(f"file too large: {len(encoded)} bytes > {max_bytes} cap")
121
+ target = _safe_join(project_dir, subpath)
122
+ if target.is_dir():
123
+ raise ValueError(f"is a directory: {subpath!r}")
124
+ if not target.parent.is_dir():
125
+ raise ValueError(f"parent folder does not exist: {subpath!r}")
126
+ target.write_bytes(encoded)
127
+ return read_file(project_dir, subpath, max_bytes=max_bytes)
128
+
129
+
130
+ def create_entry(project_dir: str, subpath: str, kind: str) -> dict:
131
+ """Create a new empty file (``kind="file"``) or directory (``kind="dir"``) at
132
+ ``subpath``. Creates missing parent directories. Raises ``ValueError`` if the
133
+ entry already exists or ``kind`` is invalid. Returns ``{subpath, type}``."""
134
+ if kind not in ("file", "dir"):
135
+ raise ValueError(f"invalid kind: {kind!r} (expected 'file' or 'dir')")
136
+ target = _safe_join(project_dir, subpath)
137
+ if target.exists():
138
+ raise ValueError(f"already exists: {subpath!r}")
139
+ if kind == "dir":
140
+ target.mkdir(parents=True)
141
+ else:
142
+ target.parent.mkdir(parents=True, exist_ok=True)
143
+ target.touch()
144
+ norm = os.path.normpath(subpath).strip("/")
145
+ return {"subpath": norm, "type": kind}
146
+
147
+
148
+ def rename_entry(project_dir: str, src: str, dst: str) -> dict:
149
+ """Rename/move ``src`` to ``dst`` (both project-scoped). Raises ``ValueError`` when
150
+ ``src`` is missing, ``dst`` already exists, or ``dst``'s parent is missing.
151
+ Returns ``{src, dst}`` with normalized paths."""
152
+ src_path = _safe_join(project_dir, src)
153
+ dst_path = _safe_join(project_dir, dst)
154
+ if not src_path.exists():
155
+ raise ValueError(f"not found: {src!r}")
156
+ if dst_path.exists():
157
+ raise ValueError(f"already exists: {dst!r}")
158
+ if not dst_path.parent.is_dir():
159
+ raise ValueError(f"destination folder does not exist: {dst!r}")
160
+ src_path.rename(dst_path)
161
+ return {"src": os.path.normpath(src).strip("/"), "dst": os.path.normpath(dst).strip("/")}
162
+
163
+
164
+ def delete_entry(project_dir: str, subpath: str) -> dict:
165
+ """Soft-delete ``subpath`` by moving it into ``<project>/.ags-trash/``. A name
166
+ collision in the trash is resolved with a numeric suffix. Raises ``ValueError``
167
+ if the entry is missing or is the trash folder itself. Returns
168
+ ``{subpath, trashed_to}`` (trash path relative to the project root)."""
169
+ target = _safe_join(project_dir, subpath)
170
+ if not target.exists():
171
+ raise ValueError(f"not found: {subpath!r}")
172
+ trash = _safe_join(project_dir, TRASH_DIRNAME)
173
+ if target == trash:
174
+ raise ValueError("cannot delete the trash folder")
175
+ trash.mkdir(parents=True, exist_ok=True)
176
+ dest = trash / target.name
177
+ suffix = 1
178
+ while dest.exists():
179
+ dest = trash / f"{target.name}.{suffix}"
180
+ suffix += 1
181
+ shutil.move(str(target), str(dest))
182
+ return {
183
+ "subpath": os.path.normpath(subpath).strip("/"),
184
+ "trashed_to": f"{TRASH_DIRNAME}/{dest.name}",
185
+ }
186
+
187
+
188
+ def _iter_files(root: Path):
189
+ """Yield ``(abs_path, rel_posix)`` for files under ``root``, skipping hidden and
190
+ heavy directories, bounded by ``_WALK_MAX_FILES``."""
191
+ count = 0
192
+ for dirpath, dirnames, filenames in os.walk(root):
193
+ dirnames[:] = [d for d in dirnames if not d.startswith(".") and d not in _SKIP_DIRS]
194
+ for name in filenames:
195
+ if name.startswith("."):
196
+ continue
197
+ abs_path = Path(dirpath) / name
198
+ rel = abs_path.relative_to(root).as_posix()
199
+ yield abs_path, rel
200
+ count += 1
201
+ if count >= _WALK_MAX_FILES:
202
+ return
203
+
204
+
205
+ def find_files(project_dir: str, query: str, limit: int = _FIND_MAX_MATCHES) -> dict:
206
+ """Quick-open: return files whose relative path contains every whitespace-separated
207
+ token of ``query`` (case-insensitive). Empty query returns no matches. Result:
208
+ ``{query, truncated, matches: [{subpath, name}]}``."""
209
+ root = Path(project_dir).resolve()
210
+ tokens = [t.lower() for t in query.split() if t]
211
+ matches: list[dict] = []
212
+ truncated = False
213
+ if tokens:
214
+ for _abs, rel in _iter_files(root):
215
+ low = rel.lower()
216
+ if all(t in low for t in tokens):
217
+ matches.append({"subpath": rel, "name": rel.rsplit("/", 1)[-1]})
218
+ if len(matches) >= limit:
219
+ truncated = True
220
+ break
221
+ matches.sort(key=lambda m: (len(m["subpath"]), m["subpath"].lower()))
222
+ return {"query": query, "truncated": truncated, "matches": matches}
223
+
224
+
225
+ def search_content(project_dir: str, query: str, limit: int = _SEARCH_MAX_MATCHES) -> dict:
226
+ """Full-text search across project files. Prefers ``ripgrep`` (``rg``) when on PATH,
227
+ else a bounded Python walk. Case-insensitive, fixed-string match. Result:
228
+ ``{query, truncated, matches: [{subpath, line, text}]}``."""
229
+ root = Path(project_dir).resolve()
230
+ if not query:
231
+ return {"query": query, "truncated": False, "matches": []}
232
+ rg = shutil.which("rg")
233
+ if rg:
234
+ return _search_ripgrep(rg, root, query, limit)
235
+ return _search_python(root, query, limit)
236
+
237
+
238
+ def _search_ripgrep(rg: str, root: Path, query: str, limit: int) -> dict:
239
+ """ripgrep fixed-string, case-insensitive search. ``query`` is passed as a single
240
+ argv element (never a shell string), so it can't inject flags/commands."""
241
+ args = [
242
+ rg, "--fixed-strings", "--ignore-case", "--line-number", "--no-heading",
243
+ "--with-filename", "--max-count", "50", "--color", "never",
244
+ "-g", f"!{TRASH_DIRNAME}", "--", query, ".",
245
+ ]
246
+ try:
247
+ proc = subprocess.run(args, cwd=root, capture_output=True, text=True, timeout=30)
248
+ except (OSError, subprocess.TimeoutExpired):
249
+ return _search_python(root, query, limit)
250
+ matches: list[dict] = []
251
+ truncated = False
252
+ for raw in proc.stdout.splitlines():
253
+ # Format: path:line:text (path may itself contain ':' — split at most twice).
254
+ parts = raw.split(":", 2)
255
+ if len(parts) < 3:
256
+ continue
257
+ path, lineno, text = parts
258
+ try:
259
+ line = int(lineno)
260
+ except ValueError:
261
+ continue
262
+ matches.append({"subpath": path.lstrip("./"), "line": line, "text": text[:400]})
263
+ if len(matches) >= limit:
264
+ truncated = True
265
+ break
266
+ return {"query": query, "truncated": truncated, "matches": matches}
267
+
268
+
269
+ def _search_python(root: Path, query: str, limit: int) -> dict:
270
+ """Fallback content search: walk files and scan UTF-8 lines for ``query`` (ci)."""
271
+ needle = query.lower()
272
+ matches: list[dict] = []
273
+ truncated = False
274
+ for abs_path, rel in _iter_files(root):
275
+ try:
276
+ if abs_path.stat().st_size > MAX_FILE_BYTES:
277
+ continue
278
+ raw = abs_path.read_bytes()
279
+ text = raw.decode("utf-8")
280
+ except (OSError, UnicodeDecodeError):
281
+ continue
282
+ for i, line in enumerate(text.splitlines(), start=1):
283
+ if needle in line.lower():
284
+ matches.append({"subpath": rel, "line": i, "text": line[:400]})
285
+ if len(matches) >= limit:
286
+ return {"query": query, "truncated": True, "matches": matches}
287
+ return {"query": query, "truncated": truncated, "matches": matches}
@@ -0,0 +1,179 @@
1
+ """GUI-managed MCP servers — create/update/delete that take effect immediately.
2
+
3
+ Mirrors the provider-management service: each GUI-added server is persisted durably as
4
+ a ``~/.ags/mcp.d/<name>.yaml`` overlay (so a re-seed/restart keeps it) AND upserted into
5
+ the ``mcp_server_configs`` table. The engine reads MCP servers from the DB live on every
6
+ run, so a change here is in effect on the next run with **no restart**.
7
+
8
+ Servers declared in ``config.yaml`` are read-only from the GUI (edit the file instead);
9
+ only overlay-managed servers can be updated or deleted. An optional bearer token is held
10
+ as a ``0600`` file secret (``secret_ref``), never in the DB.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ import yaml
18
+ from sqlalchemy import select
19
+ from sqlalchemy.ext.asyncio import AsyncSession
20
+
21
+ from harness.models.enums import McpTransport
22
+ from harness.models.mcp_server_config import McpServerConfig
23
+ from harness.observability.audit import write_audit
24
+ from harness.security.secrets import delete_secret_file, write_secret_file
25
+ from harness.settings import MCP_DIR, get_settings
26
+
27
+ _NAME_RE = re.compile(r"[a-z0-9][a-z0-9_-]{0,63}")
28
+
29
+
30
+ def _server_file(name: str):
31
+ return MCP_DIR / f"{name}.yaml"
32
+
33
+
34
+ def managed_mcp_names() -> set[str]:
35
+ """Names of GUI-added (overlay) MCP servers — the editable/deletable ones."""
36
+ if not MCP_DIR.is_dir():
37
+ return set()
38
+ return {f.stem for f in MCP_DIR.glob("*.yaml")}
39
+
40
+
41
+ async def list_mcp_servers(db: AsyncSession) -> list[McpServerConfig]:
42
+ return list(
43
+ (await db.execute(select(McpServerConfig).order_by(McpServerConfig.name))).scalars()
44
+ )
45
+
46
+
47
+ async def _named(db: AsyncSession, name: str) -> McpServerConfig | None:
48
+ return (
49
+ await db.execute(select(McpServerConfig).where(McpServerConfig.name == name))
50
+ ).scalar_one_or_none()
51
+
52
+
53
+ def _entry(
54
+ *, name: str, transport: str, command, args, env, url, headers,
55
+ secret_ref, tool_allowlist, bind, read_only, enabled,
56
+ ) -> dict:
57
+ """The overlay-file shape (also used to set DB columns)."""
58
+ return {
59
+ "name": name, "transport": transport, "command": command, "args": args or [],
60
+ "env": env or {}, "url": url, "headers": headers or {}, "secret_ref": secret_ref,
61
+ "tool_allowlist": tool_allowlist or [], "bind": bind or {},
62
+ "read_only": bool(read_only), "enabled": bool(enabled),
63
+ }
64
+
65
+
66
+ def _apply(row: McpServerConfig, e: dict) -> None:
67
+ row.transport = McpTransport(e["transport"])
68
+ row.command = e.get("command")
69
+ row.args = e.get("args") or []
70
+ row.env = e.get("env") or {}
71
+ row.url = e.get("url")
72
+ row.headers = e.get("headers") or {}
73
+ row.secret_ref = e.get("secret_ref")
74
+ row.tool_allowlist = e.get("tool_allowlist") or []
75
+ row.bind = e.get("bind") or {}
76
+ row.read_only = bool(e.get("read_only"))
77
+ row.enabled = bool(e.get("enabled", True))
78
+
79
+
80
+ def _write_overlay(name: str, e: dict) -> None:
81
+ MCP_DIR.mkdir(parents=True, exist_ok=True)
82
+ _server_file(name).write_text(yaml.safe_dump(e, sort_keys=False))
83
+ get_settings().reset_config_cache()
84
+
85
+
86
+ async def create_mcp_server(
87
+ db: AsyncSession, *, name: str, transport: str = "stdio",
88
+ command: str | None = None, args: list | None = None, env: dict | None = None,
89
+ url: str | None = None, headers: dict | None = None, token: str | None = None,
90
+ tool_allowlist: list | None = None, read_only: bool = False, enabled: bool = True,
91
+ actor: str = "gui",
92
+ ) -> dict:
93
+ name = (name or "").strip().lower()
94
+ if not _NAME_RE.fullmatch(name):
95
+ raise ValueError("name must be lowercase letters/digits/-/_ (e.g. 'github')")
96
+ if transport not in ("stdio", "http"):
97
+ raise ValueError("transport must be 'stdio' or 'http'")
98
+ if transport == "stdio" and not (command or "").strip():
99
+ raise ValueError("stdio transport requires a command")
100
+ if transport == "http" and not (url or "").strip():
101
+ raise ValueError("http transport requires a url")
102
+ if await _named(db, name):
103
+ raise ValueError(f"MCP server '{name}' already exists")
104
+
105
+ secret_ref = (
106
+ write_secret_file(f"mcp-{name}", token.strip()) if token and token.strip() else None
107
+ )
108
+ e = _entry(
109
+ name=name, transport=transport, command=command, args=args, env=env, url=url,
110
+ headers=headers, secret_ref=secret_ref, tool_allowlist=tool_allowlist, bind={},
111
+ read_only=read_only, enabled=enabled,
112
+ )
113
+ _write_overlay(name, e)
114
+ row = McpServerConfig(name=name)
115
+ _apply(row, e)
116
+ db.add(row)
117
+ await db.flush()
118
+ await write_audit(
119
+ db, actor=actor, action="create_mcp_server", target_type="mcp_server", target_id=name,
120
+ before=None, after={"transport": transport, "has_token": bool(secret_ref)},
121
+ )
122
+ await db.commit() # durable + live before we respond
123
+ return {"name": name, "transport": transport, "enabled": enabled}
124
+
125
+
126
+ async def update_mcp_server(
127
+ db: AsyncSession, name: str, *, fields: dict, actor: str = "gui"
128
+ ) -> dict:
129
+ """Update an overlay-managed server. ``fields`` may include transport/command/args/
130
+ env/url/headers/token/tool_allowlist/read_only/enabled (only the keys present)."""
131
+ if not _server_file(name).exists():
132
+ raise ValueError(f"'{name}' is not a GUI-managed MCP server (edit config.yaml instead)")
133
+ row = await _named(db, name)
134
+ if row is None:
135
+ raise ValueError(f"MCP server '{name}' not found")
136
+ current = yaml.safe_load(_server_file(name).read_text()) or {}
137
+
138
+ # Token: a non-empty value rotates the file secret; an explicit empty string clears it.
139
+ if "token" in fields:
140
+ tok = (fields.pop("token") or "").strip()
141
+ if tok:
142
+ current["secret_ref"] = write_secret_file(f"mcp-{name}", tok)
143
+ else:
144
+ delete_secret_file(f"mcp-{name}")
145
+ current["secret_ref"] = None
146
+ current.update({k: v for k, v in fields.items() if k != "token"})
147
+ e = _entry(
148
+ name=name, transport=current.get("transport", "stdio"), command=current.get("command"),
149
+ args=current.get("args"), env=current.get("env"), url=current.get("url"),
150
+ headers=current.get("headers"), secret_ref=current.get("secret_ref"),
151
+ tool_allowlist=current.get("tool_allowlist"), bind=current.get("bind"),
152
+ read_only=current.get("read_only", False), enabled=current.get("enabled", True),
153
+ )
154
+ _write_overlay(name, e)
155
+ _apply(row, e)
156
+ await db.flush()
157
+ await write_audit(
158
+ db, actor=actor, action="update_mcp_server", target_type="mcp_server", target_id=name,
159
+ before=None, after={"enabled": e["enabled"], "transport": e["transport"]},
160
+ )
161
+ await db.commit()
162
+ return {"name": name, "enabled": e["enabled"]}
163
+
164
+
165
+ async def delete_mcp_server(db: AsyncSession, name: str, *, actor: str = "gui") -> dict:
166
+ if not _server_file(name).exists():
167
+ raise ValueError(f"'{name}' is not a GUI-managed MCP server (edit config.yaml instead)")
168
+ _server_file(name).unlink()
169
+ delete_secret_file(f"mcp-{name}")
170
+ get_settings().reset_config_cache()
171
+ if (row := await _named(db, name)) is not None:
172
+ await db.delete(row)
173
+ await db.flush()
174
+ await write_audit(
175
+ db, actor=actor, action="delete_mcp_server", target_type="mcp_server", target_id=name,
176
+ before={"name": name}, after=None,
177
+ )
178
+ await db.commit()
179
+ return {"deleted": name}
@@ -0,0 +1,101 @@
1
+ """Git status + per-file diff for the GUI Files panel, scoped to a workspace's active
2
+ ``project_dir``.
3
+
4
+ Thin wrapper over the git subprocess helpers already used elsewhere: it reuses
5
+ ``worktrees._git`` (a timed, checked ``git`` runner) and ``worktrees.is_git_repo``, and
6
+ mirrors the capped-diff pattern in ``runs_diff._git_diff``. All work runs sync (called
7
+ via ``asyncio.to_thread`` from the API layer). Not-a-git-repo is a soft, empty result —
8
+ the panel simply shows no badges — rather than an error.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import subprocess
14
+
15
+ from harness.services.worktrees import is_git_repo
16
+
17
+ # Cap diff output so a huge change can't blow up the response (matches runs_diff).
18
+ _MAX_DIFF_BYTES = 1_000_000
19
+
20
+
21
+ # Map porcelain-v1 XY status codes to a single-letter class the UI renders as a badge.
22
+ def _classify(xy: str) -> str:
23
+ """Collapse a 2-char porcelain status (e.g. ' M', 'A ', '??', 'R ') to one of
24
+ ``modified`` | ``added`` | ``deleted`` | ``renamed`` | ``untracked``."""
25
+ if xy == "??":
26
+ return "untracked"
27
+ codes = xy.strip()
28
+ if "R" in codes:
29
+ return "renamed"
30
+ if "D" in codes:
31
+ return "deleted"
32
+ if "A" in codes:
33
+ return "added"
34
+ return "modified"
35
+
36
+
37
+ def git_status(project_dir: str) -> dict:
38
+ """Return ``{is_repo, files: {subpath: status}}`` for the working tree.
39
+
40
+ ``status`` is one of modified/added/deleted/renamed/untracked. Paths are
41
+ project-relative POSIX (as git reports them). Non-git dirs return
42
+ ``{is_repo: False, files: {}}`` rather than raising."""
43
+ if not is_git_repo(project_dir):
44
+ return {"is_repo": False, "files": {}}
45
+ try:
46
+ # Run directly (not via the shared ``_git`` helper): that helper strips stdout,
47
+ # which would eat the leading status column of the first porcelain line and
48
+ # corrupt the fixed-width XY parsing below.
49
+ proc = subprocess.run(
50
+ ["git", "status", "--porcelain"], cwd=project_dir,
51
+ capture_output=True, text=True, timeout=60,
52
+ )
53
+ except (subprocess.TimeoutExpired, OSError):
54
+ return {"is_repo": True, "files": {}}
55
+ if proc.returncode != 0:
56
+ return {"is_repo": True, "files": {}}
57
+ files: dict[str, str] = {}
58
+ for line in proc.stdout.splitlines():
59
+ if len(line) < 3:
60
+ continue
61
+ xy = line[:2]
62
+ # porcelain v1: path after the XY+space; "old -> new" on renames.
63
+ name = line[3:].split(" -> ")[-1].strip().strip('"')
64
+ if name:
65
+ files[name] = _classify(xy)
66
+ return {"is_repo": True, "files": files}
67
+
68
+
69
+ def file_diff(project_dir: str, subpath: str) -> dict:
70
+ """Return the working-tree diff for a single file vs HEAD (staged + unstaged).
71
+
72
+ Result: ``{subpath, diff, truncated, is_repo}``. For an untracked file (no HEAD
73
+ blob) falls back to ``git diff --no-index /dev/null <file>`` so new files still
74
+ show content. Non-git dirs return an empty diff with ``is_repo: False``."""
75
+ if not is_git_repo(project_dir):
76
+ return {"subpath": subpath, "diff": "", "truncated": False, "is_repo": False}
77
+ diff = _run_diff(project_dir, ["git", "diff", "HEAD", "--", subpath])
78
+ if not diff:
79
+ # Untracked file: HEAD has no blob, so `git diff HEAD` is empty. Compare to
80
+ # an empty tree to surface the full new-file content.
81
+ diff = _run_diff(
82
+ project_dir, ["git", "diff", "--no-index", "--", "/dev/null", subpath]
83
+ )
84
+ truncated = False
85
+ if len(diff.encode("utf-8", "replace")) > _MAX_DIFF_BYTES:
86
+ diff = diff.encode("utf-8", "replace")[:_MAX_DIFF_BYTES].decode("utf-8", "ignore")
87
+ truncated = True
88
+ return {"subpath": subpath, "diff": diff, "truncated": truncated, "is_repo": True}
89
+
90
+
91
+ def _run_diff(project_dir: str, argv: list[str]) -> str:
92
+ """Run a ``git diff`` variant, returning stdout. ``git diff`` exits non-zero (1)
93
+ simply when differences exist, and ``--no-index`` always exits 1 on a diff, so we
94
+ return stdout regardless of return code (unlike the checked ``_git`` helper)."""
95
+ try:
96
+ result = subprocess.run(
97
+ argv, cwd=project_dir, capture_output=True, text=True, timeout=60
98
+ )
99
+ except (OSError, subprocess.TimeoutExpired):
100
+ return ""
101
+ return result.stdout or ""
@@ -0,0 +1,97 @@
1
+ """Ledger retention service — compact old sessions by pruning bulky run events.
2
+
3
+ Token and tool_result events are large and reconstructible from the transcript
4
+ artifact (written by ``_persist_artifacts`` in engine.py). After compaction the
5
+ essential audit trail (message, status, cost, adapter_meta, tool_call, error,
6
+ artifact) remains intact.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from datetime import UTC, datetime, timedelta
12
+
13
+ from sqlalchemy import delete, func, select
14
+ from sqlalchemy.ext.asyncio import AsyncSession
15
+
16
+ from harness.models.enums import RunEventType, SessionStatus
17
+ from harness.models.run import Run
18
+ from harness.models.run_event import RunEvent
19
+ from harness.models.session import Session
20
+
21
+ # Statuses considered terminal — only these sessions are eligible for compaction.
22
+ _TERMINAL_STATUSES = (
23
+ SessionStatus.completed,
24
+ SessionStatus.failed,
25
+ SessionStatus.archived,
26
+ )
27
+
28
+ # Event types that are bulky and reconstructible from the transcript artifact.
29
+ _PURGEABLE_TYPES = (
30
+ RunEventType.token,
31
+ RunEventType.tool_result,
32
+ )
33
+
34
+
35
+ @dataclass
36
+ class CompactReport:
37
+ sessions: int
38
+ events_deleted: int
39
+
40
+
41
+ async def compact_sessions(
42
+ db: AsyncSession,
43
+ *,
44
+ older_than_days: int,
45
+ dry_run: bool = False,
46
+ ) -> CompactReport:
47
+ """Delete (or count) bulky run events from old, terminal sessions.
48
+
49
+ Target sessions: terminal status (completed/failed/archived) AND
50
+ ``updated_at`` older than the horizon (``now(UTC) - older_than_days``).
51
+
52
+ Purges ``token`` and ``tool_result`` events only — keeps message/status/
53
+ cost/adapter_meta/tool_call/error/artifact.
54
+
55
+ When ``dry_run`` is True, counts the events that *would* be deleted without
56
+ actually deleting them.
57
+
58
+ Returns a :class:`CompactReport` with the session count and event count.
59
+ """
60
+ horizon = datetime.now(UTC) - timedelta(days=older_than_days)
61
+
62
+ # Resolve qualifying session ids.
63
+ session_ids_stmt = (
64
+ select(Session.id)
65
+ .where(Session.status.in_(_TERMINAL_STATUSES))
66
+ .where(Session.updated_at < horizon)
67
+ )
68
+ session_ids = list((await db.execute(session_ids_stmt)).scalars())
69
+
70
+ if not session_ids:
71
+ return CompactReport(sessions=0, events_deleted=0)
72
+
73
+ # Resolve qualifying run ids for those sessions.
74
+ run_ids_stmt = select(Run.id).where(Run.session_id.in_(session_ids))
75
+ run_ids = list((await db.execute(run_ids_stmt)).scalars())
76
+
77
+ if not run_ids:
78
+ return CompactReport(sessions=len(session_ids), events_deleted=0)
79
+
80
+ # Count purgeable events.
81
+ count_stmt = (
82
+ select(func.count())
83
+ .select_from(RunEvent)
84
+ .where(RunEvent.run_id.in_(run_ids))
85
+ .where(RunEvent.type.in_(_PURGEABLE_TYPES))
86
+ )
87
+ events_deleted = (await db.execute(count_stmt)).scalar_one()
88
+
89
+ if not dry_run and events_deleted > 0:
90
+ await db.execute(
91
+ delete(RunEvent)
92
+ .where(RunEvent.run_id.in_(run_ids))
93
+ .where(RunEvent.type.in_(_PURGEABLE_TYPES))
94
+ )
95
+ await db.commit()
96
+
97
+ return CompactReport(sessions=len(session_ids), events_deleted=events_deleted)