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.
- ags_cli-0.1.0.dist-info/METADATA +332 -0
- ags_cli-0.1.0.dist-info/RECORD +156 -0
- ags_cli-0.1.0.dist-info/WHEEL +5 -0
- ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ags_cli-0.1.0.dist-info/top_level.txt +2 -0
- cli/__init__.py +0 -0
- cli/__main__.py +4 -0
- cli/client.py +145 -0
- cli/commands/__init__.py +0 -0
- cli/commands/adapter.py +33 -0
- cli/commands/artifact.py +21 -0
- cli/commands/ask.py +212 -0
- cli/commands/chat.py +649 -0
- cli/commands/diff.py +49 -0
- cli/commands/doctor.py +93 -0
- cli/commands/gui.py +45 -0
- cli/commands/init.py +20 -0
- cli/commands/mcp.py +50 -0
- cli/commands/memory.py +65 -0
- cli/commands/model.py +73 -0
- cli/commands/policy.py +52 -0
- cli/commands/project.py +122 -0
- cli/commands/quota.py +107 -0
- cli/commands/run.py +84 -0
- cli/commands/serve.py +171 -0
- cli/commands/service.py +236 -0
- cli/commands/session.py +219 -0
- cli/commands/stats.py +96 -0
- cli/commands/status.py +36 -0
- cli/commands/task.py +37 -0
- cli/commands/usage.py +86 -0
- cli/local.py +84 -0
- cli/main.py +149 -0
- harness/__init__.py +4 -0
- harness/adapters/__init__.py +26 -0
- harness/adapters/antigravity.py +301 -0
- harness/adapters/claude_code.py +522 -0
- harness/adapters/local_openai.py +534 -0
- harness/adapters/mock.py +112 -0
- harness/adapters/probe.py +65 -0
- harness/adapters/registry.py +56 -0
- harness/adapters/warm_pool.py +255 -0
- harness/api/__init__.py +0 -0
- harness/api/router.py +38 -0
- harness/api/v1/__init__.py +0 -0
- harness/api/v1/adapters.py +105 -0
- harness/api/v1/approvals.py +173 -0
- harness/api/v1/artifacts.py +44 -0
- harness/api/v1/attachments.py +48 -0
- harness/api/v1/doctor.py +22 -0
- harness/api/v1/health.py +37 -0
- harness/api/v1/mcp.py +131 -0
- harness/api/v1/memory.py +80 -0
- harness/api/v1/policies.py +49 -0
- harness/api/v1/project.py +302 -0
- harness/api/v1/runs.py +98 -0
- harness/api/v1/sessions.py +248 -0
- harness/api/v1/stream.py +110 -0
- harness/api/v1/tasks.py +30 -0
- harness/api/v1/usage.py +58 -0
- harness/bootstrap.py +216 -0
- harness/db.py +164 -0
- harness/deps.py +58 -0
- harness/eventbus/__init__.py +20 -0
- harness/eventbus/inprocess_bus.py +85 -0
- harness/main.py +144 -0
- harness/mcp/__init__.py +22 -0
- harness/mcp/client.py +139 -0
- harness/mcp/config_gen.py +77 -0
- harness/mcp/registry.py +156 -0
- harness/mcp/tools.py +81 -0
- harness/memory/__init__.py +13 -0
- harness/memory/context_budget.py +116 -0
- harness/memory/embed.py +217 -0
- harness/memory/extract.py +74 -0
- harness/memory/ingest.py +106 -0
- harness/memory/namespaces.py +57 -0
- harness/memory/ranking.py +31 -0
- harness/memory/redact.py +37 -0
- harness/memory/service.py +320 -0
- harness/memory/sqlite_vec_backend.py +266 -0
- harness/memory/summarize.py +68 -0
- harness/models/__init__.py +35 -0
- harness/models/adapter_health.py +27 -0
- harness/models/approval.py +37 -0
- harness/models/artifact.py +35 -0
- harness/models/audit_log.py +33 -0
- harness/models/comparison.py +33 -0
- harness/models/enums.py +146 -0
- harness/models/mcp_server_config.py +49 -0
- harness/models/memory.py +76 -0
- harness/models/policy.py +29 -0
- harness/models/provider_config.py +35 -0
- harness/models/run.py +46 -0
- harness/models/run_event.py +35 -0
- harness/models/session.py +50 -0
- harness/models/task.py +30 -0
- harness/models/types.py +63 -0
- harness/models/workspace.py +27 -0
- harness/observability/__init__.py +4 -0
- harness/observability/audit.py +88 -0
- harness/observability/logging.py +47 -0
- harness/observability/metrics.py +43 -0
- harness/observability/tracing.py +38 -0
- harness/orchestrator/__init__.py +19 -0
- harness/orchestrator/engine.py +821 -0
- harness/orchestrator/handoff.py +33 -0
- harness/orchestrator/lifecycle.py +69 -0
- harness/orchestrator/native_resume.py +93 -0
- harness/orchestrator/policies.py +101 -0
- harness/orchestrator/reconcile.py +39 -0
- harness/orchestrator/run_graph.py +62 -0
- harness/orchestrator/snapshot.py +49 -0
- harness/schemas/__init__.py +1 -0
- harness/schemas/adapter.py +26 -0
- harness/schemas/approval.py +25 -0
- harness/schemas/artifact.py +17 -0
- harness/schemas/common.py +20 -0
- harness/schemas/memory.py +50 -0
- harness/schemas/policy.py +39 -0
- harness/schemas/run.py +116 -0
- harness/schemas/session.py +93 -0
- harness/schemas/task.py +24 -0
- harness/security/__init__.py +5 -0
- harness/security/approval_policy.py +107 -0
- harness/security/auth.py +106 -0
- harness/security/permissions.py +59 -0
- harness/security/risk.py +59 -0
- harness/security/secrets.py +49 -0
- harness/services/__init__.py +1 -0
- harness/services/adapters_health.py +212 -0
- harness/services/approvals.py +84 -0
- harness/services/artifacts.py +78 -0
- harness/services/attachments.py +228 -0
- harness/services/comparison.py +345 -0
- harness/services/doctor.py +156 -0
- harness/services/files.py +287 -0
- harness/services/mcp_servers.py +179 -0
- harness/services/project_git.py +101 -0
- harness/services/retention.py +97 -0
- harness/services/runs.py +155 -0
- harness/services/runs_diff.py +201 -0
- harness/services/sessions.py +242 -0
- harness/services/ssh.py +300 -0
- harness/services/stats.py +132 -0
- harness/services/tasks.py +41 -0
- harness/services/workspaces.py +184 -0
- harness/services/worktrees.py +439 -0
- harness/settings.py +186 -0
- harness/skills/__init__.py +11 -0
- harness/skills/manager.py +141 -0
- harness/tools/__init__.py +1 -0
- harness/tools/fs_tools.py +295 -0
- harness/workers/__init__.py +0 -0
- harness/workers/dispatch.py +26 -0
- 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"]
|