ags-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ags_cli-0.1.0.dist-info/METADATA +332 -0
- ags_cli-0.1.0.dist-info/RECORD +156 -0
- ags_cli-0.1.0.dist-info/WHEEL +5 -0
- ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ags_cli-0.1.0.dist-info/top_level.txt +2 -0
- cli/__init__.py +0 -0
- cli/__main__.py +4 -0
- cli/client.py +145 -0
- cli/commands/__init__.py +0 -0
- cli/commands/adapter.py +33 -0
- cli/commands/artifact.py +21 -0
- cli/commands/ask.py +212 -0
- cli/commands/chat.py +649 -0
- cli/commands/diff.py +49 -0
- cli/commands/doctor.py +93 -0
- cli/commands/gui.py +45 -0
- cli/commands/init.py +20 -0
- cli/commands/mcp.py +50 -0
- cli/commands/memory.py +65 -0
- cli/commands/model.py +73 -0
- cli/commands/policy.py +52 -0
- cli/commands/project.py +122 -0
- cli/commands/quota.py +107 -0
- cli/commands/run.py +84 -0
- cli/commands/serve.py +171 -0
- cli/commands/service.py +236 -0
- cli/commands/session.py +219 -0
- cli/commands/stats.py +96 -0
- cli/commands/status.py +36 -0
- cli/commands/task.py +37 -0
- cli/commands/usage.py +86 -0
- cli/local.py +84 -0
- cli/main.py +149 -0
- harness/__init__.py +4 -0
- harness/adapters/__init__.py +26 -0
- harness/adapters/antigravity.py +301 -0
- harness/adapters/claude_code.py +522 -0
- harness/adapters/local_openai.py +534 -0
- harness/adapters/mock.py +112 -0
- harness/adapters/probe.py +65 -0
- harness/adapters/registry.py +56 -0
- harness/adapters/warm_pool.py +255 -0
- harness/api/__init__.py +0 -0
- harness/api/router.py +38 -0
- harness/api/v1/__init__.py +0 -0
- harness/api/v1/adapters.py +105 -0
- harness/api/v1/approvals.py +173 -0
- harness/api/v1/artifacts.py +44 -0
- harness/api/v1/attachments.py +48 -0
- harness/api/v1/doctor.py +22 -0
- harness/api/v1/health.py +37 -0
- harness/api/v1/mcp.py +131 -0
- harness/api/v1/memory.py +80 -0
- harness/api/v1/policies.py +49 -0
- harness/api/v1/project.py +302 -0
- harness/api/v1/runs.py +98 -0
- harness/api/v1/sessions.py +248 -0
- harness/api/v1/stream.py +110 -0
- harness/api/v1/tasks.py +30 -0
- harness/api/v1/usage.py +58 -0
- harness/bootstrap.py +216 -0
- harness/db.py +164 -0
- harness/deps.py +58 -0
- harness/eventbus/__init__.py +20 -0
- harness/eventbus/inprocess_bus.py +85 -0
- harness/main.py +144 -0
- harness/mcp/__init__.py +22 -0
- harness/mcp/client.py +139 -0
- harness/mcp/config_gen.py +77 -0
- harness/mcp/registry.py +156 -0
- harness/mcp/tools.py +81 -0
- harness/memory/__init__.py +13 -0
- harness/memory/context_budget.py +116 -0
- harness/memory/embed.py +217 -0
- harness/memory/extract.py +74 -0
- harness/memory/ingest.py +106 -0
- harness/memory/namespaces.py +57 -0
- harness/memory/ranking.py +31 -0
- harness/memory/redact.py +37 -0
- harness/memory/service.py +320 -0
- harness/memory/sqlite_vec_backend.py +266 -0
- harness/memory/summarize.py +68 -0
- harness/models/__init__.py +35 -0
- harness/models/adapter_health.py +27 -0
- harness/models/approval.py +37 -0
- harness/models/artifact.py +35 -0
- harness/models/audit_log.py +33 -0
- harness/models/comparison.py +33 -0
- harness/models/enums.py +146 -0
- harness/models/mcp_server_config.py +49 -0
- harness/models/memory.py +76 -0
- harness/models/policy.py +29 -0
- harness/models/provider_config.py +35 -0
- harness/models/run.py +46 -0
- harness/models/run_event.py +35 -0
- harness/models/session.py +50 -0
- harness/models/task.py +30 -0
- harness/models/types.py +63 -0
- harness/models/workspace.py +27 -0
- harness/observability/__init__.py +4 -0
- harness/observability/audit.py +88 -0
- harness/observability/logging.py +47 -0
- harness/observability/metrics.py +43 -0
- harness/observability/tracing.py +38 -0
- harness/orchestrator/__init__.py +19 -0
- harness/orchestrator/engine.py +821 -0
- harness/orchestrator/handoff.py +33 -0
- harness/orchestrator/lifecycle.py +69 -0
- harness/orchestrator/native_resume.py +93 -0
- harness/orchestrator/policies.py +101 -0
- harness/orchestrator/reconcile.py +39 -0
- harness/orchestrator/run_graph.py +62 -0
- harness/orchestrator/snapshot.py +49 -0
- harness/schemas/__init__.py +1 -0
- harness/schemas/adapter.py +26 -0
- harness/schemas/approval.py +25 -0
- harness/schemas/artifact.py +17 -0
- harness/schemas/common.py +20 -0
- harness/schemas/memory.py +50 -0
- harness/schemas/policy.py +39 -0
- harness/schemas/run.py +116 -0
- harness/schemas/session.py +93 -0
- harness/schemas/task.py +24 -0
- harness/security/__init__.py +5 -0
- harness/security/approval_policy.py +107 -0
- harness/security/auth.py +106 -0
- harness/security/permissions.py +59 -0
- harness/security/risk.py +59 -0
- harness/security/secrets.py +49 -0
- harness/services/__init__.py +1 -0
- harness/services/adapters_health.py +212 -0
- harness/services/approvals.py +84 -0
- harness/services/artifacts.py +78 -0
- harness/services/attachments.py +228 -0
- harness/services/comparison.py +345 -0
- harness/services/doctor.py +156 -0
- harness/services/files.py +287 -0
- harness/services/mcp_servers.py +179 -0
- harness/services/project_git.py +101 -0
- harness/services/retention.py +97 -0
- harness/services/runs.py +155 -0
- harness/services/runs_diff.py +201 -0
- harness/services/sessions.py +242 -0
- harness/services/ssh.py +300 -0
- harness/services/stats.py +132 -0
- harness/services/tasks.py +41 -0
- harness/services/workspaces.py +184 -0
- harness/services/worktrees.py +439 -0
- harness/settings.py +186 -0
- harness/skills/__init__.py +11 -0
- harness/skills/manager.py +141 -0
- harness/tools/__init__.py +1 -0
- harness/tools/fs_tools.py +295 -0
- harness/workers/__init__.py +0 -0
- harness/workers/dispatch.py +26 -0
- harness/workers/inprocess.py +135 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, HTTPException
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
|
+
|
|
9
|
+
from harness.deps import CurrentPrincipal, DbSession
|
|
10
|
+
from harness.models.approval import Approval
|
|
11
|
+
from harness.models.enums import ApprovalStatus, RunStatus, SessionStatus
|
|
12
|
+
from harness.models.run import Run
|
|
13
|
+
from harness.models.session import Session
|
|
14
|
+
from harness.schemas.approval import ApprovalDecideRequest, ApprovalOut
|
|
15
|
+
from harness.services.approvals import decide_approval
|
|
16
|
+
from harness.services.approvals import list_approvals as list_approvals_svc
|
|
17
|
+
|
|
18
|
+
router = APIRouter(prefix="/approvals", tags=["approvals"])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.get("", response_model=list[ApprovalOut])
|
|
22
|
+
async def list_approvals(
|
|
23
|
+
db: DbSession, _: CurrentPrincipal, status: ApprovalStatus | None = ApprovalStatus.pending
|
|
24
|
+
) -> list[ApprovalOut]:
|
|
25
|
+
approvals = await list_approvals_svc(db, status=status)
|
|
26
|
+
return [ApprovalOut.model_validate(a) for a in approvals]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@router.post("/{approval_id}/decide", response_model=ApprovalOut)
|
|
30
|
+
async def decide(
|
|
31
|
+
approval_id: uuid.UUID, req: ApprovalDecideRequest, db: DbSession, principal: CurrentPrincipal
|
|
32
|
+
) -> ApprovalOut:
|
|
33
|
+
try:
|
|
34
|
+
approval = await decide_approval(
|
|
35
|
+
db,
|
|
36
|
+
approval_id=approval_id,
|
|
37
|
+
decision=req.decision,
|
|
38
|
+
actor=principal.subject,
|
|
39
|
+
)
|
|
40
|
+
except ValueError as exc:
|
|
41
|
+
raise HTTPException(400, str(exc)) from exc
|
|
42
|
+
|
|
43
|
+
if approval.status == ApprovalStatus.approved:
|
|
44
|
+
await _continue_after_approval(db, approval)
|
|
45
|
+
elif approval.status == ApprovalStatus.denied:
|
|
46
|
+
await _fail_run_after_denial(db, approval)
|
|
47
|
+
|
|
48
|
+
return ApprovalOut.model_validate(approval)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _dir_exists(path: str) -> bool:
|
|
52
|
+
"""Sync filesystem check kept out of the async helpers (they must not call
|
|
53
|
+
``os.path`` directly). A quick stat, so no thread offload needed."""
|
|
54
|
+
import os
|
|
55
|
+
|
|
56
|
+
return os.path.isdir(path)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def _session_host(db: AsyncSession, session_id: uuid.UUID) -> str | None:
|
|
60
|
+
"""The remote ssh host the session's project lives on, or None for a local project.
|
|
61
|
+
The approval payload doesn't carry the host, so we re-derive it from the workspace
|
|
62
|
+
the same way the engine does. A private worktree is always local (host None)."""
|
|
63
|
+
from harness.models.workspace import Workspace
|
|
64
|
+
from harness.services.workspaces import effective_host
|
|
65
|
+
|
|
66
|
+
session = await db.get(Session, session_id)
|
|
67
|
+
if session is None:
|
|
68
|
+
return None
|
|
69
|
+
sess_settings = session.settings or {}
|
|
70
|
+
if sess_settings.get("workspace_mode") == "worktree":
|
|
71
|
+
return None # worktrees are always local, even for a remote base project
|
|
72
|
+
workspace = await db.get(Workspace, session.workspace_id)
|
|
73
|
+
if workspace is None:
|
|
74
|
+
return None
|
|
75
|
+
return effective_host(workspace.settings or {})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def _run_approved_tool(
|
|
79
|
+
db: AsyncSession, approval: Approval, tool_name: str, arguments: dict
|
|
80
|
+
) -> str:
|
|
81
|
+
"""Execute the just-approved tool in the session's live environment and return a
|
|
82
|
+
human-readable result.
|
|
83
|
+
|
|
84
|
+
The command runs in ``workspace_dir`` — the exact ``FileTools`` root the agent used
|
|
85
|
+
(worktree dir for isolated sessions), recorded on the approval payload. We add two
|
|
86
|
+
reliability guarantees the old path lacked: for a **remote** project we run over SSH
|
|
87
|
+
in the remote dir (a local ``FileTools`` would silently no-op on a remote path), and
|
|
88
|
+
for a **local** project we verify the dir still exists first — so a ``rm -f`` against
|
|
89
|
+
a stale/missing worktree no longer reports a misleading exit-0 success."""
|
|
90
|
+
payload = approval.payload or {}
|
|
91
|
+
exec_dir = payload.get("workspace_dir")
|
|
92
|
+
host = await _session_host(db, approval.session_id)
|
|
93
|
+
if not exec_dir:
|
|
94
|
+
return f"(tool {tool_name!r} not re-run: the approval recorded no workspace directory)"
|
|
95
|
+
|
|
96
|
+
# Remote project: run over SSH in the remote dir via a login shell (so the remote
|
|
97
|
+
# PATH resolves anything the command needs). Only shell commands are re-runnable
|
|
98
|
+
# remotely; other FileTools verbs operate on the local filesystem.
|
|
99
|
+
if host:
|
|
100
|
+
if tool_name != "execute_command":
|
|
101
|
+
return f"(tool {tool_name!r} not re-run: unsupported for remote project on {host!r})"
|
|
102
|
+
import shlex
|
|
103
|
+
|
|
104
|
+
from harness.services.ssh import ssh_run
|
|
105
|
+
command = (arguments or {}).get("command", "")
|
|
106
|
+
remote = f"cd {shlex.quote(exec_dir)} && {command}"
|
|
107
|
+
try:
|
|
108
|
+
rc, out, err = await ssh_run(host, f"sh -lc {shlex.quote(remote)}", timeout_s=60)
|
|
109
|
+
except (TimeoutError, OSError) as exc:
|
|
110
|
+
return f"error running approved command on {host!r}: {exc}"
|
|
111
|
+
body = "".join(
|
|
112
|
+
part for part in (f"STDOUT:\n{out.strip()}\n" if out.strip() else "",
|
|
113
|
+
f"STDERR:\n{err.strip()}\n" if err.strip() else "")
|
|
114
|
+
)
|
|
115
|
+
return body + f"Exit code: {rc}" if body else f"command executed (exit code {rc})"
|
|
116
|
+
|
|
117
|
+
# Local project (main dir or private worktree). Verify the dir still exists first.
|
|
118
|
+
from harness.tools.fs_tools import FileTools
|
|
119
|
+
if not _dir_exists(exec_dir):
|
|
120
|
+
return f"(tool {tool_name!r} not re-run: workspace directory no longer exists: {exec_dir})"
|
|
121
|
+
fs = FileTools(exec_dir, read_only=False)
|
|
122
|
+
# bypass_gate: this exact call was just approved by a human — re-running it
|
|
123
|
+
# through the gate would only raise ApprovalRequired a second time.
|
|
124
|
+
output, _ = fs.execute(tool_name, arguments, bypass_gate=True)
|
|
125
|
+
return output
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def _continue_after_approval(db: AsyncSession, approval: Approval) -> None:
|
|
129
|
+
"""Continuation Run pattern: actually run the gated tool now that a human
|
|
130
|
+
approved it, then kick off a fresh turn on the same session carrying the
|
|
131
|
+
result — rather than trying to resume the paused adapter's in-memory
|
|
132
|
+
state, which no longer exists."""
|
|
133
|
+
from harness.schemas.run import RunStartRequest
|
|
134
|
+
from harness.services.runs import start_run
|
|
135
|
+
|
|
136
|
+
payload = approval.payload or {}
|
|
137
|
+
tool_name = payload.get("tool_name")
|
|
138
|
+
arguments = payload.get("arguments", {})
|
|
139
|
+
provider = payload.get("provider")
|
|
140
|
+
|
|
141
|
+
if tool_name:
|
|
142
|
+
output = await _run_approved_tool(db, approval, tool_name, arguments)
|
|
143
|
+
else:
|
|
144
|
+
output = "(no tool recorded on the approval — nothing to run)"
|
|
145
|
+
|
|
146
|
+
# Restate what was approved and its result so the continued run has enough context
|
|
147
|
+
# to pick up the original task rather than acting as if it forgot.
|
|
148
|
+
system_message = (
|
|
149
|
+
f"[system] Your previously-requested action was approved and executed. "
|
|
150
|
+
f"Tool `{tool_name}` result: {output}. "
|
|
151
|
+
f"Continue the original task from here; do not repeat the approved action."
|
|
152
|
+
)
|
|
153
|
+
req = RunStartRequest(
|
|
154
|
+
session_id=approval.session_id,
|
|
155
|
+
policy="single",
|
|
156
|
+
providers=[provider] if provider else None,
|
|
157
|
+
additional_objective=system_message,
|
|
158
|
+
)
|
|
159
|
+
await start_run(db, req, actor="approval")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def _fail_run_after_denial(db: AsyncSession, approval: Approval) -> None:
|
|
163
|
+
"""A denied action must not silently leave its run parked forever."""
|
|
164
|
+
if approval.run_id is not None:
|
|
165
|
+
run = await db.get(Run, approval.run_id)
|
|
166
|
+
if run is not None and run.status == RunStatus.needs_approval:
|
|
167
|
+
run.status = RunStatus.failed
|
|
168
|
+
reason = (approval.payload or {}).get("reason", "action not approved")
|
|
169
|
+
run.error = f"approval denied: {reason}"
|
|
170
|
+
run.finished_at = datetime.now(UTC)
|
|
171
|
+
sess = await db.get(Session, approval.session_id)
|
|
172
|
+
if sess is not None and sess.status == SessionStatus.running:
|
|
173
|
+
sess.status = SessionStatus.failed
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Read-only artifacts API: list a session's artifacts and preview their content.
|
|
2
|
+
|
|
3
|
+
Artifacts are produced by runs (``engine._persist_artifacts``) and stored by reference.
|
|
4
|
+
Content is resolved by :func:`harness.services.artifacts.resolve_content`, which keeps
|
|
5
|
+
file reads scoped to the workspace's active project folder.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, HTTPException
|
|
13
|
+
|
|
14
|
+
from harness.deps import CurrentPrincipal, DbSession
|
|
15
|
+
from harness.schemas.artifact import ArtifactOut
|
|
16
|
+
from harness.services.artifacts import get_artifact, list_session_artifacts, resolve_content
|
|
17
|
+
from harness.services.workspaces import get_project_settings
|
|
18
|
+
|
|
19
|
+
router = APIRouter(prefix="/artifacts", tags=["artifacts"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.get("", response_model=list[ArtifactOut])
|
|
23
|
+
async def index(
|
|
24
|
+
session_id: uuid.UUID, db: DbSession, _: CurrentPrincipal
|
|
25
|
+
) -> list[ArtifactOut]:
|
|
26
|
+
"""List a session's artifacts, newest first."""
|
|
27
|
+
return [ArtifactOut.model_validate(a) for a in await list_session_artifacts(db, session_id)]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.get("/{artifact_id}/content")
|
|
31
|
+
async def content(
|
|
32
|
+
artifact_id: uuid.UUID, db: DbSession, _: CurrentPrincipal, workspace: str = "default"
|
|
33
|
+
) -> dict:
|
|
34
|
+
"""Resolve an artifact's previewable content (transcript from the DB, files from the
|
|
35
|
+
project folder). File paths are validated to stay inside that folder."""
|
|
36
|
+
artifact = await get_artifact(db, artifact_id)
|
|
37
|
+
if artifact is None:
|
|
38
|
+
raise HTTPException(404, "artifact not found")
|
|
39
|
+
project = await get_project_settings(db, workspace)
|
|
40
|
+
try:
|
|
41
|
+
return resolve_content(artifact, project["project_dir"],
|
|
42
|
+
project_host=project.get("project_host"))
|
|
43
|
+
except ValueError as exc:
|
|
44
|
+
raise HTTPException(403, str(exc)) from exc
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Upload + preview endpoints for message attachments (GUI composer)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException, UploadFile
|
|
8
|
+
from fastapi.responses import FileResponse
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from harness.deps import CurrentPrincipal, DbSession
|
|
12
|
+
from harness.services.attachments import get_attachment_file, save_attachment
|
|
13
|
+
|
|
14
|
+
router = APIRouter(prefix="/attachments", tags=["attachments"])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AttachmentOut(BaseModel):
|
|
18
|
+
id: str
|
|
19
|
+
name: str
|
|
20
|
+
media_type: str
|
|
21
|
+
size: int
|
|
22
|
+
image: bool
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.post("", response_model=AttachmentOut, status_code=201)
|
|
26
|
+
async def upload(
|
|
27
|
+
file: UploadFile, db: DbSession, principal: CurrentPrincipal,
|
|
28
|
+
workspace: str = "default",
|
|
29
|
+
) -> AttachmentOut:
|
|
30
|
+
"""Accept one multipart file. Size-capped (413); rejected for remote
|
|
31
|
+
projects (400). Returns the id to include in a subsequent message send."""
|
|
32
|
+
try:
|
|
33
|
+
out = await save_attachment(db, workspace, file, actor=principal.subject)
|
|
34
|
+
except ValueError as exc:
|
|
35
|
+
raise HTTPException(413 if "too large" in str(exc) else 400, str(exc)) from exc
|
|
36
|
+
return AttachmentOut(**{k: out[k] for k in ("id", "name", "media_type", "size", "image")})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.get("/{attachment_id}")
|
|
40
|
+
async def preview(
|
|
41
|
+
attachment_id: uuid.UUID, db: DbSession, _: CurrentPrincipal,
|
|
42
|
+
) -> FileResponse:
|
|
43
|
+
"""Serve an uploaded attachment's bytes (thumbnails in the transcript)."""
|
|
44
|
+
try:
|
|
45
|
+
path, media_type = await get_attachment_file(db, attachment_id)
|
|
46
|
+
except ValueError as exc:
|
|
47
|
+
raise HTTPException(404, str(exc)) from exc
|
|
48
|
+
return FileResponse(path, media_type=media_type)
|
harness/api/v1/doctor.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""GET /doctor — end-to-end provider preflight report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter
|
|
6
|
+
|
|
7
|
+
from harness.deps import CurrentPrincipal, DbSession
|
|
8
|
+
from harness.services.doctor import doctor_report
|
|
9
|
+
|
|
10
|
+
router = APIRouter(tags=["doctor"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.get("/doctor")
|
|
14
|
+
async def doctor(db: DbSession, _: CurrentPrincipal) -> list[dict]:
|
|
15
|
+
"""Run a preflight health check on all enabled providers.
|
|
16
|
+
|
|
17
|
+
Returns one record per enabled provider with binary, version, health,
|
|
18
|
+
latency, model, MCP flag support, native-session eligibility, and any
|
|
19
|
+
detected problems. Clients should treat a non-empty ``problems`` list
|
|
20
|
+
as actionable — the provider may not work correctly until resolved.
|
|
21
|
+
"""
|
|
22
|
+
return await doctor_report(db)
|
harness/api/v1/health.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Response
|
|
4
|
+
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
|
5
|
+
from sqlalchemy import text
|
|
6
|
+
|
|
7
|
+
from harness.deps import CurrentPrincipal, DbSession
|
|
8
|
+
from harness.observability.audit import verify_chain
|
|
9
|
+
from harness.observability.metrics import memory_hit_rate
|
|
10
|
+
from harness.services.workspaces import get_project_settings
|
|
11
|
+
|
|
12
|
+
router = APIRouter(tags=["health"])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@router.get("/healthz")
|
|
16
|
+
async def healthz(db: DbSession) -> dict:
|
|
17
|
+
await db.execute(text("SELECT 1"))
|
|
18
|
+
return {"status": "ok"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.get("/status")
|
|
22
|
+
async def status(db: DbSession, workspace: str = "default") -> dict:
|
|
23
|
+
"""What the CLI is pointed at: the project folder the agents operate in and
|
|
24
|
+
whether they may edit files. Reflects the per-workspace runtime settings
|
|
25
|
+
(falls back to the process cwd / HARNESS_WRITE default when unset)."""
|
|
26
|
+
return await get_project_settings(db, workspace)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@router.get("/metrics")
|
|
30
|
+
async def metrics() -> Response:
|
|
31
|
+
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@router.get("/audit/verify")
|
|
35
|
+
async def audit_verify(db: DbSession, _: CurrentPrincipal) -> dict:
|
|
36
|
+
ok = await verify_chain(db)
|
|
37
|
+
return {"audit_chain_intact": ok, "memory_hit_rate": round(memory_hit_rate(), 4)}
|
harness/api/v1/mcp.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from sqlalchemy import select
|
|
6
|
+
|
|
7
|
+
from harness.deps import CurrentPrincipal, DbSession
|
|
8
|
+
from harness.mcp.client import McpClient, McpUnavailable
|
|
9
|
+
from harness.mcp.registry import server_to_dict
|
|
10
|
+
from harness.models.mcp_server_config import McpServerConfig
|
|
11
|
+
from harness.services.mcp_servers import (
|
|
12
|
+
create_mcp_server,
|
|
13
|
+
delete_mcp_server,
|
|
14
|
+
list_mcp_servers,
|
|
15
|
+
managed_mcp_names,
|
|
16
|
+
update_mcp_server,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
router = APIRouter(prefix="/mcp", tags=["mcp"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class McpServerOut(BaseModel):
|
|
23
|
+
name: str
|
|
24
|
+
transport: str
|
|
25
|
+
enabled: bool
|
|
26
|
+
read_only: bool
|
|
27
|
+
command: str | None = None
|
|
28
|
+
args: list[str] = []
|
|
29
|
+
url: str | None = None
|
|
30
|
+
tool_allowlist: list[str] = []
|
|
31
|
+
bind: dict = {}
|
|
32
|
+
managed: bool = False # GUI-added (overlay) ⇒ editable/deletable here
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class McpTestResult(BaseModel):
|
|
36
|
+
ok: bool
|
|
37
|
+
tools: list[str] = []
|
|
38
|
+
error: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CreateMcpRequest(BaseModel):
|
|
42
|
+
name: str
|
|
43
|
+
transport: str = "stdio"
|
|
44
|
+
command: str | None = None
|
|
45
|
+
args: list[str] = []
|
|
46
|
+
env: dict = {}
|
|
47
|
+
url: str | None = None
|
|
48
|
+
headers: dict = {}
|
|
49
|
+
token: str | None = None
|
|
50
|
+
tool_allowlist: list[str] = []
|
|
51
|
+
read_only: bool = False
|
|
52
|
+
enabled: bool = True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class UpdateMcpRequest(BaseModel):
|
|
56
|
+
# Only provided fields are changed.
|
|
57
|
+
transport: str | None = None
|
|
58
|
+
command: str | None = None
|
|
59
|
+
args: list[str] | None = None
|
|
60
|
+
env: dict | None = None
|
|
61
|
+
url: str | None = None
|
|
62
|
+
headers: dict | None = None
|
|
63
|
+
token: str | None = None
|
|
64
|
+
tool_allowlist: list[str] | None = None
|
|
65
|
+
read_only: bool | None = None
|
|
66
|
+
enabled: bool | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _out(r: McpServerConfig, managed: set[str]) -> McpServerOut:
|
|
70
|
+
return McpServerOut(
|
|
71
|
+
name=r.name, transport=r.transport.value, enabled=r.enabled,
|
|
72
|
+
read_only=bool(r.read_only), command=r.command, args=r.args or [],
|
|
73
|
+
url=r.url, tool_allowlist=r.tool_allowlist or [], bind=r.bind or {},
|
|
74
|
+
managed=r.name in managed,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@router.get("/servers", response_model=list[McpServerOut])
|
|
79
|
+
async def index(db: DbSession, _: CurrentPrincipal) -> list[McpServerOut]:
|
|
80
|
+
managed = managed_mcp_names()
|
|
81
|
+
return [_out(r, managed) for r in await list_mcp_servers(db)]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@router.post("/servers", status_code=201)
|
|
85
|
+
async def create(req: CreateMcpRequest, db: DbSession, principal: CurrentPrincipal) -> dict:
|
|
86
|
+
try:
|
|
87
|
+
return await create_mcp_server(
|
|
88
|
+
db, name=req.name, transport=req.transport, command=req.command, args=req.args,
|
|
89
|
+
env=req.env, url=req.url, headers=req.headers, token=req.token,
|
|
90
|
+
tool_allowlist=req.tool_allowlist, read_only=req.read_only, enabled=req.enabled,
|
|
91
|
+
actor=principal.subject,
|
|
92
|
+
)
|
|
93
|
+
except ValueError as exc:
|
|
94
|
+
raise HTTPException(422, str(exc)) from exc
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@router.put("/servers/{name}")
|
|
98
|
+
async def update(
|
|
99
|
+
name: str, req: UpdateMcpRequest, db: DbSession, principal: CurrentPrincipal
|
|
100
|
+
) -> dict:
|
|
101
|
+
fields = req.model_dump(exclude_none=True)
|
|
102
|
+
try:
|
|
103
|
+
return await update_mcp_server(db, name, fields=fields, actor=principal.subject)
|
|
104
|
+
except ValueError as exc:
|
|
105
|
+
raise HTTPException(422, str(exc)) from exc
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@router.delete("/servers/{name}")
|
|
109
|
+
async def remove(name: str, db: DbSession, principal: CurrentPrincipal) -> dict:
|
|
110
|
+
try:
|
|
111
|
+
return await delete_mcp_server(db, name, actor=principal.subject)
|
|
112
|
+
except ValueError as exc:
|
|
113
|
+
raise HTTPException(404, str(exc)) from exc
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@router.post("/servers/{name}/test", response_model=McpTestResult)
|
|
117
|
+
async def test(name: str, db: DbSession, _: CurrentPrincipal) -> McpTestResult:
|
|
118
|
+
row = (
|
|
119
|
+
await db.execute(select(McpServerConfig).where(McpServerConfig.name == name))
|
|
120
|
+
).scalar_one_or_none()
|
|
121
|
+
if row is None:
|
|
122
|
+
raise HTTPException(404, f"MCP server {name!r} not found")
|
|
123
|
+
client = McpClient(server_to_dict(row))
|
|
124
|
+
try:
|
|
125
|
+
await client.connect()
|
|
126
|
+
tools = await client.list_tools()
|
|
127
|
+
return McpTestResult(ok=True, tools=[t.name for t in tools])
|
|
128
|
+
except McpUnavailable as exc:
|
|
129
|
+
return McpTestResult(ok=False, error=str(exc))
|
|
130
|
+
finally:
|
|
131
|
+
await client.aclose()
|
harness/api/v1/memory.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
log = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
from harness.deps import CurrentPrincipal, DbSession
|
|
11
|
+
from harness.memory.ingest import ingest_memory
|
|
12
|
+
from harness.memory.namespaces import session_ns, workspace_ns
|
|
13
|
+
from harness.memory.service import MemoryService, reindex_memory
|
|
14
|
+
from harness.models.enums import MemoryScope
|
|
15
|
+
from harness.schemas.memory import (
|
|
16
|
+
MemoryHit,
|
|
17
|
+
MemoryIngestRequest,
|
|
18
|
+
MemoryItemOut,
|
|
19
|
+
MemorySearchRequest,
|
|
20
|
+
MemorySearchResponse,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
router = APIRouter(prefix="/memory", tags=["memory"])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MemoryReindexRequest(BaseModel):
|
|
27
|
+
workspace: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MemoryReindexResponse(BaseModel):
|
|
31
|
+
reindexed: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@router.post("/ingest", response_model=MemoryItemOut, status_code=201)
|
|
35
|
+
async def ingest(req: MemoryIngestRequest, db: DbSession, _: CurrentPrincipal) -> MemoryItemOut:
|
|
36
|
+
if req.scope == MemoryScope.session and req.session_id:
|
|
37
|
+
ns = session_ns(req.workspace, req.session_id, project=req.project)
|
|
38
|
+
else:
|
|
39
|
+
ns = workspace_ns(req.workspace, project=req.project)
|
|
40
|
+
item = await ingest_memory(
|
|
41
|
+
db, namespace=ns, scope=req.scope, mem_type=req.type,
|
|
42
|
+
content=req.content, tags=req.tags,
|
|
43
|
+
)
|
|
44
|
+
return MemoryItemOut.model_validate(item, from_attributes=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.post("/search", response_model=MemorySearchResponse)
|
|
48
|
+
async def search(
|
|
49
|
+
req: MemorySearchRequest, db: DbSession, _: CurrentPrincipal
|
|
50
|
+
) -> MemorySearchResponse:
|
|
51
|
+
result = await MemoryService(db).retrieve(
|
|
52
|
+
workspace_slug=req.workspace, query=req.query,
|
|
53
|
+
session_id=req.session_id, project=req.project, top_k=req.top_k,
|
|
54
|
+
)
|
|
55
|
+
return MemorySearchResponse(
|
|
56
|
+
query=result.query,
|
|
57
|
+
hits=[
|
|
58
|
+
MemoryHit(
|
|
59
|
+
item_id=i.item_id, namespace=i.namespace, type=i.type,
|
|
60
|
+
content=i.content, similarity=round(i.similarity, 4), score=round(i.score, 4),
|
|
61
|
+
)
|
|
62
|
+
for i in result.items
|
|
63
|
+
],
|
|
64
|
+
context_block=result.as_context_block(),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.post("/reindex", response_model=MemoryReindexResponse)
|
|
69
|
+
async def reindex(
|
|
70
|
+
req: MemoryReindexRequest, db: DbSession, _: CurrentPrincipal
|
|
71
|
+
) -> MemoryReindexResponse:
|
|
72
|
+
"""Re-embed all memory items whose embeddings don't match the active model.
|
|
73
|
+
|
|
74
|
+
Use after switching the embedding provider or model to ensure retrieval
|
|
75
|
+
remains model-consistent. Returns the number of items re-embedded.
|
|
76
|
+
"""
|
|
77
|
+
n = await reindex_memory(db, workspace_slug=req.workspace)
|
|
78
|
+
if req.workspace and n == 0:
|
|
79
|
+
log.warning("reindex: workspace_slug=%r matched 0 items — possible typo", req.workspace)
|
|
80
|
+
return MemoryReindexResponse(reindexed=n)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter
|
|
4
|
+
from sqlalchemy import select
|
|
5
|
+
|
|
6
|
+
from harness.deps import CurrentPrincipal, DbSession
|
|
7
|
+
from harness.models.policy import Policy
|
|
8
|
+
from harness.observability.audit import write_audit
|
|
9
|
+
from harness.schemas.policy import PolicyOut, PolicySetRequest
|
|
10
|
+
from harness.services.workspaces import get_or_create_workspace
|
|
11
|
+
|
|
12
|
+
router = APIRouter(tags=["policies"])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@router.put("/policies", response_model=PolicyOut)
|
|
16
|
+
async def set_policy(
|
|
17
|
+
req: PolicySetRequest, db: DbSession, principal: CurrentPrincipal
|
|
18
|
+
) -> PolicyOut:
|
|
19
|
+
ws = await get_or_create_workspace(db, req.workspace)
|
|
20
|
+
existing = (
|
|
21
|
+
await db.execute(
|
|
22
|
+
select(Policy).where(Policy.workspace_id == ws.id, Policy.name == req.name)
|
|
23
|
+
)
|
|
24
|
+
).scalar_one_or_none()
|
|
25
|
+
if existing is None:
|
|
26
|
+
existing = Policy(workspace_id=ws.id, name=req.name, kind=req.kind)
|
|
27
|
+
db.add(existing)
|
|
28
|
+
existing.kind = req.kind
|
|
29
|
+
existing.spec = req.spec
|
|
30
|
+
existing.priority = req.priority
|
|
31
|
+
await db.flush()
|
|
32
|
+
await write_audit(
|
|
33
|
+
db, actor=principal.subject, action="policy.set", target_type="policy",
|
|
34
|
+
target_id=str(existing.id), after={"name": req.name, "kind": req.kind.value},
|
|
35
|
+
)
|
|
36
|
+
return PolicyOut.model_validate(existing, from_attributes=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.get("/policies", response_model=list[PolicyOut])
|
|
40
|
+
async def list_policies(db: DbSession, _: CurrentPrincipal) -> list[PolicyOut]:
|
|
41
|
+
rows = (await db.execute(select(Policy).order_by(Policy.priority.desc()))).scalars().all()
|
|
42
|
+
return [PolicyOut.model_validate(p, from_attributes=True) for p in rows]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# NOTE: the `/approvals` and `/approvals/{id}/decide` endpoints live in
|
|
46
|
+
# harness.api.v1.approvals — that router owns the HITL approval lifecycle
|
|
47
|
+
# (Continuation Run on approve, fail-run on deny). They were previously also
|
|
48
|
+
# defined here, which shadowed the dedicated router (this one registers first)
|
|
49
|
+
# and made the GUI/CLI decide calls 422 against the wrong request schema.
|