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,302 @@
|
|
|
1
|
+
"""Project folder + plan/write mode for a workspace — runtime, no restart.
|
|
2
|
+
|
|
3
|
+
These are stored in ``workspace.settings`` and read by the orchestrator on each run,
|
|
4
|
+
so changing them here takes effect on the next message. Drives ``ags project``."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, HTTPException
|
|
11
|
+
from fastapi.responses import FileResponse
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from harness.deps import CurrentPrincipal, DbSession
|
|
15
|
+
from harness.services import project_git
|
|
16
|
+
from harness.services.files import (
|
|
17
|
+
create_entry,
|
|
18
|
+
delete_entry,
|
|
19
|
+
file_path,
|
|
20
|
+
find_files,
|
|
21
|
+
list_dir,
|
|
22
|
+
read_file,
|
|
23
|
+
rename_entry,
|
|
24
|
+
search_content,
|
|
25
|
+
write_file,
|
|
26
|
+
)
|
|
27
|
+
from harness.services.ssh import (
|
|
28
|
+
browse_dirs_remote,
|
|
29
|
+
ssh_check,
|
|
30
|
+
validate_host,
|
|
31
|
+
list_dir_remote,
|
|
32
|
+
read_file_remote,
|
|
33
|
+
write_file_remote,
|
|
34
|
+
delete_file_remote,
|
|
35
|
+
create_file_remote,
|
|
36
|
+
)
|
|
37
|
+
from harness.services.workspaces import browse_dirs, get_project_settings, set_project_settings
|
|
38
|
+
|
|
39
|
+
router = APIRouter(prefix="/project", tags=["project"])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ProjectUpdate(BaseModel):
|
|
43
|
+
project_dir: str | None = None
|
|
44
|
+
project_host: str | None = None # "" clears (back to local)
|
|
45
|
+
write_mode: bool | None = None
|
|
46
|
+
default_workspace_mode: str | None = None # "main" | "worktree" (new sessions)
|
|
47
|
+
remote_hosts: list[dict] | None = None # saved-host address book [{label, host}]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SshTest(BaseModel):
|
|
51
|
+
host: str
|
|
52
|
+
path: str | None = None
|
|
53
|
+
bin: str | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class FileWrite(BaseModel):
|
|
57
|
+
subpath: str
|
|
58
|
+
text: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class EntryCreate(BaseModel):
|
|
62
|
+
subpath: str
|
|
63
|
+
kind: str # "file" | "dir"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class EntryRename(BaseModel):
|
|
67
|
+
src: str
|
|
68
|
+
dst: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@router.get("")
|
|
72
|
+
async def get_project(db: DbSession, _: CurrentPrincipal, workspace: str = "default") -> dict:
|
|
73
|
+
return await get_project_settings(db, workspace)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.get("/browse")
|
|
77
|
+
async def browse(
|
|
78
|
+
_: CurrentPrincipal, path: str | None = None, strict: bool = False,
|
|
79
|
+
host: str | None = None,
|
|
80
|
+
) -> dict:
|
|
81
|
+
"""List subdirectories of ``path`` (default: home) for the GUI folder picker.
|
|
82
|
+
``strict`` 404s on a non-directory instead of falling back to home (autocomplete).
|
|
83
|
+
``host`` browses a remote ssh host instead of the server's filesystem."""
|
|
84
|
+
try:
|
|
85
|
+
if host:
|
|
86
|
+
return await browse_dirs_remote(host, path)
|
|
87
|
+
return browse_dirs(path, strict=strict)
|
|
88
|
+
except ValueError as exc:
|
|
89
|
+
raise HTTPException(404, str(exc)) from exc
|
|
90
|
+
except (TimeoutError, OSError) as exc:
|
|
91
|
+
raise HTTPException(502, f"ssh to {host!r} failed: {exc}") from exc
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@router.post("/ssh-test")
|
|
95
|
+
async def ssh_test(req: SshTest, _: CurrentPrincipal) -> dict:
|
|
96
|
+
"""Probe an ssh host for the settings page — connectivity, optional remote dir,
|
|
97
|
+
optional agent-CLI version. Connection failures return ``ok: false``, never 5xx."""
|
|
98
|
+
try:
|
|
99
|
+
validate_host(req.host)
|
|
100
|
+
except ValueError as exc:
|
|
101
|
+
raise HTTPException(422, str(exc)) from exc
|
|
102
|
+
return await ssh_check(req.host, path=req.path, bin=req.bin)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _reject_remote(project: dict, what: str) -> None:
|
|
106
|
+
"""Local-filesystem endpoints refuse remote projects with a clear 400 — the
|
|
107
|
+
server can't (and shouldn't) read the remote host's files."""
|
|
108
|
+
if project.get("project_host"):
|
|
109
|
+
raise HTTPException(
|
|
110
|
+
400, f"{what} unavailable for remote project on {project['project_host']!r}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@router.get("/tree")
|
|
114
|
+
async def tree(
|
|
115
|
+
db: DbSession, _: CurrentPrincipal, subpath: str = "", workspace: str = "default"
|
|
116
|
+
) -> dict:
|
|
117
|
+
"""List files + subdirectories of ``subpath`` within the workspace's active project
|
|
118
|
+
folder (read-only, scoped to that folder). Drives the GUI file explorer."""
|
|
119
|
+
project = await get_project_settings(db, workspace)
|
|
120
|
+
if project.get("project_host"):
|
|
121
|
+
try:
|
|
122
|
+
return await list_dir_remote(project["project_host"], project["project_dir"], subpath)
|
|
123
|
+
except ValueError as exc:
|
|
124
|
+
raise HTTPException(404, str(exc)) from exc
|
|
125
|
+
except (TimeoutError, OSError) as exc:
|
|
126
|
+
raise HTTPException(502, f"SSH error: {exc}") from exc
|
|
127
|
+
_reject_remote(project, "file explorer")
|
|
128
|
+
try:
|
|
129
|
+
return list_dir(project["project_dir"], subpath)
|
|
130
|
+
except ValueError as exc:
|
|
131
|
+
raise HTTPException(404, str(exc)) from exc
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@router.get("/file")
|
|
135
|
+
async def file(
|
|
136
|
+
db: DbSession, _: CurrentPrincipal, subpath: str, workspace: str = "default"
|
|
137
|
+
) -> dict:
|
|
138
|
+
"""Return the UTF-8 text of a file within the workspace's active project folder.
|
|
139
|
+
Paths are resolved and asserted to stay inside that folder."""
|
|
140
|
+
project = await get_project_settings(db, workspace)
|
|
141
|
+
if project.get("project_host"):
|
|
142
|
+
try:
|
|
143
|
+
return await read_file_remote(project["project_host"], project["project_dir"], subpath)
|
|
144
|
+
except ValueError as exc:
|
|
145
|
+
raise HTTPException(404, str(exc)) from exc
|
|
146
|
+
except (TimeoutError, OSError) as exc:
|
|
147
|
+
raise HTTPException(502, f"SSH error: {exc}") from exc
|
|
148
|
+
_reject_remote(project, "file explorer")
|
|
149
|
+
try:
|
|
150
|
+
return read_file(project["project_dir"], subpath)
|
|
151
|
+
except ValueError as exc:
|
|
152
|
+
raise HTTPException(404, str(exc)) from exc
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@router.get("/raw")
|
|
156
|
+
async def raw(
|
|
157
|
+
db: DbSession, _: CurrentPrincipal, subpath: str, workspace: str = "default"
|
|
158
|
+
) -> FileResponse:
|
|
159
|
+
"""Stream a file's raw bytes (content-type guessed from the name) so the GUI can
|
|
160
|
+
render images and other binaries. Path-scoped to the project folder."""
|
|
161
|
+
project = await get_project_settings(db, workspace)
|
|
162
|
+
_reject_remote(project, "file explorer")
|
|
163
|
+
try:
|
|
164
|
+
path = file_path(project["project_dir"], subpath)
|
|
165
|
+
except ValueError as exc:
|
|
166
|
+
raise HTTPException(404, str(exc)) from exc
|
|
167
|
+
return FileResponse(path)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@router.put("/file")
|
|
171
|
+
async def write(
|
|
172
|
+
req: FileWrite, db: DbSession, _: CurrentPrincipal, workspace: str = "default"
|
|
173
|
+
) -> dict:
|
|
174
|
+
"""Write UTF-8 text to a file within the project folder (creating it if needed).
|
|
175
|
+
Path-scoped via ``_safe_join``; refuses directories and over-cap payloads."""
|
|
176
|
+
project = await get_project_settings(db, workspace)
|
|
177
|
+
if project.get("project_host"):
|
|
178
|
+
try:
|
|
179
|
+
return await write_file_remote(
|
|
180
|
+
project["project_host"], project["project_dir"], req.subpath, req.text
|
|
181
|
+
)
|
|
182
|
+
except ValueError as exc:
|
|
183
|
+
raise HTTPException(400, str(exc)) from exc
|
|
184
|
+
except (TimeoutError, OSError) as exc:
|
|
185
|
+
raise HTTPException(502, f"SSH error: {exc}") from exc
|
|
186
|
+
_reject_remote(project, "file editing")
|
|
187
|
+
try:
|
|
188
|
+
return write_file(project["project_dir"], req.subpath, req.text)
|
|
189
|
+
except ValueError as exc:
|
|
190
|
+
raise HTTPException(400, str(exc)) from exc
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@router.post("/entry", status_code=201)
|
|
194
|
+
async def create(
|
|
195
|
+
req: EntryCreate, db: DbSession, _: CurrentPrincipal, workspace: str = "default"
|
|
196
|
+
) -> dict:
|
|
197
|
+
"""Create a new empty file or directory within the project folder."""
|
|
198
|
+
project = await get_project_settings(db, workspace)
|
|
199
|
+
if project.get("project_host"):
|
|
200
|
+
try:
|
|
201
|
+
return await create_file_remote(
|
|
202
|
+
project["project_host"], project["project_dir"], req.subpath, req.kind == "dir"
|
|
203
|
+
)
|
|
204
|
+
except ValueError as exc:
|
|
205
|
+
raise HTTPException(400, str(exc)) from exc
|
|
206
|
+
except (TimeoutError, OSError) as exc:
|
|
207
|
+
raise HTTPException(502, f"SSH error: {exc}") from exc
|
|
208
|
+
_reject_remote(project, "file management")
|
|
209
|
+
try:
|
|
210
|
+
return create_entry(project["project_dir"], req.subpath, req.kind)
|
|
211
|
+
except ValueError as exc:
|
|
212
|
+
raise HTTPException(400, str(exc)) from exc
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@router.post("/rename")
|
|
216
|
+
async def rename(
|
|
217
|
+
req: EntryRename, db: DbSession, _: CurrentPrincipal, workspace: str = "default"
|
|
218
|
+
) -> dict:
|
|
219
|
+
"""Rename/move a file or directory within the project folder."""
|
|
220
|
+
project = await get_project_settings(db, workspace)
|
|
221
|
+
_reject_remote(project, "file management")
|
|
222
|
+
try:
|
|
223
|
+
return rename_entry(project["project_dir"], req.src, req.dst)
|
|
224
|
+
except ValueError as exc:
|
|
225
|
+
raise HTTPException(400, str(exc)) from exc
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@router.delete("/file")
|
|
229
|
+
async def delete(
|
|
230
|
+
db: DbSession, _: CurrentPrincipal, subpath: str, workspace: str = "default"
|
|
231
|
+
) -> dict:
|
|
232
|
+
"""Soft-delete a file or directory (moved into ``.ags-trash/`` at the project root)."""
|
|
233
|
+
project = await get_project_settings(db, workspace)
|
|
234
|
+
if project.get("project_host"):
|
|
235
|
+
try:
|
|
236
|
+
return await delete_file_remote(
|
|
237
|
+
project["project_host"], project["project_dir"], subpath
|
|
238
|
+
)
|
|
239
|
+
except ValueError as exc:
|
|
240
|
+
raise HTTPException(400, str(exc)) from exc
|
|
241
|
+
except (TimeoutError, OSError) as exc:
|
|
242
|
+
raise HTTPException(502, f"SSH error: {exc}") from exc
|
|
243
|
+
_reject_remote(project, "file management")
|
|
244
|
+
try:
|
|
245
|
+
return delete_entry(project["project_dir"], subpath)
|
|
246
|
+
except ValueError as exc:
|
|
247
|
+
raise HTTPException(400, str(exc)) from exc
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@router.get("/find")
|
|
251
|
+
async def find(
|
|
252
|
+
db: DbSession, _: CurrentPrincipal, q: str, workspace: str = "default"
|
|
253
|
+
) -> dict:
|
|
254
|
+
"""Quick-open: files whose path matches every token of ``q`` (case-insensitive)."""
|
|
255
|
+
project = await get_project_settings(db, workspace)
|
|
256
|
+
_reject_remote(project, "file search")
|
|
257
|
+
return await asyncio.to_thread(find_files, project["project_dir"], q)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@router.get("/search")
|
|
261
|
+
async def search(
|
|
262
|
+
db: DbSession, _: CurrentPrincipal, q: str, workspace: str = "default"
|
|
263
|
+
) -> dict:
|
|
264
|
+
"""Full-text content search across project files (ripgrep when available)."""
|
|
265
|
+
project = await get_project_settings(db, workspace)
|
|
266
|
+
_reject_remote(project, "file search")
|
|
267
|
+
return await asyncio.to_thread(search_content, project["project_dir"], q)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@router.get("/git-status")
|
|
271
|
+
async def git_status(
|
|
272
|
+
db: DbSession, _: CurrentPrincipal, workspace: str = "default"
|
|
273
|
+
) -> dict:
|
|
274
|
+
"""Working-tree git status ({subpath: modified|added|deleted|renamed|untracked})."""
|
|
275
|
+
project = await get_project_settings(db, workspace)
|
|
276
|
+
_reject_remote(project, "git status")
|
|
277
|
+
return await asyncio.to_thread(project_git.git_status, project["project_dir"])
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@router.get("/git-diff")
|
|
281
|
+
async def git_diff(
|
|
282
|
+
db: DbSession, _: CurrentPrincipal, subpath: str, workspace: str = "default"
|
|
283
|
+
) -> dict:
|
|
284
|
+
"""Per-file working-tree diff vs HEAD (falls back to full content for new files)."""
|
|
285
|
+
project = await get_project_settings(db, workspace)
|
|
286
|
+
_reject_remote(project, "git diff")
|
|
287
|
+
return await asyncio.to_thread(project_git.file_diff, project["project_dir"], subpath)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@router.put("")
|
|
291
|
+
async def put_project(
|
|
292
|
+
req: ProjectUpdate, db: DbSession, principal: CurrentPrincipal, workspace: str = "default"
|
|
293
|
+
) -> dict:
|
|
294
|
+
try:
|
|
295
|
+
return await set_project_settings(
|
|
296
|
+
db, workspace, project_dir=req.project_dir, project_host=req.project_host,
|
|
297
|
+
write_mode=req.write_mode,
|
|
298
|
+
default_workspace_mode=req.default_workspace_mode,
|
|
299
|
+
remote_hosts=req.remote_hosts, actor=principal.subject,
|
|
300
|
+
)
|
|
301
|
+
except ValueError as exc:
|
|
302
|
+
raise HTTPException(422, str(exc)) from exc
|
harness/api/v1/runs.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, HTTPException
|
|
6
|
+
|
|
7
|
+
from harness.deps import CurrentPrincipal, DbSession
|
|
8
|
+
from harness.orchestrator.lifecycle import get_canceller
|
|
9
|
+
from harness.schemas.common import Message
|
|
10
|
+
from harness.schemas.run import (
|
|
11
|
+
CompareRequest,
|
|
12
|
+
ComparisonOut,
|
|
13
|
+
MergeRequest,
|
|
14
|
+
RunOut,
|
|
15
|
+
RunStartRequest,
|
|
16
|
+
RunStartResponse,
|
|
17
|
+
)
|
|
18
|
+
from harness.services.comparison import compare_runs, merge_runs
|
|
19
|
+
from harness.services.runs import start_run
|
|
20
|
+
from harness.services.runs_diff import latest_run_diff, run_diff
|
|
21
|
+
|
|
22
|
+
router = APIRouter(prefix="/runs", tags=["runs"])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.post("/start", response_model=RunStartResponse, status_code=201)
|
|
26
|
+
async def start(
|
|
27
|
+
req: RunStartRequest, db: DbSession, principal: CurrentPrincipal,
|
|
28
|
+
inline: bool = False,
|
|
29
|
+
) -> RunStartResponse:
|
|
30
|
+
try:
|
|
31
|
+
session_id, runs, dispatched = await start_run(
|
|
32
|
+
db, req, inline=inline, actor=principal.subject
|
|
33
|
+
)
|
|
34
|
+
except ValueError as exc:
|
|
35
|
+
raise HTTPException(400, str(exc)) from exc
|
|
36
|
+
return RunStartResponse(
|
|
37
|
+
session_id=session_id,
|
|
38
|
+
runs=[RunOut.model_validate(r) for r in runs],
|
|
39
|
+
dispatched=dispatched,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.post("/{run_id}/cancel", response_model=Message)
|
|
44
|
+
async def cancel(run_id: uuid.UUID, _: CurrentPrincipal) -> Message:
|
|
45
|
+
canceller = get_canceller()
|
|
46
|
+
await canceller.request_cancel(str(run_id))
|
|
47
|
+
await canceller.aclose() # no-op for the in-memory (local) registry
|
|
48
|
+
return Message(message=f"cancellation requested for run {run_id}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.post("/compare", response_model=ComparisonOut)
|
|
52
|
+
async def compare(req: CompareRequest, db: DbSession, _: CurrentPrincipal) -> ComparisonOut:
|
|
53
|
+
try:
|
|
54
|
+
comp = await compare_runs(
|
|
55
|
+
db, run_ids=req.run_ids, strategy=req.strategy,
|
|
56
|
+
judge_provider=req.judge_provider, judge_model=req.judge_model,
|
|
57
|
+
)
|
|
58
|
+
except ValueError as exc:
|
|
59
|
+
raise HTTPException(400, str(exc)) from exc
|
|
60
|
+
return ComparisonOut.model_validate(comp)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.post("/merge", response_model=ComparisonOut)
|
|
64
|
+
async def merge(req: MergeRequest, db: DbSession, _: CurrentPrincipal) -> ComparisonOut:
|
|
65
|
+
try:
|
|
66
|
+
comp = await merge_runs(
|
|
67
|
+
db, run_ids=req.run_ids, persist=req.persist, merged_content=req.merged_content
|
|
68
|
+
)
|
|
69
|
+
except ValueError as exc:
|
|
70
|
+
raise HTTPException(400, str(exc)) from exc
|
|
71
|
+
return ComparisonOut.model_validate(comp)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@router.get("/{run_id}/diff")
|
|
75
|
+
async def diff(run_id: uuid.UUID, db: DbSession, _: CurrentPrincipal) -> dict:
|
|
76
|
+
"""Return the git diff between the run's pre-run snapshot and the current HEAD.
|
|
77
|
+
|
|
78
|
+
404 when the run has no snapshot artifact (e.g. read-only / plan-mode runs).
|
|
79
|
+
Response: ``{"run_id", "snapshot", "diff", "truncated"}``.
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
return await run_diff(db, run_id)
|
|
83
|
+
except ValueError as exc:
|
|
84
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@router.get("/diff/latest")
|
|
88
|
+
async def diff_latest(
|
|
89
|
+
db: DbSession, _: CurrentPrincipal, workspace: str = "default"
|
|
90
|
+
) -> dict:
|
|
91
|
+
"""Return the diff for the newest run (in *workspace*) that has a snapshot artifact.
|
|
92
|
+
|
|
93
|
+
404 when no qualifying run exists.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
return await latest_run_diff(db, workspace)
|
|
97
|
+
except ValueError as exc:
|
|
98
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, HTTPException
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from harness.deps import CurrentPrincipal, DbSession
|
|
9
|
+
from harness.schemas.run import RunEventOut, RunOut, RunStartRequest, RunStartResponse
|
|
10
|
+
from harness.schemas.session import (
|
|
11
|
+
SessionBranchRequest,
|
|
12
|
+
SessionClearRequest,
|
|
13
|
+
SessionClearResponse,
|
|
14
|
+
SessionMergeRequest,
|
|
15
|
+
SessionMergeResponse,
|
|
16
|
+
SessionModeOut,
|
|
17
|
+
SessionOut,
|
|
18
|
+
SessionResumeRequest,
|
|
19
|
+
SessionSettingsUpdate,
|
|
20
|
+
SessionWorkspaceOut,
|
|
21
|
+
)
|
|
22
|
+
from harness.services.retention import compact_sessions
|
|
23
|
+
from harness.services.runs import start_run
|
|
24
|
+
from harness.services.sessions import (
|
|
25
|
+
branch_session,
|
|
26
|
+
delete_sessions,
|
|
27
|
+
get_session,
|
|
28
|
+
get_session_mode,
|
|
29
|
+
list_recent_sessions,
|
|
30
|
+
list_run_events,
|
|
31
|
+
list_session_runs,
|
|
32
|
+
select_sessions_to_clear,
|
|
33
|
+
set_session_settings,
|
|
34
|
+
)
|
|
35
|
+
from harness.services.stats import session_stats
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CompactRequest(BaseModel):
|
|
39
|
+
older_than_days: int = Field(..., ge=1)
|
|
40
|
+
dry_run: bool = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CompactResponse(BaseModel):
|
|
44
|
+
sessions: int
|
|
45
|
+
events_deleted: int
|
|
46
|
+
dry_run: bool
|
|
47
|
+
|
|
48
|
+
router = APIRouter(prefix="/sessions", tags=["sessions"])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.get("", response_model=list[SessionOut])
|
|
52
|
+
async def index(db: DbSession, _: CurrentPrincipal, limit: int = 50) -> list[SessionOut]:
|
|
53
|
+
return [SessionOut.model_validate(s) for s in await list_recent_sessions(db, limit=limit)]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@router.post("/compact", response_model=CompactResponse)
|
|
57
|
+
async def compact(
|
|
58
|
+
req: CompactRequest, db: DbSession, _: CurrentPrincipal
|
|
59
|
+
) -> CompactResponse:
|
|
60
|
+
"""Prune bulky token and tool_result events from old terminal sessions.
|
|
61
|
+
|
|
62
|
+
Keeps the essential audit trail (message, status, cost, adapter_meta,
|
|
63
|
+
tool_call, error, artifact). With ``dry_run`` it only reports counts."""
|
|
64
|
+
report = await compact_sessions(db, older_than_days=req.older_than_days, dry_run=req.dry_run)
|
|
65
|
+
return CompactResponse(
|
|
66
|
+
sessions=report.sessions,
|
|
67
|
+
events_deleted=report.events_deleted,
|
|
68
|
+
dry_run=req.dry_run,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.post("/clear", response_model=SessionClearResponse)
|
|
73
|
+
async def clear(
|
|
74
|
+
req: SessionClearRequest, db: DbSession, _: CurrentPrincipal
|
|
75
|
+
) -> SessionClearResponse:
|
|
76
|
+
"""Hard-delete sessions matching the filters (AND-combined). With ``dry_run`` it only
|
|
77
|
+
reports the matches. Refuses an unfiltered request unless ``all`` is set."""
|
|
78
|
+
if not (req.all or req.status or req.older_than_days or req.workspace or req.ids):
|
|
79
|
+
raise HTTPException(400, "refusing to clear without a filter (set all=true to wipe all)")
|
|
80
|
+
matches = await select_sessions_to_clear(
|
|
81
|
+
db, all=req.all, status=req.status, older_than_days=req.older_than_days,
|
|
82
|
+
workspace_slug=req.workspace, ids=req.ids,
|
|
83
|
+
)
|
|
84
|
+
out = [SessionOut.model_validate(s) for s in matches]
|
|
85
|
+
if req.dry_run:
|
|
86
|
+
return SessionClearResponse(count=len(out), deleted=False, sessions=out)
|
|
87
|
+
n = await delete_sessions(db, matches)
|
|
88
|
+
return SessionClearResponse(count=n, deleted=True, sessions=out)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@router.post("/prune-worktrees")
|
|
92
|
+
async def prune_worktrees(db: DbSession, _: CurrentPrincipal) -> dict:
|
|
93
|
+
"""Remove worktree directories whose session no longer exists."""
|
|
94
|
+
from harness.services.worktrees import prune_orphan_worktrees
|
|
95
|
+
|
|
96
|
+
return await prune_orphan_worktrees(db)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@router.get("/{session_id}", response_model=SessionOut)
|
|
100
|
+
async def get(session_id: uuid.UUID, db: DbSession, _: CurrentPrincipal) -> SessionOut:
|
|
101
|
+
sess = await get_session(db, session_id)
|
|
102
|
+
if sess is None:
|
|
103
|
+
raise HTTPException(404, "session not found")
|
|
104
|
+
return SessionOut.model_validate(sess)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@router.get("/{session_id}/settings", response_model=SessionModeOut)
|
|
108
|
+
async def get_settings(
|
|
109
|
+
session_id: uuid.UUID, db: DbSession, _: CurrentPrincipal
|
|
110
|
+
) -> SessionModeOut:
|
|
111
|
+
"""The session's plan/write mode: its own override plus the resolved effective value."""
|
|
112
|
+
sess = await get_session(db, session_id)
|
|
113
|
+
if sess is None:
|
|
114
|
+
raise HTTPException(404, "session not found")
|
|
115
|
+
return SessionModeOut(**await get_session_mode(db, sess))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@router.put("/{session_id}/settings", response_model=SessionModeOut)
|
|
119
|
+
async def put_settings(
|
|
120
|
+
session_id: uuid.UUID, req: SessionSettingsUpdate, db: DbSession,
|
|
121
|
+
principal: CurrentPrincipal,
|
|
122
|
+
) -> SessionModeOut:
|
|
123
|
+
"""Set (true/false) or clear (explicit null) the session's write-mode override.
|
|
124
|
+
Takes effect on the session's next turn."""
|
|
125
|
+
sess = await get_session(db, session_id)
|
|
126
|
+
if sess is None:
|
|
127
|
+
raise HTTPException(404, "session not found")
|
|
128
|
+
if "write_mode" in req.model_fields_set:
|
|
129
|
+
sess = await set_session_settings(
|
|
130
|
+
db, session_id, write_mode=req.write_mode, actor=principal.subject
|
|
131
|
+
)
|
|
132
|
+
return SessionModeOut(**await get_session_mode(db, sess))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@router.get("/{session_id}/workspace", response_model=SessionWorkspaceOut)
|
|
136
|
+
async def workspace_status(
|
|
137
|
+
session_id: uuid.UUID, db: DbSession, _: CurrentPrincipal
|
|
138
|
+
) -> SessionWorkspaceOut:
|
|
139
|
+
"""Isolation state of the session's private workspace (worktree)."""
|
|
140
|
+
from harness.services.worktrees import worktree_status
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
return SessionWorkspaceOut(**await worktree_status(db, session_id))
|
|
144
|
+
except ValueError as exc:
|
|
145
|
+
raise HTTPException(404, str(exc)) from exc
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@router.post("/{session_id}/merge", response_model=SessionMergeResponse)
|
|
149
|
+
async def merge_workspace(
|
|
150
|
+
session_id: uuid.UUID, req: SessionMergeRequest, db: DbSession,
|
|
151
|
+
principal: CurrentPrincipal,
|
|
152
|
+
) -> SessionMergeResponse:
|
|
153
|
+
"""Merge the session's private branch back into its base branch. Conflicts
|
|
154
|
+
abort cleanly and come back in ``conflicts``; user-fixable refusals are 400."""
|
|
155
|
+
from harness.services.worktrees import merge_session_worktree
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
out = await merge_session_worktree(
|
|
159
|
+
db, session_id, squash=req.squash, delete_worktree=req.delete_worktree,
|
|
160
|
+
message=req.message, actor=principal.subject,
|
|
161
|
+
)
|
|
162
|
+
except ValueError as exc:
|
|
163
|
+
raise HTTPException(400, str(exc)) from exc
|
|
164
|
+
return SessionMergeResponse(**out)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@router.delete("/{session_id}/workspace")
|
|
168
|
+
async def discard_workspace(
|
|
169
|
+
session_id: uuid.UUID, db: DbSession, principal: CurrentPrincipal,
|
|
170
|
+
) -> dict:
|
|
171
|
+
"""Discard the session's worktree AND its branch (keep the session). This is
|
|
172
|
+
an explicit destructive action — the next turn gets a fresh worktree off the
|
|
173
|
+
current base. (Session *deletion* keeps unmerged branches as a safety net;
|
|
174
|
+
discard means discard.)"""
|
|
175
|
+
from harness.services.sessions import get_session as _get
|
|
176
|
+
from harness.services.worktrees import remove_worktree
|
|
177
|
+
|
|
178
|
+
sess = await _get(db, session_id)
|
|
179
|
+
if sess is None:
|
|
180
|
+
raise HTTPException(404, "session not found")
|
|
181
|
+
removed = await remove_worktree(
|
|
182
|
+
db, session_id, delete_branch=True, actor=principal.subject,
|
|
183
|
+
)
|
|
184
|
+
return {"removed": removed}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@router.get("/{session_id}/runs", response_model=list[RunOut])
|
|
188
|
+
async def runs(session_id: uuid.UUID, db: DbSession, _: CurrentPrincipal) -> list[RunOut]:
|
|
189
|
+
return [RunOut.model_validate(r) for r in await list_session_runs(db, session_id)]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@router.get("/{session_id}/runs/{run_id}/events", response_model=list[RunEventOut])
|
|
193
|
+
async def events(
|
|
194
|
+
session_id: uuid.UUID, run_id: uuid.UUID, db: DbSession, _: CurrentPrincipal
|
|
195
|
+
) -> list[RunEventOut]:
|
|
196
|
+
return [
|
|
197
|
+
RunEventOut(seq=e.seq, type=e.type.value, ts=e.ts, payload=e.payload)
|
|
198
|
+
for e in await list_run_events(db, run_id)
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@router.post("/{session_id}/resume", response_model=RunStartResponse, status_code=201)
|
|
203
|
+
async def resume(
|
|
204
|
+
session_id: uuid.UUID, req: SessionResumeRequest, db: DbSession, principal: CurrentPrincipal,
|
|
205
|
+
inline: bool = False,
|
|
206
|
+
) -> RunStartResponse:
|
|
207
|
+
"""Continue a session from its canonical summary (not raw transcript)."""
|
|
208
|
+
sess = await get_session(db, session_id)
|
|
209
|
+
if sess is None:
|
|
210
|
+
raise HTTPException(404, "session not found")
|
|
211
|
+
start_req = RunStartRequest(
|
|
212
|
+
session_id=session_id, policy=req.policy, providers=req.providers,
|
|
213
|
+
additional_objective=req.additional_objective, model=req.model, models=req.models,
|
|
214
|
+
attachments=req.attachments,
|
|
215
|
+
)
|
|
216
|
+
try:
|
|
217
|
+
sid, runs_, dispatched = await start_run(
|
|
218
|
+
db, start_req, inline=inline, actor=principal.subject
|
|
219
|
+
)
|
|
220
|
+
except ValueError as exc: # user-fixable (e.g. attachment rejections) → 400
|
|
221
|
+
raise HTTPException(400, str(exc)) from exc
|
|
222
|
+
return RunStartResponse(
|
|
223
|
+
session_id=sid, runs=[RunOut.model_validate(r) for r in runs_], dispatched=dispatched
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@router.post("/{session_id}/branch", response_model=SessionOut, status_code=201)
|
|
228
|
+
async def branch(
|
|
229
|
+
session_id: uuid.UUID, _req: SessionBranchRequest, db: DbSession, principal: CurrentPrincipal
|
|
230
|
+
) -> SessionOut:
|
|
231
|
+
child = await branch_session(db, session_id, actor=principal.subject)
|
|
232
|
+
return SessionOut.model_validate(child)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@router.get("/{session_id}/stats", response_model=list[dict])
|
|
236
|
+
async def stats(session_id: uuid.UUID, db: DbSession, _: CurrentPrincipal) -> list[dict]:
|
|
237
|
+
"""Per-run latency and token breakdown for a session.
|
|
238
|
+
|
|
239
|
+
Returns a list of dicts (one per run, ordered by created_at) with keys:
|
|
240
|
+
- run_id: The run UUID
|
|
241
|
+
- provider: The adapter kind (e.g. claude_code)
|
|
242
|
+
- queue_ms: Time from created_at to started_at (int, None if not started)
|
|
243
|
+
- first_event_ms: Time from started_at to first RunEvent (int, None if no events)
|
|
244
|
+
- total_ms: Time from started_at to finished_at (int, None if not finished)
|
|
245
|
+
- tokens: {prompt: int, completion: int} from run.cost (default 0)
|
|
246
|
+
- resume: Resume mode from run.context_meta, or None
|
|
247
|
+
"""
|
|
248
|
+
return await session_stats(db, session_id)
|