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,155 @@
1
+ """Run-start orchestration entrypoint shared by API and CLI.
2
+
3
+ Resolves the routing policy, materializes a session + queued runs, and either
4
+ dispatches an in-process asyncio task (async, the default) or executes inline
5
+ (when ``inline=True``, used by tests and ``--inline`` CLI runs)."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+
13
+ from harness.models.run import Run
14
+ from harness.observability.audit import write_audit
15
+ from harness.orchestrator.engine import create_runs, execute_session
16
+ from harness.orchestrator.policies import compile_policy, load_policy_specs
17
+ from harness.schemas.run import RunStartRequest
18
+ from harness.services.sessions import create_session, get_session, list_session_runs
19
+ from harness.services.tasks import get_task
20
+ from harness.services.workspaces import get_or_create_workspace
21
+ from harness.settings import get_settings
22
+
23
+
24
+ async def start_run(
25
+ db: AsyncSession,
26
+ req: RunStartRequest,
27
+ *,
28
+ inline: bool = False,
29
+ actor: str = "cli",
30
+ ) -> tuple[uuid.UUID, list[Run], bool]:
31
+ """Returns (session_id, runs, dispatched)."""
32
+ config = get_settings().config()
33
+ specs = load_policy_specs(config)
34
+ policy_spec = specs.get(req.policy, {"name": req.policy, "mode": req.policy})
35
+ policy = compile_policy(policy_spec, override_providers=req.providers)
36
+
37
+ # Resolve session: resume existing, or create from task/objective.
38
+ if req.session_id is not None:
39
+ session = await get_session(db, req.session_id)
40
+ if session is None:
41
+ raise ValueError(f"session {req.session_id} not found")
42
+ else:
43
+ if req.task_id is not None:
44
+ task = await get_task(db, req.task_id)
45
+ if task is None:
46
+ raise ValueError(f"task {req.task_id} not found")
47
+ workspace_id, objective, constraints, task_id = (
48
+ task.workspace_id, task.objective, task.constraints, task.id,
49
+ )
50
+ else:
51
+ if not req.objective:
52
+ raise ValueError("either task_id or objective is required")
53
+ ws = await get_or_create_workspace(db, req.workspace)
54
+ workspace_id, objective, constraints, task_id = (ws.id, req.objective, {}, None)
55
+ # Isolation choice for the NEW session: request → workspace default →
56
+ # global default (private worktree). The engine may still downgrade to
57
+ # main at run time (non-git dir, remote host) with a recorded reason.
58
+ from harness.models.workspace import Workspace
59
+ from harness.services.workspaces import effective_default_workspace_mode
60
+
61
+ ws_obj = await db.get(Workspace, workspace_id)
62
+ workspace_mode = req.workspace_mode or effective_default_workspace_mode(
63
+ (ws_obj.settings if ws_obj else None) or {}
64
+ )
65
+ # Plan/edit choice for the NEW session: an explicit request write_mode is
66
+ # stored on the session; omitting it (None) inherits the workspace/env default
67
+ # (plan unless HARNESS_WRITE is set).
68
+ session_settings: dict = {"workspace_mode": workspace_mode}
69
+ if req.write_mode is not None:
70
+ session_settings["write_mode"] = bool(req.write_mode)
71
+ session = await create_session(
72
+ db, workspace_id=workspace_id, objective=objective,
73
+ constraints=constraints, task_id=task_id,
74
+ settings=session_settings,
75
+ )
76
+
77
+ # Resolve message attachments (GUI uploads) into descriptors and stamp the
78
+ # artifact rows with this session. Skipped ids are logged, never fatal.
79
+ attachment_descriptors: list[dict] | None = None
80
+ if req.attachments:
81
+ from harness.models.artifact import Artifact
82
+ from harness.models.workspace import Workspace
83
+ from harness.services.attachments import resolve_attachments
84
+
85
+ ws_row = await db.get(Workspace, session.workspace_id)
86
+ attachment_descriptors = await resolve_attachments(
87
+ db, req.attachments, ws_row.slug if ws_row else "default"
88
+ )
89
+ for desc in attachment_descriptors:
90
+ art = await db.get(Artifact, uuid.UUID(desc["id"]))
91
+ if art is not None and art.session_id is None:
92
+ art.session_id = session.id
93
+ await db.flush()
94
+
95
+ provider_configs: dict = {}
96
+ from sqlalchemy import select
97
+
98
+ from harness.models.provider_config import ProviderConfig
99
+ for pc in (
100
+ await db.execute(select(ProviderConfig).where(ProviderConfig.enabled.is_(True)))
101
+ ).scalars():
102
+ provider_configs[pc.name] = pc
103
+
104
+ await create_runs(
105
+ db, session_id=session.id, policy=policy, provider_configs=provider_configs
106
+ )
107
+ await write_audit(
108
+ db, actor=actor, action="run.start", target_type="session",
109
+ target_id=str(session.id), after={"policy": req.policy, "providers": req.providers},
110
+ )
111
+
112
+ dispatched = False
113
+ if inline:
114
+ from sqlalchemy.ext.asyncio import async_sessionmaker
115
+ from harness.workers.inprocess import _session_lock, schedule_finalize
116
+ async with _session_lock(session.id):
117
+ await execute_session(
118
+ db, session_id=session.id, policy=policy,
119
+ message_override=req.additional_objective,
120
+ model_override=req.model,
121
+ model_overrides=req.models,
122
+ attachments=attachment_descriptors,
123
+ )
124
+ await db.commit()
125
+ # schedule_finalize is called OUTSIDE the lock — it acquires _session_lock
126
+ # itself internally; nesting would deadlock.
127
+ # Summarize + extract run post-run in the background so the ask response
128
+ # is returned immediately. Pass a sessionmaker bound to the caller's
129
+ # engine so the background task hits the same DB as this request/test
130
+ # session (the global factory defaults to 127.0.0.1:5432 which may not
131
+ # be reachable in test environments).
132
+ schedule_finalize(
133
+ session.id,
134
+ sessionmaker=async_sessionmaker(db.bind, expire_on_commit=False),
135
+ )
136
+ else:
137
+ # Commit the session + queued runs BEFORE dispatching so the executor (which
138
+ # uses its own DB connection — an in-process asyncio task that may run before
139
+ # the request's own commit) reliably sees them.
140
+ await db.commit()
141
+ # Dispatch off the request path as an in-process asyncio task. The executor
142
+ # re-derives the policy from config + session.
143
+ from harness.workers.dispatch import dispatch_session
144
+
145
+ await dispatch_session(
146
+ session.id, req.policy, req.providers,
147
+ additional_objective=req.additional_objective,
148
+ model_override=req.model,
149
+ model_overrides=req.models,
150
+ attachments=attachment_descriptors,
151
+ )
152
+ dispatched = True
153
+
154
+ runs = await list_session_runs(db, session.id)
155
+ return session.id, runs, dispatched
@@ -0,0 +1,201 @@
1
+ """Service: compute the git diff between a run's pre-run snapshot and the current HEAD.
2
+
3
+ The snapshot SHA is stored as a ``patch`` artifact with ``uri`` starting with
4
+ ``git-snapshot://`` and ``meta.pre_run == True`` (written by Task 21 /
5
+ ``harness.orchestrator.engine``).
6
+
7
+ Resolves the workspace project_dir exactly like the engine's own
8
+ ``effective_project``, runs ``git diff <sha>`` (60 s timeout), and caps output
9
+ at 1 MB. Raises an HTTP 404 when the run has no snapshot artifact.
10
+
11
+ Safety:
12
+ - project_dir comes from workspace settings (same trust as the engine).
13
+ - Path.resolve() + .git existence check before running git.
14
+ - SHA is regex-validated (^[0-9a-f]{40}$) before use in argv.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import re
20
+ import subprocess
21
+ import uuid
22
+ from pathlib import Path
23
+
24
+ from fastapi import HTTPException
25
+ from sqlalchemy import select
26
+ from sqlalchemy.ext.asyncio import AsyncSession
27
+
28
+ from harness.models.artifact import Artifact
29
+ from harness.models.enums import ArtifactKind
30
+ from harness.models.run import Run
31
+ from harness.models.session import Session
32
+ from harness.models.workspace import Workspace
33
+ from harness.services.workspaces import effective_host, effective_project
34
+
35
+ _SHA_RE = re.compile(r"^[0-9a-f]{40}$")
36
+ _GIT_SNAPSHOT_PREFIX = "git-snapshot://"
37
+ _MAX_DIFF_BYTES = 1_000_000 # 1 MB cap
38
+
39
+
40
+ async def run_diff(db: AsyncSession, run_id: uuid.UUID) -> dict:
41
+ """Compute the git diff for *run_id*.
42
+
43
+ Returns ``{"run_id", "snapshot", "diff", "truncated"}``.
44
+ Raises ``HTTPException(404)`` when no snapshot artifact exists for the run.
45
+ Raises ``ValueError`` when the stored SHA fails validation.
46
+ """
47
+ # 1. Fetch the run.
48
+ run: Run | None = await db.get(Run, run_id)
49
+ if run is None:
50
+ raise HTTPException(status_code=404, detail=f"run {run_id} not found")
51
+
52
+ # 2. Find the snapshot artifact (kind=patch, uri starts with git-snapshot://, pre_run=True).
53
+ snapshot_artifact = await _get_snapshot_artifact(db, run_id)
54
+ if snapshot_artifact is None:
55
+ raise HTTPException(status_code=404, detail=f"no snapshot artifact for run {run_id}")
56
+
57
+ # 3. Extract and validate the SHA.
58
+ sha = _extract_sha(snapshot_artifact)
59
+ if not _SHA_RE.match(sha):
60
+ raise ValueError(f"invalid snapshot SHA: {sha!r} — expected 40 hex chars")
61
+
62
+ # 4. Resolve project_dir via workspace settings (same path as the engine).
63
+ project_dir, project_host = await _resolve_project_dir(db, run)
64
+ if project_host:
65
+ # The project now lives on a remote host: a stale local snapshot must not
66
+ # be diffed against a same-named LOCAL dir (wrong repo).
67
+ raise HTTPException(
68
+ status_code=400,
69
+ detail=f"diff unavailable for remote project on {project_host!r}",
70
+ )
71
+
72
+ # 5. Safety checks on the resolved directory.
73
+ root = Path(project_dir).resolve()
74
+ if not root.is_dir():
75
+ raise HTTPException(status_code=500, detail=f"project_dir not found: {project_dir}")
76
+ if not (root / ".git").exists():
77
+ raise HTTPException(status_code=500, detail=f"project_dir is not a git repo: {project_dir}")
78
+
79
+ # 6. Run git diff in a thread (60 s timeout).
80
+ diff_text, truncated = await asyncio.to_thread(_git_diff, str(root), sha)
81
+
82
+ return {
83
+ "run_id": run_id,
84
+ "snapshot": sha,
85
+ "diff": diff_text,
86
+ "truncated": truncated,
87
+ }
88
+
89
+
90
+ async def latest_run_diff(db: AsyncSession, workspace: str) -> dict:
91
+ """Find the newest run (in this workspace) that has a snapshot artifact, then
92
+ delegate to :func:`run_diff`. Raises ``HTTPException(404)`` when nothing qualifies.
93
+ """
94
+ # Join Run → Session → Workspace to filter by workspace slug; fetch candidates
95
+ # ordered newest-first and apply the pre_run check in Python for JSON portability.
96
+ stmt = (
97
+ select(Run, Artifact)
98
+ .join(Session, Session.id == Run.session_id)
99
+ .join(Workspace, Workspace.id == Session.workspace_id)
100
+ .join(
101
+ Artifact,
102
+ (Artifact.run_id == Run.id)
103
+ & (Artifact.kind == ArtifactKind.patch)
104
+ & Artifact.uri.startswith(_GIT_SNAPSHOT_PREFIX),
105
+ )
106
+ .where(Workspace.slug == workspace)
107
+ .order_by(Run.created_at.desc())
108
+ .limit(20)
109
+ )
110
+ rows = (await db.execute(stmt)).all()
111
+ qualifying_run = None
112
+ for run_row, artifact_row in rows:
113
+ if artifact_row.meta and artifact_row.meta.get("pre_run"):
114
+ qualifying_run = run_row
115
+ break
116
+ if qualifying_run is None:
117
+ raise HTTPException(
118
+ status_code=404,
119
+ detail=f"no run with a snapshot found in workspace {workspace!r}",
120
+ )
121
+ return await run_diff(db, qualifying_run.id)
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Private helpers
126
+ # ---------------------------------------------------------------------------
127
+
128
+ async def _get_snapshot_artifact(db: AsyncSession, run_id: uuid.UUID) -> Artifact | None:
129
+ stmt = (
130
+ select(Artifact)
131
+ .where(
132
+ Artifact.run_id == run_id,
133
+ Artifact.kind == ArtifactKind.patch,
134
+ Artifact.uri.startswith(_GIT_SNAPSHOT_PREFIX),
135
+ )
136
+ .order_by(Artifact.created_at.desc())
137
+ )
138
+ candidates = (await db.execute(stmt)).scalars().all()
139
+ # Prefer artifacts where meta.pre_run is truthy (Python check for JSON portability).
140
+ for a in candidates:
141
+ if a.meta and a.meta.get("pre_run"):
142
+ return a
143
+ return None
144
+
145
+
146
+ def _extract_sha(artifact: Artifact) -> str:
147
+ """Extract the SHA from the artifact — prefer meta['git_snapshot'], fall back to URI suffix."""
148
+ if artifact.meta and artifact.meta.get("git_snapshot"):
149
+ return str(artifact.meta["git_snapshot"])
150
+ # uri = "git-snapshot://<sha>"
151
+ return artifact.uri[len(_GIT_SNAPSHOT_PREFIX):]
152
+
153
+
154
+ async def _resolve_project_dir(db: AsyncSession, run: Run) -> tuple[str, str | None]:
155
+ """Resolve the (project_dir, project_host) for a run via run → session →
156
+ workspace.settings. A non-None host means the project is remote. A session
157
+ isolated in a private worktree diffs against ITS worktree (always local),
158
+ not the shared project dir."""
159
+ session: Session | None = await db.get(Session, run.session_id)
160
+ if session is None:
161
+ raise HTTPException(status_code=500, detail="session not found for run")
162
+ sess_settings = session.settings or {}
163
+ if sess_settings.get("workspace_mode") == "worktree" and sess_settings.get("worktree_path"):
164
+ return sess_settings["worktree_path"], None
165
+ workspace: Workspace | None = await db.get(Workspace, session.workspace_id)
166
+ if workspace is None:
167
+ raise HTTPException(status_code=500, detail="workspace not found for session")
168
+ settings = workspace.settings or {}
169
+ project_dir, _ = effective_project(settings)
170
+ return project_dir, effective_host(settings)
171
+
172
+
173
+ def _git_diff(project_dir: str, sha: str) -> tuple[str, bool]:
174
+ """Run ``git diff <sha>`` synchronously (called via asyncio.to_thread).
175
+
176
+ Returns ``(diff_text, truncated)``. Never passes user-controlled strings
177
+ other than the pre-validated SHA into argv.
178
+ """
179
+ try:
180
+ import os
181
+ result = subprocess.run(["git", "diff", sha],
182
+ cwd=project_dir,
183
+ capture_output=True,
184
+ text=True,
185
+ timeout=60,
186
+ shell=(os.name == "nt")
187
+ )
188
+ diff = result.stdout
189
+ if result.returncode != 0 and not diff:
190
+ # Non-zero exit is normal when there are changes; empty diff on error is not.
191
+ diff = result.stderr or "(git diff returned no output)"
192
+ except subprocess.TimeoutExpired:
193
+ diff = "(git diff timed out after 60 s)"
194
+ return diff, False
195
+
196
+ truncated = False
197
+ if len(diff.encode()) > _MAX_DIFF_BYTES:
198
+ diff = diff.encode()[:_MAX_DIFF_BYTES].decode("utf-8", errors="replace")
199
+ truncated = True
200
+
201
+ return diff, truncated
@@ -0,0 +1,242 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from datetime import UTC, datetime, timedelta
5
+
6
+ from sqlalchemy import delete, select
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ import logging
10
+
11
+ from harness.memory.sqlite_vec_backend import SqliteVecIndex
12
+ from harness.models.enums import SessionStatus
13
+ from harness.models.memory import MemoryItem
14
+ from harness.models.run import Run
15
+
16
+ _log = logging.getLogger(__name__)
17
+ from harness.models.run_event import RunEvent
18
+ from harness.models.session import Session
19
+ from harness.models.workspace import Workspace
20
+ from harness.observability.audit import write_audit
21
+
22
+
23
+ async def create_session(
24
+ db: AsyncSession,
25
+ *,
26
+ workspace_id: uuid.UUID,
27
+ objective: str,
28
+ constraints: dict | None = None,
29
+ task_id: uuid.UUID | None = None,
30
+ parent_session_id: uuid.UUID | None = None,
31
+ settings: dict | None = None,
32
+ ) -> Session:
33
+ sess = Session(
34
+ workspace_id=workspace_id,
35
+ task_id=task_id,
36
+ parent_session_id=parent_session_id,
37
+ canonical_objective=objective,
38
+ constraints=constraints or {},
39
+ settings=settings or {},
40
+ )
41
+ db.add(sess)
42
+ await db.flush()
43
+ return sess
44
+
45
+
46
+ async def get_session(db: AsyncSession, session_id: uuid.UUID) -> Session | None:
47
+ return (
48
+ await db.execute(select(Session).where(Session.id == session_id))
49
+ ).scalar_one_or_none()
50
+
51
+
52
+ async def set_session_settings(
53
+ db: AsyncSession, session_id: uuid.UUID, *,
54
+ write_mode: bool | None, actor: str = "api",
55
+ ) -> Session:
56
+ """Set or clear (``write_mode=None``) a session's plan/write mode override.
57
+
58
+ Only ``write_mode`` is writable here — the other ``Session.settings`` keys
59
+ (worktree bookkeeping) are engine-managed and must not be clobbered from the
60
+ API. Audited."""
61
+ sess = await get_session(db, session_id)
62
+ if sess is None:
63
+ raise ValueError("session not found")
64
+ settings = dict(sess.settings or {})
65
+ before = {"write_mode": settings.get("write_mode")}
66
+ if write_mode is None:
67
+ settings.pop("write_mode", None)
68
+ else:
69
+ settings["write_mode"] = bool(write_mode)
70
+ sess.settings = settings # reassign so SQLAlchemy flags the JSON column dirty
71
+ await db.flush()
72
+ await write_audit(
73
+ db, actor=actor, action="session.set_mode", target_type="session",
74
+ target_id=str(session_id), before=before,
75
+ after={"write_mode": settings.get("write_mode")},
76
+ )
77
+ return sess
78
+
79
+
80
+ async def get_session_mode(db: AsyncSession, sess: Session) -> dict:
81
+ """Resolve a session's mode: its own override, the workspace default, and the
82
+ effective value the next run will use."""
83
+ from harness.services.workspaces import (
84
+ _mode_label,
85
+ effective_project,
86
+ effective_write_mode,
87
+ )
88
+
89
+ workspace = (
90
+ await db.execute(select(Workspace).where(Workspace.id == sess.workspace_id))
91
+ ).scalar_one()
92
+ session_settings = sess.settings or {}
93
+ workspace_settings = workspace.settings or {}
94
+ effective = effective_write_mode(session_settings, workspace_settings)
95
+ return {
96
+ "write_mode": session_settings.get("write_mode"),
97
+ "workspace_write_mode": effective_project(workspace_settings)[1],
98
+ "effective_write_mode": effective,
99
+ "mode": _mode_label(effective),
100
+ }
101
+
102
+
103
+ async def list_recent_sessions(
104
+ db: AsyncSession, *, workspace_id: uuid.UUID | None = None, limit: int = 50
105
+ ) -> list[Session]:
106
+ stmt = select(Session).order_by(Session.updated_at.desc()).limit(limit)
107
+ if workspace_id is not None:
108
+ stmt = stmt.where(Session.workspace_id == workspace_id)
109
+ return list((await db.execute(stmt)).scalars())
110
+
111
+
112
+ async def branch_session(db: AsyncSession, session_id: uuid.UUID, actor: str = "system") -> Session:
113
+ """Create a child session seeded from the parent's canonical summary/objective —
114
+ branching from canonical state, not raw transcript."""
115
+ parent = (
116
+ await db.execute(select(Session).where(Session.id == session_id))
117
+ ).scalar_one()
118
+ objective = parent.canonical_objective
119
+ if parent.summary:
120
+ objective += f"\n\n## Inherited summary\n{parent.summary}"
121
+ # Inherit the parent's isolation choice and mode override — but never its
122
+ # worktree bookkeeping: the child gets its own worktree (keyed by its id).
123
+ inherited = {
124
+ k: (parent.settings or {})[k]
125
+ for k in ("workspace_mode", "write_mode")
126
+ if k in (parent.settings or {})
127
+ }
128
+ child = await create_session(
129
+ db,
130
+ workspace_id=parent.workspace_id,
131
+ objective=objective,
132
+ constraints=parent.constraints,
133
+ task_id=parent.task_id,
134
+ parent_session_id=parent.id,
135
+ settings=inherited,
136
+ )
137
+ await write_audit(
138
+ db, actor=actor, action="session.branch", target_type="session",
139
+ target_id=str(child.id), after={"parent_session_id": str(parent.id)}
140
+ )
141
+ return child
142
+
143
+
144
+ async def select_sessions_to_clear(
145
+ db: AsyncSession,
146
+ *,
147
+ all: bool = False, # noqa: A002 - mirrors the `--all` flag
148
+ status: SessionStatus | None = None,
149
+ older_than_days: int | None = None,
150
+ workspace_slug: str | None = None,
151
+ ids: list[uuid.UUID] | None = None,
152
+ ) -> list[Session]:
153
+ """Resolve the sessions matched by the clear filters (AND-combined). Returns [] when
154
+ no filter is given — the caller refuses to clear without an explicit selection."""
155
+ if not (all or status or older_than_days or workspace_slug or ids):
156
+ return []
157
+ stmt = select(Session).order_by(Session.updated_at.desc())
158
+ if status is not None:
159
+ stmt = stmt.where(Session.status == status)
160
+ if older_than_days is not None:
161
+ cutoff = datetime.now(UTC) - timedelta(days=older_than_days)
162
+ stmt = stmt.where(Session.updated_at < cutoff)
163
+ if workspace_slug is not None:
164
+ stmt = stmt.join(Workspace, Workspace.id == Session.workspace_id).where(
165
+ Workspace.slug == workspace_slug
166
+ )
167
+ if ids:
168
+ stmt = stmt.where(Session.id.in_(ids))
169
+ return list((await db.execute(stmt)).scalars())
170
+
171
+
172
+ async def delete_sessions(db: AsyncSession, sessions: list[Session]) -> int:
173
+ """Hard-delete sessions. DB FK cascade drops runs/run_events/approvals/comparisons;
174
+ session-scoped memory (namespace-keyed, no FK) is cleaned explicitly here."""
175
+ ids = [s.id for s in sessions]
176
+ if not ids:
177
+ return 0
178
+ # Discard private worktrees first (fail-soft). The session branch is kept
179
+ # unless the session was merged — an unmerged branch is the safety net.
180
+ from harness.services.worktrees import remove_worktree
181
+
182
+ for s in sessions:
183
+ if (s.settings or {}).get("worktree_path"):
184
+ try:
185
+ await remove_worktree(
186
+ db, s.id,
187
+ delete_branch=bool((s.settings or {}).get("merge_commit")),
188
+ actor="delete",
189
+ )
190
+ except Exception: # noqa: BLE001 - deletion must never fail on cleanup
191
+ _log.warning("delete_sessions: worktree cleanup failed for %s", s.id,
192
+ exc_info=True)
193
+ # Feature-detect sqlite-vec: only attempt vec cleanup when the extension is loaded.
194
+ vec_loaded = await SqliteVecIndex.loaded_for_session(db)
195
+
196
+ for sid in ids:
197
+ # Session-scoped memory lives under a `…/session/<id>` namespace (see
198
+ # harness/memory/namespaces.py) — no FK, so remove it directly.
199
+ # First, clean up any vec_memory rows for these items so they don't leak.
200
+ if vec_loaded:
201
+ try:
202
+ item_ids_stmt = select(MemoryItem.id).where(
203
+ MemoryItem.namespace.like(f"%/session/{sid}%")
204
+ )
205
+ item_ids = list(
206
+ (await db.execute(item_ids_stmt)).scalars().all()
207
+ )
208
+ for iid in item_ids:
209
+ await SqliteVecIndex.delete_item(db, str(iid))
210
+ except Exception: # noqa: BLE001
211
+ _log.warning(
212
+ "delete_sessions: vec_memory cleanup failed for session %s; "
213
+ "continuing with item deletion",
214
+ sid,
215
+ exc_info=True,
216
+ )
217
+ await db.execute(
218
+ delete(MemoryItem).where(MemoryItem.namespace.like(f"%/session/{sid}%"))
219
+ )
220
+ await db.execute(delete(Session).where(Session.id.in_(ids)))
221
+ await db.commit()
222
+ return len(ids)
223
+
224
+
225
+ async def list_session_runs(db: AsyncSession, session_id: uuid.UUID) -> list[Run]:
226
+ return list(
227
+ (
228
+ await db.execute(
229
+ select(Run).where(Run.session_id == session_id).order_by(Run.created_at.asc())
230
+ )
231
+ ).scalars()
232
+ )
233
+
234
+
235
+ async def list_run_events(db: AsyncSession, run_id: uuid.UUID) -> list[RunEvent]:
236
+ return list(
237
+ (
238
+ await db.execute(
239
+ select(RunEvent).where(RunEvent.run_id == run_id).order_by(RunEvent.seq.asc())
240
+ )
241
+ ).scalars()
242
+ )