ags-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ags_cli-0.1.0.dist-info/METADATA +332 -0
- ags_cli-0.1.0.dist-info/RECORD +156 -0
- ags_cli-0.1.0.dist-info/WHEEL +5 -0
- ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ags_cli-0.1.0.dist-info/top_level.txt +2 -0
- cli/__init__.py +0 -0
- cli/__main__.py +4 -0
- cli/client.py +145 -0
- cli/commands/__init__.py +0 -0
- cli/commands/adapter.py +33 -0
- cli/commands/artifact.py +21 -0
- cli/commands/ask.py +212 -0
- cli/commands/chat.py +649 -0
- cli/commands/diff.py +49 -0
- cli/commands/doctor.py +93 -0
- cli/commands/gui.py +45 -0
- cli/commands/init.py +20 -0
- cli/commands/mcp.py +50 -0
- cli/commands/memory.py +65 -0
- cli/commands/model.py +73 -0
- cli/commands/policy.py +52 -0
- cli/commands/project.py +122 -0
- cli/commands/quota.py +107 -0
- cli/commands/run.py +84 -0
- cli/commands/serve.py +171 -0
- cli/commands/service.py +236 -0
- cli/commands/session.py +219 -0
- cli/commands/stats.py +96 -0
- cli/commands/status.py +36 -0
- cli/commands/task.py +37 -0
- cli/commands/usage.py +86 -0
- cli/local.py +84 -0
- cli/main.py +149 -0
- harness/__init__.py +4 -0
- harness/adapters/__init__.py +26 -0
- harness/adapters/antigravity.py +301 -0
- harness/adapters/claude_code.py +522 -0
- harness/adapters/local_openai.py +534 -0
- harness/adapters/mock.py +112 -0
- harness/adapters/probe.py +65 -0
- harness/adapters/registry.py +56 -0
- harness/adapters/warm_pool.py +255 -0
- harness/api/__init__.py +0 -0
- harness/api/router.py +38 -0
- harness/api/v1/__init__.py +0 -0
- harness/api/v1/adapters.py +105 -0
- harness/api/v1/approvals.py +173 -0
- harness/api/v1/artifacts.py +44 -0
- harness/api/v1/attachments.py +48 -0
- harness/api/v1/doctor.py +22 -0
- harness/api/v1/health.py +37 -0
- harness/api/v1/mcp.py +131 -0
- harness/api/v1/memory.py +80 -0
- harness/api/v1/policies.py +49 -0
- harness/api/v1/project.py +302 -0
- harness/api/v1/runs.py +98 -0
- harness/api/v1/sessions.py +248 -0
- harness/api/v1/stream.py +110 -0
- harness/api/v1/tasks.py +30 -0
- harness/api/v1/usage.py +58 -0
- harness/bootstrap.py +216 -0
- harness/db.py +164 -0
- harness/deps.py +58 -0
- harness/eventbus/__init__.py +20 -0
- harness/eventbus/inprocess_bus.py +85 -0
- harness/main.py +144 -0
- harness/mcp/__init__.py +22 -0
- harness/mcp/client.py +139 -0
- harness/mcp/config_gen.py +77 -0
- harness/mcp/registry.py +156 -0
- harness/mcp/tools.py +81 -0
- harness/memory/__init__.py +13 -0
- harness/memory/context_budget.py +116 -0
- harness/memory/embed.py +217 -0
- harness/memory/extract.py +74 -0
- harness/memory/ingest.py +106 -0
- harness/memory/namespaces.py +57 -0
- harness/memory/ranking.py +31 -0
- harness/memory/redact.py +37 -0
- harness/memory/service.py +320 -0
- harness/memory/sqlite_vec_backend.py +266 -0
- harness/memory/summarize.py +68 -0
- harness/models/__init__.py +35 -0
- harness/models/adapter_health.py +27 -0
- harness/models/approval.py +37 -0
- harness/models/artifact.py +35 -0
- harness/models/audit_log.py +33 -0
- harness/models/comparison.py +33 -0
- harness/models/enums.py +146 -0
- harness/models/mcp_server_config.py +49 -0
- harness/models/memory.py +76 -0
- harness/models/policy.py +29 -0
- harness/models/provider_config.py +35 -0
- harness/models/run.py +46 -0
- harness/models/run_event.py +35 -0
- harness/models/session.py +50 -0
- harness/models/task.py +30 -0
- harness/models/types.py +63 -0
- harness/models/workspace.py +27 -0
- harness/observability/__init__.py +4 -0
- harness/observability/audit.py +88 -0
- harness/observability/logging.py +47 -0
- harness/observability/metrics.py +43 -0
- harness/observability/tracing.py +38 -0
- harness/orchestrator/__init__.py +19 -0
- harness/orchestrator/engine.py +821 -0
- harness/orchestrator/handoff.py +33 -0
- harness/orchestrator/lifecycle.py +69 -0
- harness/orchestrator/native_resume.py +93 -0
- harness/orchestrator/policies.py +101 -0
- harness/orchestrator/reconcile.py +39 -0
- harness/orchestrator/run_graph.py +62 -0
- harness/orchestrator/snapshot.py +49 -0
- harness/schemas/__init__.py +1 -0
- harness/schemas/adapter.py +26 -0
- harness/schemas/approval.py +25 -0
- harness/schemas/artifact.py +17 -0
- harness/schemas/common.py +20 -0
- harness/schemas/memory.py +50 -0
- harness/schemas/policy.py +39 -0
- harness/schemas/run.py +116 -0
- harness/schemas/session.py +93 -0
- harness/schemas/task.py +24 -0
- harness/security/__init__.py +5 -0
- harness/security/approval_policy.py +107 -0
- harness/security/auth.py +106 -0
- harness/security/permissions.py +59 -0
- harness/security/risk.py +59 -0
- harness/security/secrets.py +49 -0
- harness/services/__init__.py +1 -0
- harness/services/adapters_health.py +212 -0
- harness/services/approvals.py +84 -0
- harness/services/artifacts.py +78 -0
- harness/services/attachments.py +228 -0
- harness/services/comparison.py +345 -0
- harness/services/doctor.py +156 -0
- harness/services/files.py +287 -0
- harness/services/mcp_servers.py +179 -0
- harness/services/project_git.py +101 -0
- harness/services/retention.py +97 -0
- harness/services/runs.py +155 -0
- harness/services/runs_diff.py +201 -0
- harness/services/sessions.py +242 -0
- harness/services/ssh.py +300 -0
- harness/services/stats.py +132 -0
- harness/services/tasks.py +41 -0
- harness/services/workspaces.py +184 -0
- harness/services/worktrees.py +439 -0
- harness/settings.py +186 -0
- harness/skills/__init__.py +11 -0
- harness/skills/manager.py +141 -0
- harness/tools/__init__.py +1 -0
- harness/tools/fs_tools.py +295 -0
- harness/workers/__init__.py +0 -0
- harness/workers/dispatch.py +26 -0
- harness/workers/inprocess.py +135 -0
harness/services/runs.py
ADDED
|
@@ -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
|
+
)
|