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,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)