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
harness/services/ssh.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Remote (SSH) project support — all the plumbing in one module.
|
|
2
|
+
|
|
3
|
+
Shells out to the system ``ssh`` binary rather than a Python SSH library, so the
|
|
4
|
+
server user's ``~/.ssh/config`` (aliases, keys, agent, ProxyJump, ControlMaster)
|
|
5
|
+
works unchanged and no key material ever touches the harness. ``BatchMode=yes``
|
|
6
|
+
means key/agent auth only — a host that would prompt for a password fails fast
|
|
7
|
+
with its stderr surfaced instead of hanging.
|
|
8
|
+
|
|
9
|
+
Remote commands are plain POSIX sh; the remote login shell must be POSIX-ish."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import os
|
|
15
|
+
import posixpath
|
|
16
|
+
import re
|
|
17
|
+
import shlex
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
# No leading '-' (blocks ssh option injection); alias, host, or user@host forms.
|
|
21
|
+
HOST_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.@-]*$")
|
|
22
|
+
|
|
23
|
+
# Test seam: point at tests/simulators/ssh_stub.py to run "remote" commands locally.
|
|
24
|
+
SSH_BIN = os.environ.get("HARNESS_SSH_BIN", "ssh")
|
|
25
|
+
|
|
26
|
+
_CONNECT_TIMEOUT_S = 5
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_host(host: str) -> str:
|
|
30
|
+
"""A host is an ssh alias, hostname, or user@host — never an option string."""
|
|
31
|
+
host = (host or "").strip()
|
|
32
|
+
if not HOST_RE.match(host):
|
|
33
|
+
raise ValueError(f"invalid ssh host: {host!r}")
|
|
34
|
+
return host
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def ssh_base_argv(host: str, *, tty: bool = False) -> list[str]:
|
|
38
|
+
return [
|
|
39
|
+
SSH_BIN,
|
|
40
|
+
"-tt" if tty else "-T",
|
|
41
|
+
"-o", "BatchMode=yes",
|
|
42
|
+
"-o", f"ConnectTimeout={_CONNECT_TIMEOUT_S}",
|
|
43
|
+
validate_host(host),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def ssh_run(host: str, remote_cmd: str, *, timeout_s: float = 15.0) -> tuple[int, str, str]:
|
|
48
|
+
"""Run ``remote_cmd`` on ``host`` via the ssh client. Returns (rc, stdout, stderr)."""
|
|
49
|
+
proc = await asyncio.create_subprocess_exec(
|
|
50
|
+
*ssh_base_argv(host), remote_cmd,
|
|
51
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
52
|
+
stdout=asyncio.subprocess.PIPE,
|
|
53
|
+
stderr=asyncio.subprocess.PIPE,
|
|
54
|
+
)
|
|
55
|
+
try:
|
|
56
|
+
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout_s)
|
|
57
|
+
except TimeoutError:
|
|
58
|
+
proc.kill()
|
|
59
|
+
raise TimeoutError(f"ssh to {host!r} timed out after {timeout_s:.0f}s") from None
|
|
60
|
+
return proc.returncode or 0, out.decode(errors="replace"), err.decode(errors="replace")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def remote_is_dir(host: str, path: str) -> bool:
|
|
64
|
+
rc, _, _ = await ssh_run(host, f"test -d {shlex.quote(path)}")
|
|
65
|
+
return rc == 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _remote_cd(path: str | None) -> str:
|
|
69
|
+
"""POSIX `cd` clause for a remote path, with `~` handled on the remote side."""
|
|
70
|
+
if path in (None, "", "~"):
|
|
71
|
+
return "cd"
|
|
72
|
+
if path.startswith("~/"):
|
|
73
|
+
return f'cd -- "$HOME"/{shlex.quote(path[2:])}'
|
|
74
|
+
return f"cd -- {shlex.quote(path)}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def browse_dirs_remote(host: str, path: str | None) -> dict:
|
|
78
|
+
"""Remote counterpart of ``browse_dirs`` — same ``{path, parent, dirs}`` shape
|
|
79
|
+
(plus ``host``). First stdout line is the resolved path (`pwd -P`), the rest is
|
|
80
|
+
`ls -1p` filtered to non-dot directories. Known limitation: directory names
|
|
81
|
+
containing newlines are mis-parsed — acceptable for a folder picker."""
|
|
82
|
+
rc, out, err = await ssh_run(host, f"{_remote_cd(path)} && pwd -P && LC_ALL=C ls -1p")
|
|
83
|
+
if rc != 0:
|
|
84
|
+
raise ValueError(err.strip() or f"cannot read remote directory: {path}")
|
|
85
|
+
lines = out.splitlines()
|
|
86
|
+
if not lines:
|
|
87
|
+
raise ValueError(f"cannot read remote directory: {path}")
|
|
88
|
+
resolved = lines[0].strip()
|
|
89
|
+
dirs = sorted(
|
|
90
|
+
(ln[:-1] for ln in lines[1:] if ln.endswith("/") and not ln.startswith(".")),
|
|
91
|
+
key=str.lower,
|
|
92
|
+
)
|
|
93
|
+
parent = posixpath.dirname(resolved)
|
|
94
|
+
return {
|
|
95
|
+
"path": resolved,
|
|
96
|
+
"parent": parent if parent != resolved else None,
|
|
97
|
+
"dirs": dirs,
|
|
98
|
+
"host": host,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def ssh_check(host: str, *, path: str | None = None, bin: str | None = None) -> dict:
|
|
103
|
+
"""Connectivity probe for the GUI "Test connection" button. Never raises on
|
|
104
|
+
connection failure — returns ``ok: False`` with the ssh client's stderr."""
|
|
105
|
+
result: dict = {"ok": False, "latency_ms": None, "error": None,
|
|
106
|
+
"dir_ok": None, "bin_version": None}
|
|
107
|
+
t0 = time.monotonic()
|
|
108
|
+
try:
|
|
109
|
+
rc, _, err = await ssh_run(host, "true")
|
|
110
|
+
except (TimeoutError, OSError, ValueError) as exc:
|
|
111
|
+
result["error"] = str(exc)
|
|
112
|
+
return result
|
|
113
|
+
result["latency_ms"] = round((time.monotonic() - t0) * 1000, 1)
|
|
114
|
+
if rc != 0:
|
|
115
|
+
result["error"] = err.strip() or f"ssh exited with code {rc}"
|
|
116
|
+
return result
|
|
117
|
+
result["ok"] = True
|
|
118
|
+
if path:
|
|
119
|
+
result["dir_ok"] = await remote_is_dir(host, path)
|
|
120
|
+
if bin:
|
|
121
|
+
q = shlex.quote(bin)
|
|
122
|
+
# Login shell so ~/.profile PATH additions (~/.local/bin, where agent CLIs
|
|
123
|
+
# usually live) are visible — same environment the run itself will get.
|
|
124
|
+
probe = shlex.quote(f"command -v {q} >/dev/null 2>&1 && {q} --version")
|
|
125
|
+
rc, out, _ = await ssh_run(host, f"sh -lc {probe}")
|
|
126
|
+
if rc == 0 and out.strip():
|
|
127
|
+
result["bin_version"] = out.strip().splitlines()[0]
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def ssh_run_with_input(host: str, remote_cmd: str, input_str: str, timeout_s: float = 15.0) -> tuple[int, str, str]:
|
|
132
|
+
"""Run ``remote_cmd`` on ``host`` via the ssh client, sending ``input_str`` to stdin. Returns (rc, stdout, stderr)."""
|
|
133
|
+
proc = await asyncio.create_subprocess_exec(
|
|
134
|
+
*ssh_base_argv(host), remote_cmd,
|
|
135
|
+
stdin=asyncio.subprocess.PIPE,
|
|
136
|
+
stdout=asyncio.subprocess.PIPE,
|
|
137
|
+
stderr=asyncio.subprocess.PIPE,
|
|
138
|
+
)
|
|
139
|
+
try:
|
|
140
|
+
out, err = await asyncio.wait_for(
|
|
141
|
+
proc.communicate(input=input_str.encode("utf-8")),
|
|
142
|
+
timeout=timeout_s
|
|
143
|
+
)
|
|
144
|
+
except TimeoutError:
|
|
145
|
+
proc.kill()
|
|
146
|
+
raise TimeoutError(f"ssh to {host!r} timed out after {timeout_s:.0f}s") from None
|
|
147
|
+
return proc.returncode or 0, out.decode(errors="replace"), err.decode(errors="replace")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _remote_safe_join(project_dir: str, subpath: str) -> str:
|
|
151
|
+
"""Validate and resolve target path under remote project directory."""
|
|
152
|
+
root = posixpath.normpath(project_dir)
|
|
153
|
+
target = posixpath.normpath(posixpath.join(root, subpath))
|
|
154
|
+
root_check = root if root.endswith('/') else root + '/'
|
|
155
|
+
if not (target + '/').startswith(root_check) and target != root:
|
|
156
|
+
raise ValueError(f"path outside remote project folder: {subpath!r}")
|
|
157
|
+
return target
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def list_dir_remote(host: str, project_dir: str, subpath: str = "") -> dict:
|
|
161
|
+
"""List directory contents on remote host."""
|
|
162
|
+
target = _remote_safe_join(project_dir, subpath)
|
|
163
|
+
cmd = (
|
|
164
|
+
f"cd -- {shlex.quote(target)} && "
|
|
165
|
+
"for f in *; do "
|
|
166
|
+
'[ -e "$f" ] || [ -L "$f" ] || continue; '
|
|
167
|
+
'if [ -d "$f" ]; then '
|
|
168
|
+
'echo "d|0|$f"; '
|
|
169
|
+
"else "
|
|
170
|
+
'size=$(ls -dn -- "$f" 2>/dev/null | awk \'{print $5}\'); '
|
|
171
|
+
'echo "f|${size:-0}|$f"; '
|
|
172
|
+
"fi; "
|
|
173
|
+
"done"
|
|
174
|
+
)
|
|
175
|
+
rc, out, err = await ssh_run(host, cmd)
|
|
176
|
+
if rc != 0:
|
|
177
|
+
dir_rc, _, _ = await ssh_run(host, f"test -d {shlex.quote(target)}")
|
|
178
|
+
if dir_rc != 0:
|
|
179
|
+
raise ValueError(f"not a directory or cannot access: {subpath!r}")
|
|
180
|
+
raise ValueError(err.strip() or f"failed to list remote directory: {subpath}")
|
|
181
|
+
|
|
182
|
+
dirs = []
|
|
183
|
+
files = []
|
|
184
|
+
for line in out.splitlines():
|
|
185
|
+
if not line or '|' not in line:
|
|
186
|
+
continue
|
|
187
|
+
parts = line.split('|', 2)
|
|
188
|
+
if len(parts) < 3:
|
|
189
|
+
continue
|
|
190
|
+
t, size_str, name = parts
|
|
191
|
+
try:
|
|
192
|
+
size = int(size_str)
|
|
193
|
+
except ValueError:
|
|
194
|
+
size = 0
|
|
195
|
+
if t == "d":
|
|
196
|
+
dirs.append({"name": name, "type": "dir", "size": 0})
|
|
197
|
+
else:
|
|
198
|
+
files.append({"name": name, "type": "file", "size": size})
|
|
199
|
+
|
|
200
|
+
dirs.sort(key=lambda e: e["name"].lower())
|
|
201
|
+
files.sort(key=lambda e: e["name"].lower())
|
|
202
|
+
|
|
203
|
+
norm = "" if subpath in ("", ".") else posixpath.normpath(subpath).strip("/")
|
|
204
|
+
parent = posixpath.dirname(norm) if norm else None
|
|
205
|
+
return {
|
|
206
|
+
"root": project_dir,
|
|
207
|
+
"subpath": norm,
|
|
208
|
+
"parent": parent,
|
|
209
|
+
"entries": dirs + files,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def read_file_remote(host: str, project_dir: str, subpath: str, max_bytes: int = 1_000_000) -> dict:
|
|
214
|
+
"""Read file content on remote host."""
|
|
215
|
+
target = _remote_safe_join(project_dir, subpath)
|
|
216
|
+
cmd = f"ls -dn -- {shlex.quote(target)}"
|
|
217
|
+
rc, out, err = await ssh_run(host, cmd)
|
|
218
|
+
if rc != 0:
|
|
219
|
+
raise ValueError(f"not a file or cannot access: {subpath!r}")
|
|
220
|
+
|
|
221
|
+
parts = out.strip().split()
|
|
222
|
+
if not parts or parts[0].startswith('d'):
|
|
223
|
+
raise ValueError(f"not a file: {subpath!r}")
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
size = int(parts[4])
|
|
227
|
+
except (IndexError, ValueError):
|
|
228
|
+
size = 0
|
|
229
|
+
|
|
230
|
+
base = {"name": posixpath.basename(target), "subpath": posixpath.normpath(subpath).strip("/"), "size": size}
|
|
231
|
+
if size > max_bytes:
|
|
232
|
+
return {**base, "truncated": True, "binary": False, "text": None}
|
|
233
|
+
|
|
234
|
+
# NUL byte check to see if binary
|
|
235
|
+
nul_check_cmd = (
|
|
236
|
+
f"head -c 1024 -- {shlex.quote(target)} | tr -d '\\0' | wc -c && "
|
|
237
|
+
f"head -c 1024 -- {shlex.quote(target)} | wc -c"
|
|
238
|
+
)
|
|
239
|
+
chk_rc, chk_out, _ = await ssh_run(host, nul_check_cmd)
|
|
240
|
+
is_binary = False
|
|
241
|
+
if chk_rc == 0:
|
|
242
|
+
chk_lines = chk_out.splitlines()
|
|
243
|
+
if len(chk_lines) >= 2:
|
|
244
|
+
try:
|
|
245
|
+
c1 = int(chk_lines[0].strip())
|
|
246
|
+
c2 = int(chk_lines[1].strip())
|
|
247
|
+
if c1 != c2:
|
|
248
|
+
is_binary = True
|
|
249
|
+
except ValueError:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
if is_binary:
|
|
253
|
+
return {**base, "truncated": False, "binary": True, "text": None}
|
|
254
|
+
|
|
255
|
+
cat_rc, cat_out, cat_err = await ssh_run(host, f"cat -- {shlex.quote(target)}")
|
|
256
|
+
if cat_rc != 0:
|
|
257
|
+
raise ValueError(cat_err.strip() or f"failed to read file: {subpath}")
|
|
258
|
+
|
|
259
|
+
return {**base, "truncated": False, "binary": False, "text": cat_out}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async def write_file_remote(host: str, project_dir: str, subpath: str, content: str) -> dict:
|
|
263
|
+
"""Write file content on remote host."""
|
|
264
|
+
target = _remote_safe_join(project_dir, subpath)
|
|
265
|
+
parent = posixpath.dirname(target)
|
|
266
|
+
cmd = f'mkdir -p -- {shlex.quote(parent)} && cat > {shlex.quote(target)}'
|
|
267
|
+
rc, out, err = await ssh_run_with_input(host, cmd, content)
|
|
268
|
+
if rc != 0:
|
|
269
|
+
raise ValueError(err.strip() or f"failed to write file: {subpath}")
|
|
270
|
+
return {"ok": True}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
async def delete_file_remote(host: str, project_dir: str, subpath: str) -> dict:
|
|
275
|
+
"""Delete file or directory on remote host."""
|
|
276
|
+
target = _remote_safe_join(project_dir, subpath)
|
|
277
|
+
norm = posixpath.normpath(subpath)
|
|
278
|
+
if norm in ("", ".", "/"):
|
|
279
|
+
raise ValueError("cannot delete project root directory")
|
|
280
|
+
cmd = f"rm -rf -- {shlex.quote(target)}"
|
|
281
|
+
rc, out, err = await ssh_run(host, cmd)
|
|
282
|
+
if rc != 0:
|
|
283
|
+
raise ValueError(err.strip() or f"failed to delete file/directory: {subpath}")
|
|
284
|
+
return {"ok": True}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
async def create_file_remote(host: str, project_dir: str, subpath: str, is_dir: bool = False) -> dict:
|
|
288
|
+
"""Create new file or directory on remote host."""
|
|
289
|
+
target = _remote_safe_join(project_dir, subpath)
|
|
290
|
+
if is_dir:
|
|
291
|
+
cmd = f"mkdir -p -- {shlex.quote(target)}"
|
|
292
|
+
else:
|
|
293
|
+
parent = posixpath.dirname(target)
|
|
294
|
+
cmd = f'mkdir -p -- {shlex.quote(parent)} && touch -- {shlex.quote(target)}'
|
|
295
|
+
rc, out, err = await ssh_run(host, cmd)
|
|
296
|
+
if rc != 0:
|
|
297
|
+
raise ValueError(err.strip() or f"failed to create: {subpath}")
|
|
298
|
+
return {"ok": True}
|
|
299
|
+
|
|
300
|
+
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Per-turn latency and token breakdown service.
|
|
2
|
+
|
|
3
|
+
Computes run statistics from timestamps and cost data, suitable for
|
|
4
|
+
the `ags stats` CLI command and the `/api/v1/sessions/{id}/stats` endpoint.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
|
|
12
|
+
from fastapi import HTTPException
|
|
13
|
+
from sqlalchemy import func, select
|
|
14
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
15
|
+
|
|
16
|
+
from harness.models.run import Run
|
|
17
|
+
from harness.models.run_event import RunEvent
|
|
18
|
+
from harness.models.session import Session
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _normalize(dt: datetime | None) -> datetime | None:
|
|
22
|
+
"""Normalize a datetime to be timezone-aware (UTC). If naive, assumes UTC."""
|
|
23
|
+
if dt is None:
|
|
24
|
+
return None
|
|
25
|
+
if dt.tzinfo is None:
|
|
26
|
+
return dt.replace(tzinfo=UTC)
|
|
27
|
+
return dt
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_stats(run: Run, first_event_ts: datetime | None) -> dict:
|
|
31
|
+
"""Pure helper to compute stats for a single run.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
run: The Run model instance.
|
|
35
|
+
first_event_ts: The timestamp of the first RunEvent for this run, or None.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
A dict with keys: run_id, provider, queue_ms, first_event_ms, total_ms,
|
|
39
|
+
tokens (dict with prompt/completion), resume.
|
|
40
|
+
All ms values are int or None; tokens default to 0.
|
|
41
|
+
"""
|
|
42
|
+
run_created = _normalize(run.created_at)
|
|
43
|
+
run_started = _normalize(run.started_at)
|
|
44
|
+
run_finished = _normalize(run.finished_at)
|
|
45
|
+
first_event_normalized = _normalize(first_event_ts)
|
|
46
|
+
|
|
47
|
+
# Queue time: started_at - created_at
|
|
48
|
+
queue_ms = None
|
|
49
|
+
if run_started is not None and run_created is not None:
|
|
50
|
+
queue_ms = int((run_started - run_created).total_seconds() * 1000)
|
|
51
|
+
|
|
52
|
+
# First event time: first_event_ts - started_at
|
|
53
|
+
first_event_ms = None
|
|
54
|
+
if first_event_normalized is not None and run_started is not None:
|
|
55
|
+
first_event_ms = int((first_event_normalized - run_started).total_seconds() * 1000)
|
|
56
|
+
|
|
57
|
+
# Total time: finished_at - started_at
|
|
58
|
+
total_ms = None
|
|
59
|
+
if run_finished is not None and run_started is not None:
|
|
60
|
+
total_ms = int((run_finished - run_started).total_seconds() * 1000)
|
|
61
|
+
|
|
62
|
+
# Tokens from cost dict, default 0
|
|
63
|
+
tokens = {
|
|
64
|
+
"prompt": run.cost.get("prompt_tokens", 0),
|
|
65
|
+
"completion": run.cost.get("completion_tokens", 0),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Resume mode from context_meta
|
|
69
|
+
resume = (run.context_meta or {}).get("resume")
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
"run_id": str(run.id),
|
|
73
|
+
"provider": run.adapter_kind.value,
|
|
74
|
+
"queue_ms": queue_ms,
|
|
75
|
+
"first_event_ms": first_event_ms,
|
|
76
|
+
"total_ms": total_ms,
|
|
77
|
+
"tokens": tokens,
|
|
78
|
+
"resume": resume,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def session_stats(db: AsyncSession, session_id: uuid.UUID) -> list[dict]:
|
|
83
|
+
"""Fetch per-run stats for a session.
|
|
84
|
+
|
|
85
|
+
Loads all runs for the session (ordered by created_at), then for each run
|
|
86
|
+
finds the minimum RunEvent timestamp (first event). Raises HTTPException(404)
|
|
87
|
+
if the session doesn't exist.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
db: AsyncSession for DB queries.
|
|
91
|
+
session_id: The session ID to fetch stats for.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
A list of dicts (one per run) from run_stats(), in created_at order.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
HTTPException(404): If the session doesn't exist.
|
|
98
|
+
"""
|
|
99
|
+
# Check session exists
|
|
100
|
+
sess = await db.execute(
|
|
101
|
+
select(Session).where(Session.id == session_id)
|
|
102
|
+
)
|
|
103
|
+
if sess.scalar_one_or_none() is None:
|
|
104
|
+
raise HTTPException(404, "session not found")
|
|
105
|
+
|
|
106
|
+
# Fetch all runs for the session, ordered by created_at
|
|
107
|
+
runs = (
|
|
108
|
+
await db.execute(
|
|
109
|
+
select(Run).where(Run.session_id == session_id).order_by(Run.created_at.asc())
|
|
110
|
+
)
|
|
111
|
+
).scalars().all()
|
|
112
|
+
|
|
113
|
+
# For each run, find the first event timestamp
|
|
114
|
+
first_event_by_run = {}
|
|
115
|
+
if runs:
|
|
116
|
+
run_ids = [r.id for r in runs]
|
|
117
|
+
# Query: min(RunEvent.ts) grouped by run_id for all runs in this session
|
|
118
|
+
first_events = (
|
|
119
|
+
await db.execute(
|
|
120
|
+
select(
|
|
121
|
+
RunEvent.run_id,
|
|
122
|
+
func.min(RunEvent.ts).label("first_ts"),
|
|
123
|
+
).where(RunEvent.run_id.in_(run_ids)).group_by(RunEvent.run_id)
|
|
124
|
+
)
|
|
125
|
+
).all()
|
|
126
|
+
first_event_by_run = {rid: ts for rid, ts in first_events}
|
|
127
|
+
|
|
128
|
+
# Build stats for each run
|
|
129
|
+
return [
|
|
130
|
+
run_stats(r, first_event_by_run.get(r.id))
|
|
131
|
+
for r in runs
|
|
132
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import select
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
|
|
8
|
+
from harness.models.task import Task
|
|
9
|
+
from harness.observability.audit import write_audit
|
|
10
|
+
from harness.schemas.task import TaskCreate
|
|
11
|
+
from harness.services.workspaces import get_or_create_workspace
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def create_task(db: AsyncSession, payload: TaskCreate, *, actor: str = "cli") -> Task:
|
|
15
|
+
ws = await get_or_create_workspace(db, payload.workspace)
|
|
16
|
+
task = Task(
|
|
17
|
+
workspace_id=ws.id,
|
|
18
|
+
title=payload.title,
|
|
19
|
+
objective=payload.objective,
|
|
20
|
+
constraints=payload.constraints,
|
|
21
|
+
created_by=actor,
|
|
22
|
+
)
|
|
23
|
+
db.add(task)
|
|
24
|
+
await db.flush()
|
|
25
|
+
await write_audit(
|
|
26
|
+
db, actor=actor, action="task.create", target_type="task",
|
|
27
|
+
target_id=str(task.id), after={"title": task.title, "workspace": payload.workspace},
|
|
28
|
+
)
|
|
29
|
+
return task
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def get_task(db: AsyncSession, task_id: uuid.UUID) -> Task | None:
|
|
33
|
+
return (await db.execute(select(Task).where(Task.id == task_id))).scalar_one_or_none()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def list_tasks(db: AsyncSession, *, limit: int = 50) -> list[Task]:
|
|
37
|
+
return list(
|
|
38
|
+
(
|
|
39
|
+
await db.execute(select(Task).order_by(Task.created_at.desc()).limit(limit))
|
|
40
|
+
).scalars()
|
|
41
|
+
)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import select
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
|
|
8
|
+
from harness.models.enums import TrustPolicy
|
|
9
|
+
from harness.models.workspace import Workspace
|
|
10
|
+
from harness.observability.audit import write_audit
|
|
11
|
+
from harness.security.permissions import write_mode_enabled
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def get_workspace_by_slug(db: AsyncSession, slug: str) -> Workspace | None:
|
|
15
|
+
return (
|
|
16
|
+
await db.execute(select(Workspace).where(Workspace.slug == slug))
|
|
17
|
+
).scalar_one_or_none()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def get_or_create_workspace(
|
|
21
|
+
db: AsyncSession, slug: str, *, name: str | None = None,
|
|
22
|
+
trust_policy: TrustPolicy = TrustPolicy.restricted,
|
|
23
|
+
) -> Workspace:
|
|
24
|
+
ws = await get_workspace_by_slug(db, slug)
|
|
25
|
+
if ws is None:
|
|
26
|
+
ws = Workspace(slug=slug, name=name or slug.title(), trust_policy=trust_policy)
|
|
27
|
+
db.add(ws)
|
|
28
|
+
await db.flush()
|
|
29
|
+
return ws
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── Project folder + plan/write mode (per-workspace runtime settings) ─────────
|
|
33
|
+
# Stored in ``workspace.settings`` so a change takes effect on the next run with no
|
|
34
|
+
# restart. Both fall back to the process defaults (cwd / HARNESS_WRITE) when unset.
|
|
35
|
+
|
|
36
|
+
def _mode_label(write_mode: bool) -> str:
|
|
37
|
+
return "write (agents can edit files)" if write_mode else "read-only (plan)"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _resolve_existing_dir(path: str) -> str:
|
|
41
|
+
resolved = os.path.abspath(os.path.expanduser(path))
|
|
42
|
+
if not os.path.isdir(resolved):
|
|
43
|
+
raise ValueError(f"not a directory: {resolved}")
|
|
44
|
+
return resolved
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def browse_dirs(path: str | None, *, strict: bool = False) -> dict:
|
|
48
|
+
"""List the immediate subdirectories of ``path`` (default: home) for the GUI
|
|
49
|
+
folder picker — directory *names* only, never file contents. Sync so it stays
|
|
50
|
+
off the async hot path. ``strict`` raises instead of falling back to home when
|
|
51
|
+
``path`` isn't a directory — autocomplete needs the error, the picker needs
|
|
52
|
+
the fallback."""
|
|
53
|
+
base = os.path.abspath(os.path.expanduser(path)) if path else os.path.expanduser("~")
|
|
54
|
+
if not os.path.isdir(base):
|
|
55
|
+
if strict:
|
|
56
|
+
raise ValueError(f"not a directory: {base}")
|
|
57
|
+
base = os.path.expanduser("~")
|
|
58
|
+
dirs: list[str] = []
|
|
59
|
+
try:
|
|
60
|
+
for entry in os.scandir(base):
|
|
61
|
+
if not entry.name.startswith(".") and entry.is_dir():
|
|
62
|
+
dirs.append(entry.name)
|
|
63
|
+
except OSError:
|
|
64
|
+
pass
|
|
65
|
+
dirs.sort(key=str.lower)
|
|
66
|
+
parent = os.path.dirname(base)
|
|
67
|
+
return {"path": base, "parent": parent if parent != base else None, "dirs": dirs}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def effective_host(settings: dict) -> str | None:
|
|
71
|
+
"""The ssh host a workspace's project lives on, or None for a local project."""
|
|
72
|
+
return settings.get("project_host") or None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def effective_project(settings: dict) -> tuple[str, bool]:
|
|
76
|
+
"""Resolve the (project_dir, write_mode) a run should use from a workspace's
|
|
77
|
+
settings, falling back to the process cwd and the HARNESS_WRITE env default."""
|
|
78
|
+
project_dir = settings.get("project_dir") or os.getcwd()
|
|
79
|
+
write_mode = settings.get("write_mode")
|
|
80
|
+
if write_mode is None:
|
|
81
|
+
write_mode = write_mode_enabled()
|
|
82
|
+
return project_dir, bool(write_mode)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def effective_default_workspace_mode(workspace_settings: dict) -> str:
|
|
86
|
+
"""The isolation mode NEW sessions get when the request doesn't choose:
|
|
87
|
+
workspace setting → HARNESS_WORKSPACE_MODE_DEFAULT env → private worktree."""
|
|
88
|
+
return (
|
|
89
|
+
workspace_settings.get("default_workspace_mode")
|
|
90
|
+
or os.environ.get("HARNESS_WORKSPACE_MODE_DEFAULT", "worktree")
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def effective_write_mode(session_settings: dict, workspace_settings: dict) -> bool:
|
|
95
|
+
"""Resolve the write_mode a run should use: the session's own ``write_mode``
|
|
96
|
+
override when set, else the workspace default (which itself falls back to
|
|
97
|
+
the HARNESS_WRITE env default via ``effective_project``)."""
|
|
98
|
+
session_mode = session_settings.get("write_mode")
|
|
99
|
+
if session_mode is not None:
|
|
100
|
+
return bool(session_mode)
|
|
101
|
+
return effective_project(workspace_settings)[1]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def get_project_settings(db: AsyncSession, slug: str) -> dict:
|
|
105
|
+
ws = await get_workspace_by_slug(db, slug)
|
|
106
|
+
settings = (ws.settings if ws else None) or {}
|
|
107
|
+
project_dir, write_mode = effective_project(settings)
|
|
108
|
+
return {
|
|
109
|
+
"workspace": slug,
|
|
110
|
+
"project_dir": project_dir,
|
|
111
|
+
"project_host": effective_host(settings),
|
|
112
|
+
"write_mode": write_mode,
|
|
113
|
+
"mode": _mode_label(write_mode),
|
|
114
|
+
"default_workspace_mode": effective_default_workspace_mode(settings),
|
|
115
|
+
"remote_hosts": settings.get("remote_hosts", []),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _normalize_remote_hosts(entries: list[dict], validate_host) -> list[dict]:
|
|
120
|
+
"""Validate + de-dupe a saved-hosts address book. Each entry is
|
|
121
|
+
``{label, host}``; the host is validated (raises ValueError on a bad value) and
|
|
122
|
+
duplicates (by host) are dropped. Labels are optional free text, trimmed."""
|
|
123
|
+
normalized: list[dict] = []
|
|
124
|
+
seen: set[str] = set()
|
|
125
|
+
for entry in entries or []:
|
|
126
|
+
host = validate_host(str((entry or {}).get("host", "")))
|
|
127
|
+
if host in seen:
|
|
128
|
+
continue
|
|
129
|
+
label = str((entry or {}).get("label", "")).strip()[:80]
|
|
130
|
+
normalized.append({"label": label, "host": host})
|
|
131
|
+
seen.add(host)
|
|
132
|
+
return normalized
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def set_project_settings(
|
|
136
|
+
db: AsyncSession, slug: str, *, project_dir: str | None = None,
|
|
137
|
+
project_host: str | None = None, write_mode: bool | None = None,
|
|
138
|
+
default_workspace_mode: str | None = None,
|
|
139
|
+
remote_hosts: list[dict] | None = None, actor: str = "cli",
|
|
140
|
+
) -> dict:
|
|
141
|
+
"""Update a workspace's project folder, remote host, mode, and/or saved-host
|
|
142
|
+
address book. ``project_dir`` is validated to be an existing directory — locally,
|
|
143
|
+
or over ssh when the (possibly just-set) ``project_host`` is present.
|
|
144
|
+
``project_host=""`` clears the host (back to local); ``None`` leaves it unchanged.
|
|
145
|
+
``remote_hosts`` (when given) replaces the whole saved list. Audited."""
|
|
146
|
+
from harness.services.ssh import remote_is_dir, validate_host
|
|
147
|
+
|
|
148
|
+
ws = await get_or_create_workspace(db, slug)
|
|
149
|
+
settings = dict(ws.settings or {})
|
|
150
|
+
before = {"project_dir": settings.get("project_dir"),
|
|
151
|
+
"project_host": settings.get("project_host"),
|
|
152
|
+
"write_mode": settings.get("write_mode"),
|
|
153
|
+
"default_workspace_mode": settings.get("default_workspace_mode"),
|
|
154
|
+
"remote_hosts": settings.get("remote_hosts")}
|
|
155
|
+
if remote_hosts is not None:
|
|
156
|
+
settings["remote_hosts"] = _normalize_remote_hosts(remote_hosts, validate_host)
|
|
157
|
+
if project_host is not None: # host first: dir validation depends on it
|
|
158
|
+
settings["project_host"] = validate_host(project_host) if project_host else None
|
|
159
|
+
if project_dir is not None:
|
|
160
|
+
host = effective_host(settings)
|
|
161
|
+
if host:
|
|
162
|
+
if not await remote_is_dir(host, project_dir):
|
|
163
|
+
raise ValueError(f"not a directory on {host}: {project_dir}")
|
|
164
|
+
settings["project_dir"] = project_dir # remote POSIX path, stored as-is
|
|
165
|
+
else:
|
|
166
|
+
settings["project_dir"] = _resolve_existing_dir(project_dir)
|
|
167
|
+
if write_mode is not None:
|
|
168
|
+
settings["write_mode"] = bool(write_mode)
|
|
169
|
+
if default_workspace_mode is not None:
|
|
170
|
+
if default_workspace_mode not in ("main", "worktree"):
|
|
171
|
+
raise ValueError("default_workspace_mode must be 'main' or 'worktree'")
|
|
172
|
+
settings["default_workspace_mode"] = default_workspace_mode
|
|
173
|
+
ws.settings = settings # reassign so SQLAlchemy flags the JSONB column dirty
|
|
174
|
+
await db.flush()
|
|
175
|
+
after = {"project_dir": settings.get("project_dir"),
|
|
176
|
+
"project_host": settings.get("project_host"),
|
|
177
|
+
"write_mode": settings.get("write_mode"),
|
|
178
|
+
"default_workspace_mode": settings.get("default_workspace_mode"),
|
|
179
|
+
"remote_hosts": settings.get("remote_hosts")}
|
|
180
|
+
await write_audit(
|
|
181
|
+
db, actor=actor, action="set_project", target_type="workspace", target_id=slug,
|
|
182
|
+
before=before, after=after,
|
|
183
|
+
)
|
|
184
|
+
return await get_project_settings(db, slug)
|