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