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,439 @@
1
+ """Per-session private workspaces: one git worktree + branch per session.
2
+
3
+ An isolated session runs in its own worktree under ``~/.ags/worktrees/<workspace>/
4
+ <session-hex12>`` on branch ``ags/session/<hex12>``, based on whatever branch the
5
+ project dir had checked out when the worktree was created. Changes flow back via
6
+ an explicit merge (``merge_session_worktree``) — never automatically. All git
7
+ work runs sync in a thread (same pattern as ``orchestrator/snapshot.py``).
8
+
9
+ Session bookkeeping lives in ``Session.settings``: ``workspace_mode``
10
+ ("main"|"worktree"), ``worktree_path``, ``worktree_branch``, ``worktree_base``,
11
+ ``worktree_fallback_reason``, ``merged_at``, ``merge_commit``."""
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import os
17
+ import shutil
18
+ import subprocess
19
+ import uuid
20
+ from datetime import UTC, datetime
21
+ from pathlib import Path
22
+
23
+ from sqlalchemy.ext.asyncio import AsyncSession
24
+
25
+ from harness.models.session import Session
26
+ from harness.models.workspace import Workspace
27
+ from harness.observability.audit import write_audit
28
+ from harness.observability.logging import get_logger
29
+ from harness.settings import HOME_DIR
30
+
31
+ log = get_logger("worktrees")
32
+
33
+ _GIT_TIMEOUT = 60
34
+
35
+
36
+ class _GitError(RuntimeError):
37
+ pass
38
+
39
+
40
+ def worktrees_root() -> Path:
41
+ """Root directory holding all session worktrees. Overridable for tests."""
42
+ override = os.environ.get("HARNESS_WORKTREES_DIR")
43
+ return Path(override) if override else HOME_DIR / "worktrees"
44
+
45
+
46
+ def branch_name(session_id: uuid.UUID) -> str:
47
+ return f"ags/session/{session_id.hex[:12]}"
48
+
49
+
50
+ def worktree_dir(workspace_slug: str, session_id: uuid.UUID) -> Path:
51
+ return worktrees_root() / workspace_slug / session_id.hex[:12]
52
+
53
+
54
+ def _git(cwd: str | Path, *args: str) -> str:
55
+ r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True,
56
+ timeout=_GIT_TIMEOUT)
57
+ if r.returncode != 0:
58
+ raise _GitError((r.stderr.strip() or r.stdout.strip())[:500])
59
+ return r.stdout.strip()
60
+
61
+
62
+ def is_git_repo(project_dir: str) -> bool:
63
+ return (Path(project_dir) / ".git").exists()
64
+
65
+
66
+ async def _save_settings(db: AsyncSession, sess: Session, updates: dict) -> None:
67
+ settings = dict(sess.settings or {})
68
+ settings.update(updates)
69
+ # Drop keys explicitly cleared with None.
70
+ for k in [k for k, v in settings.items() if v is None]:
71
+ settings.pop(k)
72
+ sess.settings = settings # reassign so SQLAlchemy flags the JSON column dirty
73
+ await db.flush()
74
+
75
+
76
+ # ── creation ──────────────────────────────────────────────────────────────────
77
+
78
+ def _ensure_sync(project_dir: str, path: Path, branch: str) -> tuple[str, str, bool]:
79
+ """Create (or re-attach) the worktree. Returns (path, base_branch, reattached)."""
80
+ try:
81
+ base = _git(project_dir, "symbolic-ref", "--short", "HEAD")
82
+ except _GitError as exc:
83
+ raise _GitError(f"detached or unborn HEAD in {project_dir}: {exc}") from exc
84
+ # HEAD must resolve to a commit (an unborn branch cannot back a worktree).
85
+ _git(project_dir, "rev-parse", "--verify", "HEAD")
86
+
87
+ path.parent.mkdir(parents=True, exist_ok=True)
88
+ _git(project_dir, "worktree", "prune") # clear stale registrations first
89
+ branch_exists = bool(
90
+ subprocess.run(["git", "rev-parse", "--verify", "--quiet", branch],
91
+ cwd=project_dir, capture_output=True, timeout=_GIT_TIMEOUT
92
+ ).returncode == 0
93
+ )
94
+ if branch_exists:
95
+ # Re-attach: the branch survived (e.g. dir was deleted); keep its history.
96
+ _git(project_dir, "worktree", "add", str(path), branch)
97
+ else:
98
+ _git(project_dir, "worktree", "add", "-b", branch, str(path), base)
99
+ return str(path), base, branch_exists
100
+
101
+
102
+ async def ensure_worktree(
103
+ db: AsyncSession, *, session: Session, workspace: Workspace, project_dir: str,
104
+ project_host: str | None = None,
105
+ ) -> tuple[str, str] | None:
106
+ """Idempotently provision the session's private worktree.
107
+
108
+ Returns (worktree_path, branch) — or None when the session runs in main
109
+ mode, including a one-time downgrade (recorded in
110
+ ``worktree_fallback_reason``) when the project can't support worktrees
111
+ (remote host, non-git dir, git failure)."""
112
+ settings = session.settings or {}
113
+ if settings.get("workspace_mode") == "main":
114
+ return None
115
+
116
+ recorded_path = settings.get("worktree_path")
117
+ branch = settings.get("worktree_branch") or branch_name(session.id)
118
+ if recorded_path and Path(recorded_path).is_dir():
119
+ return recorded_path, branch
120
+
121
+ reason: str | None = None
122
+ if project_host:
123
+ reason = "private workspaces are local-only; project lives on a remote host"
124
+ elif not is_git_repo(project_dir):
125
+ reason = f"not a git repository: {project_dir}"
126
+ if reason:
127
+ await _save_settings(db, session, {
128
+ "workspace_mode": "main", "worktree_fallback_reason": reason,
129
+ })
130
+ log.warning("worktree_fallback_main", session_id=str(session.id), reason=reason)
131
+ return None
132
+
133
+ path = Path(recorded_path) if recorded_path else worktree_dir(workspace.slug, session.id)
134
+ try:
135
+ wt_path, base, reattached = await asyncio.to_thread(
136
+ _ensure_sync, project_dir, path, branch
137
+ )
138
+ except (_GitError, subprocess.TimeoutExpired, OSError) as exc:
139
+ reason = f"worktree creation failed: {exc}"
140
+ await _save_settings(db, session, {
141
+ "workspace_mode": "main", "worktree_fallback_reason": reason,
142
+ })
143
+ log.warning("worktree_fallback_main", session_id=str(session.id), reason=reason)
144
+ return None
145
+
146
+ # A re-attached branch keeps its recorded base; a freshly created branch is
147
+ # based on whatever is checked out NOW — record that, or a later merge would
148
+ # target a stale base branch.
149
+ recorded_base = (session.settings or {}).get("worktree_base")
150
+ await _save_settings(db, session, {
151
+ "workspace_mode": "worktree",
152
+ "worktree_path": wt_path,
153
+ "worktree_branch": branch,
154
+ "worktree_base": (recorded_base if reattached and recorded_base else base),
155
+ })
156
+ await write_audit(
157
+ db, actor="engine", action="session.worktree_create", target_type="session",
158
+ target_id=str(session.id), after={"path": wt_path, "branch": branch, "base": base},
159
+ )
160
+ return wt_path, branch
161
+
162
+
163
+ # ── status ────────────────────────────────────────────────────────────────────
164
+
165
+ def _status_sync(wt: str, base: str) -> dict:
166
+ dirty_lines = _git(wt, "status", "--porcelain")
167
+ changed: set[str] = set()
168
+ # Committed changes vs the merge-base (three-dot): commits landed on the base
169
+ # branch AFTER the worktree branched off must not show up as session changes.
170
+ try:
171
+ changed.update(
172
+ f for f in _git(wt, "diff", "--name-only", f"{base}...HEAD").splitlines() if f
173
+ )
174
+ except _GitError:
175
+ pass
176
+ # ...plus uncommitted work: modified/staged tracked files and untracked files.
177
+ for line in dirty_lines.splitlines():
178
+ # porcelain v1: 2-char XY status, then the path ("old -> new" on renames).
179
+ name = line[2:].strip().split(" -> ")[-1].strip('"')
180
+ if name:
181
+ changed.add(name)
182
+ try:
183
+ behind_ahead = _git(wt, "rev-list", "--left-right", "--count", f"{base}...HEAD")
184
+ behind, ahead = (int(x) for x in behind_ahead.split())
185
+ except (_GitError, ValueError):
186
+ behind = ahead = 0
187
+ return {
188
+ "dirty": bool(dirty_lines),
189
+ "changed_files": sorted(changed),
190
+ "ahead": ahead,
191
+ "behind": behind,
192
+ }
193
+
194
+
195
+ async def worktree_status(db: AsyncSession, session_id: uuid.UUID) -> dict:
196
+ """The session's workspace isolation state, for the GUI badge / CLI status."""
197
+ from harness.services.sessions import get_session
198
+
199
+ sess = await get_session(db, session_id)
200
+ if sess is None:
201
+ raise ValueError("session not found")
202
+ settings = sess.settings or {}
203
+ mode = settings.get("workspace_mode") or (
204
+ "worktree" if settings.get("worktree_path") else "main"
205
+ )
206
+ out = {
207
+ "workspace_mode": mode,
208
+ "path": settings.get("worktree_path"),
209
+ "branch": settings.get("worktree_branch"),
210
+ "base": settings.get("worktree_base"),
211
+ "exists": False,
212
+ "dirty": False,
213
+ "changed_files": [],
214
+ "ahead": 0,
215
+ "behind": 0,
216
+ "merged_at": settings.get("merged_at"),
217
+ "merge_commit": settings.get("merge_commit"),
218
+ "fallback_reason": settings.get("worktree_fallback_reason"),
219
+ }
220
+ wt, base = out["path"], out["base"]
221
+ if mode != "worktree" or not wt or not Path(wt).is_dir():
222
+ return out
223
+ out["exists"] = True
224
+ try:
225
+ out.update(await asyncio.to_thread(_status_sync, wt, base or "HEAD"))
226
+ except (subprocess.TimeoutExpired, _GitError, OSError) as exc:
227
+ log.warning("worktree_status_failed", session_id=str(session_id), error=str(exc))
228
+ return out
229
+
230
+
231
+ # ── merge-back ────────────────────────────────────────────────────────────────
232
+
233
+ def _main_checkout_of(wt: str) -> str:
234
+ """The primary checkout a worktree belongs to (parent of its common .git dir).
235
+ Derived from the worktree itself so it stays correct even if the workspace's
236
+ project_dir setting changed after the worktree was created."""
237
+ common = _git(wt, "rev-parse", "--path-format=absolute", "--git-common-dir")
238
+ return str(Path(common).parent)
239
+
240
+
241
+ def _merge_sync(
242
+ project_dir: str, wt: str, branch: str, base: str, *, squash: bool, message: str,
243
+ ) -> dict:
244
+ # 1. The main checkout must be clean and on the base branch — a merge mutates
245
+ # it. Validate FIRST so a refused merge leaves the worktree untouched too.
246
+ if _git(project_dir, "status", "--porcelain"):
247
+ raise ValueError(
248
+ f"project checkout has uncommitted (dirty) changes; commit or stash "
249
+ f"them in {project_dir} first"
250
+ )
251
+ current = _git(project_dir, "symbolic-ref", "--short", "HEAD")
252
+ if current != base:
253
+ raise ValueError(
254
+ f"project checkout is on branch '{current}' but the session is based "
255
+ f"on '{base}'; check out '{base}' first"
256
+ )
257
+
258
+ # 2. Auto-commit the worktree — agents usually leave changes uncommitted.
259
+ if _git(wt, "status", "--porcelain"):
260
+ _git(wt, "add", "-A")
261
+ _git(wt, "-c", "user.name=ags-harness", "-c", "user.email=ags@localhost",
262
+ "commit", "-m", "ags: session work (auto-committed at merge)")
263
+
264
+ # 3. Anything to merge at all?
265
+ ahead = int(_git(project_dir, "rev-list", "--count", f"{base}..{branch}") or "0")
266
+ if ahead == 0:
267
+ return {"merged": False, "nothing_to_merge": True, "commit": None,
268
+ "conflicts": [], "reason": "session branch has no new commits"}
269
+
270
+ # 4. Merge (never leave a conflicted tree behind).
271
+ try:
272
+ if squash:
273
+ _git(project_dir, "merge", "--squash", branch)
274
+ _git(project_dir, "-c", "user.name=ags-harness",
275
+ "-c", "user.email=ags@localhost", "commit", "-m", message)
276
+ else:
277
+ _git(project_dir, "-c", "user.name=ags-harness",
278
+ "-c", "user.email=ags@localhost",
279
+ "merge", "--no-ff", branch, "-m", message)
280
+ except _GitError as exc:
281
+ try:
282
+ conflicts = [
283
+ f for f in _git(project_dir, "diff", "--name-only",
284
+ "--diff-filter=U").splitlines() if f
285
+ ]
286
+ except _GitError:
287
+ conflicts = []
288
+ for abort in (("merge", "--abort"), ("reset", "--merge")):
289
+ try:
290
+ _git(project_dir, *abort)
291
+ break
292
+ except _GitError:
293
+ continue
294
+ return {"merged": False, "nothing_to_merge": False, "commit": None,
295
+ "conflicts": conflicts, "reason": str(exc)}
296
+
297
+ commit = _git(project_dir, "rev-parse", "HEAD")
298
+ return {"merged": True, "nothing_to_merge": False, "commit": commit,
299
+ "conflicts": [], "reason": None}
300
+
301
+
302
+ async def merge_session_worktree(
303
+ db: AsyncSession, session_id: uuid.UUID, *,
304
+ squash: bool = False, delete_worktree: bool = False,
305
+ message: str | None = None, actor: str = "api",
306
+ ) -> dict:
307
+ """Merge the session branch back into its base branch in the main checkout.
308
+
309
+ Auto-commits pending worktree changes first. Raises ValueError for
310
+ user-fixable refusals (main-mode session, missing worktree, dirty main,
311
+ wrong branch checked out); returns ``merged=False`` with a ``conflicts``
312
+ list when the merge itself conflicts (already aborted)."""
313
+ from harness.services.sessions import get_session
314
+
315
+ sess = await get_session(db, session_id)
316
+ if sess is None:
317
+ raise ValueError("session not found")
318
+ # Refuse while a run is executing in this session's worktree — merging would
319
+ # auto-commit and land a half-written tree on the base branch.
320
+ from harness.workers.inprocess import _session_lock
321
+
322
+ if _session_lock(session_id).locked():
323
+ raise ValueError("a run is still executing in this session; wait for it to finish")
324
+ settings = sess.settings or {}
325
+ if settings.get("workspace_mode") != "worktree":
326
+ raise ValueError("session runs directly in the main project (no private worktree)")
327
+ wt, branch, base = (settings.get("worktree_path"), settings.get("worktree_branch"),
328
+ settings.get("worktree_base"))
329
+ if not (wt and branch and base) or not Path(wt).is_dir():
330
+ raise ValueError("session worktree does not exist (nothing to merge)")
331
+
332
+ try:
333
+ project_dir = await asyncio.to_thread(_main_checkout_of, wt)
334
+ except (_GitError, subprocess.TimeoutExpired) as exc:
335
+ raise ValueError(f"cannot locate the main checkout for the worktree: {exc}") from exc
336
+
337
+ msg = message or f"Merge session {session_id.hex[:12]} ({sess.canonical_objective[:60]})"
338
+ try:
339
+ out = await asyncio.to_thread(
340
+ _merge_sync, project_dir, wt, branch, base, squash=squash, message=msg,
341
+ )
342
+ except (subprocess.TimeoutExpired, _GitError) as exc:
343
+ raise ValueError(f"merge failed: {exc}") from exc
344
+
345
+ if out["merged"]:
346
+ await _save_settings(db, sess, {
347
+ "merged_at": datetime.now(UTC).isoformat(), "merge_commit": out["commit"],
348
+ })
349
+ await write_audit(
350
+ db, actor=actor, action="session.worktree_merge", target_type="session",
351
+ target_id=str(session_id),
352
+ after={"commit": out["commit"], "squash": squash, "branch": branch},
353
+ )
354
+ if delete_worktree:
355
+ await remove_worktree(db, session_id, delete_branch=True, actor=actor)
356
+ return out
357
+
358
+
359
+ # ── removal / cleanup ─────────────────────────────────────────────────────────
360
+
361
+ def _remove_sync(project_dir: str | None, wt: str, branch: str | None,
362
+ delete_branch: bool) -> None:
363
+ repo = project_dir if project_dir and is_git_repo(project_dir) else None
364
+ if repo:
365
+ try:
366
+ _git(repo, "worktree", "remove", "--force", wt)
367
+ except _GitError:
368
+ shutil.rmtree(wt, ignore_errors=True)
369
+ try:
370
+ _git(repo, "worktree", "prune")
371
+ except _GitError:
372
+ pass
373
+ if delete_branch and branch:
374
+ try:
375
+ _git(repo, "branch", "-D", branch)
376
+ except _GitError:
377
+ pass
378
+ else:
379
+ shutil.rmtree(wt, ignore_errors=True)
380
+
381
+
382
+ async def remove_worktree(
383
+ db: AsyncSession, session_id: uuid.UUID, *,
384
+ delete_branch: bool = False, actor: str = "api",
385
+ ) -> bool:
386
+ """Discard a session's worktree (fail-soft). The branch is kept unless
387
+ ``delete_branch`` — an unmerged branch is the safety net for declined merges."""
388
+ from harness.services.sessions import get_session
389
+
390
+ sess = await get_session(db, session_id)
391
+ if sess is None:
392
+ return False
393
+ settings = sess.settings or {}
394
+ wt = settings.get("worktree_path")
395
+ if not wt:
396
+ return False
397
+ try:
398
+ project_dir: str | None = (
399
+ await asyncio.to_thread(_main_checkout_of, wt) if Path(wt).is_dir() else None
400
+ )
401
+ except (_GitError, subprocess.TimeoutExpired):
402
+ project_dir = None
403
+ try:
404
+ await asyncio.to_thread(
405
+ _remove_sync, project_dir, wt, settings.get("worktree_branch"), delete_branch,
406
+ )
407
+ except (subprocess.TimeoutExpired, OSError) as exc:
408
+ log.warning("worktree_remove_failed", session_id=str(session_id), error=str(exc))
409
+ await _save_settings(db, sess, {
410
+ "worktree_path": None,
411
+ **({"worktree_branch": None} if delete_branch else {}),
412
+ })
413
+ await write_audit(
414
+ db, actor=actor, action="session.worktree_remove", target_type="session",
415
+ target_id=str(session_id), after={"deleted_branch": delete_branch},
416
+ )
417
+ return True
418
+
419
+
420
+ async def prune_orphan_worktrees(db: AsyncSession) -> dict:
421
+ """Sweep the worktrees root for directories whose session no longer exists."""
422
+ from sqlalchemy import select
423
+
424
+ root = worktrees_root()
425
+ if not root.is_dir():
426
+ return {"removed": 0}
427
+ known = {
428
+ s.hex[:12]
429
+ for s in (await db.execute(select(Session.id))).scalars()
430
+ }
431
+ removed = 0
432
+ for ws_dir in root.iterdir():
433
+ if not ws_dir.is_dir():
434
+ continue
435
+ for wt in ws_dir.iterdir():
436
+ if wt.is_dir() and wt.name not in known:
437
+ shutil.rmtree(wt, ignore_errors=True)
438
+ removed += 1
439
+ return {"removed": removed}
harness/settings.py ADDED
@@ -0,0 +1,186 @@
1
+ """Application settings: environment variables merged with `config.yaml`.
2
+
3
+ Precedence: environment variables (secrets, URLs) override the YAML file; the
4
+ YAML file owns declarative structure (workspaces, providers, routing, memory,
5
+ safety). Secrets never live in YAML — only `secret_ref` pointers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import functools
11
+ from pathlib import Path
12
+ from typing import Any, Literal
13
+
14
+ import yaml
15
+ from dotenv import find_dotenv, load_dotenv
16
+ from pydantic import model_validator
17
+ from pydantic_settings import BaseSettings, SettingsConfigDict
18
+
19
+ Env = Literal["dev", "test", "prod"]
20
+ AuthProviderName = Literal["local", "oidc"]
21
+ EmbeddingProviderName = Literal["auto", "hash", "openai_compatible", "fastembed"]
22
+
23
+ # Daemonless state lives under the user's home, not the repo, so an installed
24
+ # `ags` (via `uv tool install`) works from any directory.
25
+ HOME_DIR = Path.home() / ".ags"
26
+ # GUI-managed providers, one YAML file each, merged into config["providers"].
27
+ PROVIDERS_DIR = HOME_DIR / "providers.d"
28
+ # GUI-managed MCP servers, one YAML file each, merged into config["mcp"]["servers"].
29
+ MCP_DIR = HOME_DIR / "mcp.d"
30
+
31
+
32
+ def _merge_overlay(base: list[dict], overlay_dir: Path) -> list[dict]:
33
+ """Append ``<overlay_dir>/*.yaml`` entries to ``base``, skipping any name already
34
+ declared in config.yaml (config.yaml wins)."""
35
+ names = {p.get("name") for p in base}
36
+ merged = list(base)
37
+ if overlay_dir.is_dir():
38
+ for f in sorted(overlay_dir.glob("*.yaml")):
39
+ try:
40
+ entry = yaml.safe_load(f.read_text())
41
+ except yaml.YAMLError:
42
+ continue
43
+ if isinstance(entry, dict) and entry.get("name") and entry["name"] not in names:
44
+ merged.append(entry)
45
+ names.add(entry["name"])
46
+ return merged
47
+
48
+
49
+ def _merge_overlay_providers(base: list[dict]) -> list[dict]:
50
+ return _merge_overlay(base, PROVIDERS_DIR)
51
+
52
+
53
+ def _merge_overlay_mcp_servers(base: list[dict]) -> list[dict]:
54
+ return _merge_overlay(base, MCP_DIR)
55
+
56
+
57
+ def _load_env_files() -> None:
58
+ """Load ``.env`` into the process environment so provider ``secret_ref: env:X``
59
+ pointers resolve (the CLI/server run as plain processes — nothing else loads
60
+ ``.env`` into ``os.environ``; pydantic-settings' ``env_file`` only
61
+ populates this class's own fields). Shell-exported vars win (``override=False``); a
62
+ project-local ``.env`` (from the CWD upward) takes precedence over the per-user
63
+ ``~/.ags/.env`` fallback."""
64
+ load_dotenv(find_dotenv(usecwd=True), override=False)
65
+ load_dotenv(HOME_DIR / ".env", override=False)
66
+
67
+
68
+ _load_env_files()
69
+
70
+
71
+ class Settings(BaseSettings):
72
+ """Process-wide configuration loaded from the `HARNESS_` env namespace."""
73
+
74
+ model_config = SettingsConfigDict(
75
+ env_prefix="HARNESS_",
76
+ env_file=".env",
77
+ env_file_encoding="utf-8",
78
+ extra="ignore",
79
+ )
80
+
81
+ env: Env = "dev"
82
+ log_level: str = "INFO"
83
+ log_json: bool = True
84
+
85
+ # API
86
+ api_host: str = "0.0.0.0"
87
+ api_port: int = 8080
88
+ api_base_url: str = "http://localhost:8080"
89
+ config_file: str = "config.yaml"
90
+
91
+ # Datastore. Left blank so the default (see _resolve_datastores) applies unless
92
+ # the operator overrides it via HARNESS_DATABASE_URL.
93
+ database_url: str = ""
94
+
95
+ # Auth
96
+ auth_provider: AuthProviderName = "local"
97
+ auth_secret: str = "change-me"
98
+ local_admin_user: str = "admin"
99
+ local_admin_password: str = "admin"
100
+ oidc_issuer: str = ""
101
+ oidc_client_id: str = ""
102
+ oidc_client_secret: str = ""
103
+
104
+ # Embeddings
105
+ embedding_provider: EmbeddingProviderName = "auto"
106
+ embedding_dim: int = 768
107
+ embedding_base_url: str = "http://localhost:11434/v1"
108
+ embedding_api_key: str = ""
109
+ embedding_model: str = "nomic-embed-text"
110
+
111
+ # Observability
112
+ otel_enabled: bool = False
113
+ otel_exporter_otlp_endpoint: str = "http://localhost:4317"
114
+
115
+ # Safety
116
+ read_only: bool = False
117
+ dry_run: bool = False
118
+
119
+ # Per-run wall-clock deadline (seconds). A hung / non-responding adapter is cancelled
120
+ # and the run fails cleanly instead of hanging. Generous by default so a slow-but-working
121
+ # agent (large-repo agy, slow local LLM) isn't killed; lower it for snappier failure.
122
+ run_timeout_s: int = 900
123
+
124
+ # Fail a run if the adapter emits no events for this long (0 = disabled).
125
+ # Catches adapters that start but then silently wedge mid-run, without waiting
126
+ # for the full wall-clock deadline.
127
+ stall_timeout_s: int = 180
128
+
129
+ # Retention policy: auto-compact sessions older than this many days on startup.
130
+ # 0 (default) disables automatic compaction. When enabled, token and tool_result
131
+ # events are purged from terminal sessions older than the horizon; the structured
132
+ # transcript artifact (written by _persist_artifacts) serves as the archive.
133
+ retention_days: int = 0
134
+
135
+ # Experimental: keep a warm (long-lived) claude process per active session so
136
+ # the CLI's KV cache is reused across turns, reducing turn-2+ latency.
137
+ # Disabled by default. Enable with HARNESS_WARM_CLI=1.
138
+ warm_cli: bool = False
139
+
140
+ _config_cache: dict[str, Any] | None = None
141
+
142
+ @model_validator(mode="after")
143
+ def _resolve_datastores(self) -> Settings:
144
+ """Resolve the database URL.
145
+
146
+ Always SQLite under ``~/.ags``: a non-sqlite ``HARNESS_DATABASE_URL`` (e.g. a
147
+ stale ``.env`` in the CWD) is ignored, so the daemonless install never loads
148
+ asyncpg or dials Postgres. An explicit *sqlite* URL is still honored for a
149
+ custom local path."""
150
+ if not self.database_url or not self.database_url.startswith("sqlite"):
151
+ HOME_DIR.mkdir(parents=True, exist_ok=True)
152
+ self.database_url = f"sqlite+aiosqlite:///{HOME_DIR / 'harness.db'}"
153
+ return self
154
+
155
+ def config(self) -> dict[str, Any]:
156
+ """Parsed `config.yaml` (cached), with GUI-managed providers merged in.
157
+
158
+ Falls back to ``~/.ags/config.yaml`` (written by ``ags init``) when
159
+ the configured path is absent — so an installed CLI works outside the repo.
160
+ Providers added from the GUI live as ``~/.ags/providers.d/*.yaml`` and are
161
+ appended here, so the seed treats them like config.yaml entries (kept, not
162
+ pruned) without ever rewriting the user's commented config.yaml."""
163
+ if self._config_cache is None:
164
+ path = Path(self.config_file).expanduser()
165
+ if not path.exists():
166
+ home_cfg = HOME_DIR / "config.yaml"
167
+ if home_cfg.exists():
168
+ path = home_cfg
169
+ cfg = (yaml.safe_load(path.read_text()) if path.exists() else {}) or {}
170
+ cfg["providers"] = _merge_overlay_providers(cfg.get("providers") or [])
171
+ mcp = cfg.get("mcp") or {}
172
+ mcp["servers"] = _merge_overlay_mcp_servers(mcp.get("servers") or [])
173
+ cfg["mcp"] = mcp
174
+ self._config_cache = cfg
175
+ return self._config_cache
176
+
177
+ def reset_config_cache(self) -> None:
178
+ """Drop the cached config so the next ``config()`` re-reads disk (e.g. after a
179
+ provider is added/removed from the GUI overlay)."""
180
+ self._config_cache = None
181
+
182
+
183
+ @functools.lru_cache
184
+ def get_settings() -> Settings:
185
+ """Cached singleton accessor used across DI and workers."""
186
+ return Settings()
@@ -0,0 +1,11 @@
1
+ """Harness-managed skill library for spawned CLI agents. See :mod:`harness.skills.manager`."""
2
+
3
+ from harness.skills.manager import (
4
+ Skill,
5
+ discover,
6
+ index_for,
7
+ index_text,
8
+ provision_config_home,
9
+ )
10
+
11
+ __all__ = ["Skill", "discover", "index_for", "index_text", "provision_config_home"]