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,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)
@@ -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)
@@ -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()
@@ -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.