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,65 @@
1
+ """Probe a CLI binary's --version and --help once; cache by (path, version)."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+
9
+ from harness_sdk.cli_base import resolve_binary
10
+
11
+ _FLAG_RE = re.compile(r"(--[a-z][a-z0-9-]+)")
12
+
13
+
14
+ def _cache_path() -> Path:
15
+ """Get cache path, respecting HOME env var (for test monkeypatching)."""
16
+ return Path.home() / ".ags" / "cli_probe.json"
17
+
18
+
19
+ async def _capture(argv: list[str]) -> str:
20
+ # Python 3.12: asyncio.TimeoutError is builtin TimeoutError
21
+ try:
22
+ proc = await asyncio.create_subprocess_exec(
23
+ *argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
24
+ )
25
+ except OSError:
26
+ return ""
27
+ try:
28
+ out, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
29
+ return out.decode(errors="replace")
30
+ except TimeoutError:
31
+ try:
32
+ proc.kill()
33
+ except ProcessLookupError:
34
+ pass
35
+ return ""
36
+
37
+
38
+ async def probe_cli(binary: str) -> dict:
39
+ # Resolve via PATH + common install dirs so an installed-but-off-PATH CLI probes OK.
40
+ path = resolve_binary(binary)
41
+ if not Path(path).exists():
42
+ return {"version": None, "flags": set(), "ok": False}
43
+ version = (await _capture([path, "--version"])).strip().splitlines()[0:1]
44
+ version = version[0] if version else None
45
+ cache = {}
46
+ cache_file = _cache_path()
47
+ if cache_file.exists():
48
+ try:
49
+ cache = json.loads(cache_file.read_text())
50
+ except json.JSONDecodeError:
51
+ cache = {}
52
+ key = f"{path}::{version}"
53
+ if key in cache:
54
+ return {"version": version, "flags": set(cache[key]), "ok": True}
55
+ help_text = await _capture([path, "--help"])
56
+ flags = set(_FLAG_RE.findall(help_text))
57
+ cache[key] = sorted(flags)
58
+ cache_file.parent.mkdir(parents=True, exist_ok=True)
59
+ cache_file.write_text(json.dumps(cache, indent=1))
60
+ cache_file.chmod(0o600)
61
+ return {"version": version, "flags": flags, "ok": True}
62
+
63
+
64
+ def supports_flag(probe: dict, flag: str) -> bool:
65
+ return probe.get("ok", False) and flag in probe.get("flags", set())
@@ -0,0 +1,56 @@
1
+ """Adapter registry — maps ``AdapterKind`` to concrete classes and builds adapter
2
+ instances from a ``ProviderConfig`` row, resolving ``secret_ref`` at construction.
3
+
4
+ This is the only place that knows about every concrete adapter; the orchestrator
5
+ asks the registry for an adapter by provider name and depends solely on
6
+ ``BaseAdapter``. New adapters register here via ``register``."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from harness_sdk.base import AdapterConfig, BaseAdapter
11
+
12
+ from harness.adapters.antigravity import AntigravityCliAdapter
13
+ from harness.adapters.claude_code import ClaudeCodeAdapter
14
+ from harness.adapters.local_openai import OpenAICompatibleLocalAdapter
15
+ from harness.adapters.mock import MockAdapter
16
+ from harness.models.enums import AdapterKind
17
+ from harness.models.provider_config import ProviderConfig
18
+ from harness.security.secrets import resolve_secret_ref
19
+
20
+ _REGISTRY: dict[AdapterKind, type[BaseAdapter]] = {
21
+ AdapterKind.mock: MockAdapter,
22
+ AdapterKind.openai_local: OpenAICompatibleLocalAdapter,
23
+ AdapterKind.claude_code: ClaudeCodeAdapter,
24
+ AdapterKind.antigravity: AntigravityCliAdapter,
25
+ }
26
+
27
+
28
+ def register(kind: AdapterKind, cls: type[BaseAdapter]) -> None:
29
+ """Extension hook for new adapter kinds (Bedrock, Vertex, Azure, custom…)."""
30
+ _REGISTRY[kind] = cls
31
+
32
+
33
+ def adapter_class_for(kind: AdapterKind) -> type[BaseAdapter]:
34
+ if kind not in _REGISTRY:
35
+ raise KeyError(f"No adapter registered for kind={kind!r}")
36
+ return _REGISTRY[kind]
37
+
38
+
39
+ def build_adapter(pc: ProviderConfig) -> BaseAdapter:
40
+ """Instantiate an adapter from a persisted provider config. The secret is
41
+ resolved into process memory only — never written back to the row."""
42
+ cfg = AdapterConfig(
43
+ name=pc.name,
44
+ kind=pc.kind,
45
+ base_url=pc.base_url,
46
+ model=pc.model,
47
+ api_key=resolve_secret_ref(pc.secret_ref),
48
+ params=pc.params or {},
49
+ capabilities=pc.capabilities or {},
50
+ )
51
+ return adapter_class_for(pc.kind)(cfg)
52
+
53
+
54
+ def build_adapter_from_config(cfg: AdapterConfig) -> BaseAdapter:
55
+ """Build directly from an in-memory config (used by tests and CLI probes)."""
56
+ return adapter_class_for(cfg.kind)(cfg)
@@ -0,0 +1,255 @@
1
+ """Warm persistent claude process pool (experimental, HARNESS_WARM_CLI=1).
2
+
3
+ Verified CLI contract (claude 2.1.190, tested empirically):
4
+ - ``claude --print --verbose --input-format stream-json --output-format stream-json
5
+ [--model m]`` reads NDJSON user messages from stdin, keeps ONE conversation
6
+ across multiple messages in ONE process, and stays alive until stdin EOF.
7
+ ``--verbose`` is REQUIRED for the event stream to be emitted.
8
+ - Turn protocol:
9
+ Write: {"type":"user","message":{"role":"user","content":[{"type":"text","text":<msg>}]}}\\n
10
+ to stdin. The turn's events stream out on stdout. A turn ENDS with a
11
+ {"type":"result", ...} line — exactly one result per turn.
12
+ - The stream also emits {"type":"system","subtype":"init","session_id":...} at startup
13
+ plus other system/thinking events (harmlessly ignored by the existing
14
+ ClaudeCodeAdapter.parse_chunk).
15
+ - Two turns sent to a single process produce exactly two result lines, each with
16
+ per-turn content and shared conversation context.
17
+
18
+ Architecture:
19
+ A dict keyed by caller-supplied ``key`` (the adapter uses ``session_id:mode`` so a
20
+ plan→write permission switch spawns a separate proc) holds live ``WarmProc``
21
+ entries. Each entry carries an ``asyncio.Lock`` that serializes turns so two
22
+ concurrent callers can't interleave their stdin writes / stdout reads.
23
+ Processes are spawned with ``start_new_session=True`` (same isolation as the
24
+ one-shot adapter path in cli_base.py) so they can be killed via the process group
25
+ (mirroring cli_base.py's killpg pattern). Each entry may carry an ``on_close``
26
+ callback (per-proc cleanup, e.g. remove the session's resolved-secret MCP temp
27
+ dir), invoked once and exception-suppressed on any kill/reap/close path.
28
+
29
+ Respawn resumes on disk: a warm proc holds ONE conversation; when it dies (idle
30
+ reap, crash, ``kill``) the on-disk conversation persists. A freshly respawned proc
31
+ passes ``--resume <native_session_id>`` (built by ``build_warm_argv``) to resume
32
+ it — no engine-side transcript replay needed (verified empirically).
33
+
34
+ A module-level singleton ``_POOL`` is created lazily and exposed via
35
+ ``get_warm_pool()``. The harness ``lifespan`` in ``main.py`` starts a repeating
36
+ 60-second reaper task and calls ``close_all()`` on shutdown.
37
+
38
+ Usage is gated behind ``settings.warm_cli`` (env ``HARNESS_WARM_CLI``). A warm-turn
39
+ death before any event reaches the bus falls back to the one-shot spawn path; a
40
+ death mid-stream is fail-closed by the adapter (see ClaudeCodeAdapter.stream_events).
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import asyncio
46
+ import contextlib
47
+ import json
48
+ import os
49
+ import signal
50
+ import time
51
+ from collections.abc import AsyncIterator, Callable
52
+ from dataclasses import dataclass, field
53
+
54
+ from harness.observability.logging import get_logger
55
+
56
+ log = get_logger("warm-pool")
57
+
58
+
59
+ @dataclass
60
+ class WarmProc:
61
+ """A live warm claude process kept between turns."""
62
+
63
+ proc: asyncio.subprocess.Process
64
+ key: str
65
+ last_used: float = field(default_factory=time.monotonic)
66
+ # Per-proc lock: ensures turns don't interleave on the same process.
67
+ lock: asyncio.Lock = field(default_factory=asyncio.Lock)
68
+ # Optional per-proc teardown callback (e.g. remove this session's MCP temp dir).
69
+ # Invoked exactly once when the proc leaves the pool (kill/reap/close), all
70
+ # exceptions suppressed.
71
+ on_close: Callable[[], None] | None = None
72
+
73
+
74
+ class WarmCliPool:
75
+ """Pool of long-lived ``claude`` (or stub) processes, keyed by session key.
76
+
77
+ Thread-safety model: all public methods are async and intended to be called
78
+ from one asyncio event loop. Concurrent turns on the same proc are serialized
79
+ by the per-proc ``asyncio.Lock``.
80
+ """
81
+
82
+ def __init__(self) -> None:
83
+ self._procs: dict[str, WarmProc] = {}
84
+
85
+ # ── public API ────────────────────────────────────────────────────────────
86
+
87
+ async def acquire(
88
+ self, key: str, argv: list[str], env: dict,
89
+ *, on_close: Callable[[], None] | None = None,
90
+ ) -> WarmProc:
91
+ """Return (or spawn) the warm process for ``key``.
92
+
93
+ If an existing process is alive, it is returned immediately.
94
+ If it has exited (or never existed), a new one is spawned.
95
+
96
+ ``on_close`` is an optional per-proc teardown callback invoked (once,
97
+ exception-suppressed) when the proc leaves the pool via kill/reap/close.
98
+ """
99
+ existing = self._procs.get(key)
100
+ if existing is not None and existing.proc.returncode is None:
101
+ return existing
102
+ # Spawn a fresh process.
103
+ merged_env = {**os.environ, **env}
104
+ proc = await asyncio.create_subprocess_exec(
105
+ *argv,
106
+ stdin=asyncio.subprocess.PIPE,
107
+ stdout=asyncio.subprocess.PIPE,
108
+ stderr=asyncio.subprocess.PIPE,
109
+ env=merged_env,
110
+ start_new_session=True, # own process group for clean kill
111
+ )
112
+ wp = WarmProc(proc=proc, key=key, on_close=on_close)
113
+ self._procs[key] = wp
114
+ log.debug("warm_pool_spawned", key=key, pid=proc.pid)
115
+ return wp
116
+
117
+ async def kill(self, key: str) -> None:
118
+ """Kill and remove the warm process for ``key`` (no-op if absent).
119
+
120
+ Used to fail-close a broken warm turn (so the next turn respawns and
121
+ ``--resume``s the on-disk conversation) and to service ``cancel_run``.
122
+ """
123
+ wp = self._procs.pop(key, None)
124
+ if wp is None:
125
+ return
126
+ await _kill_proc(wp.proc, key)
127
+ _run_on_close(wp)
128
+
129
+ async def send_turn(self, proc: WarmProc, message: str) -> AsyncIterator[str]:
130
+ """Write one user NDJSON line to ``proc.proc.stdin`` and yield stdout lines
131
+ until (and including) the ``{"type":"result"}`` line for this turn.
132
+
133
+ Serialized per-proc via ``proc.lock`` so concurrent callers queue up.
134
+ A per-line idle timeout is applied when ``stall_timeout_s > 0`` (0 → no
135
+ timeout), reusing the harness-wide setting.
136
+ """
137
+ from harness.settings import get_settings
138
+
139
+ stall_s = get_settings().stall_timeout_s or None # None = no timeout
140
+
141
+ async with proc.lock:
142
+ proc.last_used = time.monotonic()
143
+ # Write the user turn to stdin.
144
+ if proc.proc.stdin is None or proc.proc.returncode is not None:
145
+ raise RuntimeError("warm proc stdin is closed or process has exited")
146
+ line_bytes = (message.rstrip("\n") + "\n").encode()
147
+ proc.proc.stdin.write(line_bytes)
148
+ await proc.proc.stdin.drain()
149
+
150
+ # Read stdout lines until a result line.
151
+ assert proc.proc.stdout is not None
152
+ async for line in _read_until_result(proc.proc.stdout, stall_s):
153
+ proc.last_used = time.monotonic()
154
+ yield line
155
+
156
+ async def reap_idle(self, max_idle_s: float = 300) -> None:
157
+ """Kill and remove processes that have been idle for more than ``max_idle_s``."""
158
+ now = time.monotonic()
159
+ stale = [
160
+ key for key, wp in self._procs.items()
161
+ if (now - wp.last_used) >= max_idle_s or wp.proc.returncode is not None
162
+ ]
163
+ for key in stale:
164
+ wp = self._procs.pop(key, None)
165
+ if wp is not None:
166
+ await _kill_proc(wp.proc, key)
167
+ _run_on_close(wp)
168
+
169
+ async def close_all(self) -> None:
170
+ """Close stdin on all procs (signals EOF exit), then kill after grace."""
171
+ keys = list(self._procs.keys())
172
+ for key in keys:
173
+ wp = self._procs.pop(key, None)
174
+ if wp is None:
175
+ continue
176
+ # Close stdin → the stub/claude reads EOF and exits cleanly.
177
+ if wp.proc.stdin is not None:
178
+ with contextlib.suppress(Exception):
179
+ wp.proc.stdin.close()
180
+ await _kill_proc(wp.proc, key, grace_s=5.0)
181
+ _run_on_close(wp)
182
+
183
+
184
+ # ── module-level singleton ────────────────────────────────────────────────────
185
+
186
+ _POOL: WarmCliPool | None = None
187
+
188
+
189
+ def get_warm_pool() -> WarmCliPool:
190
+ """Return the module-level WarmCliPool singleton (created lazily)."""
191
+ global _POOL
192
+ if _POOL is None:
193
+ _POOL = WarmCliPool()
194
+ return _POOL
195
+
196
+
197
+ # ── internal helpers ──────────────────────────────────────────────────────────
198
+
199
+ def _run_on_close(wp: WarmProc) -> None:
200
+ """Invoke a WarmProc's teardown callback once, suppressing any error."""
201
+ if wp.on_close is not None:
202
+ with contextlib.suppress(Exception):
203
+ wp.on_close()
204
+ wp.on_close = None # ensure it runs at most once
205
+
206
+
207
+ async def _read_until_result(
208
+ stdout: asyncio.StreamReader, stall_s: float | None
209
+ ) -> AsyncIterator[str]:
210
+ """Yield decoded stdout lines until (and including) a ``type=result`` line."""
211
+ while True:
212
+ if stall_s is not None:
213
+ try:
214
+ raw = await asyncio.wait_for(stdout.readline(), timeout=stall_s)
215
+ except asyncio.TimeoutError:
216
+ raise TimeoutError(
217
+ f"warm pool: no output for {stall_s}s (stalled)"
218
+ )
219
+ else:
220
+ raw = await stdout.readline()
221
+
222
+ if not raw: # EOF before result — process died
223
+ raise RuntimeError("warm pool: process stdout closed before result line")
224
+
225
+ line = raw.decode(errors="replace").rstrip("\r\n")
226
+ if not line:
227
+ continue # skip blank lines
228
+
229
+ yield line
230
+
231
+ # Stop after the result line.
232
+ try:
233
+ obj = json.loads(line)
234
+ if obj.get("type") == "result":
235
+ break
236
+ except json.JSONDecodeError:
237
+ pass # non-JSON noise; keep reading
238
+
239
+
240
+ async def _kill_proc(
241
+ proc: asyncio.subprocess.Process, key: str, grace_s: float = 2.0
242
+ ) -> None:
243
+ """SIGTERM the process group, wait grace_s, then SIGKILL if still alive."""
244
+ if proc.returncode is not None:
245
+ return
246
+ log.debug("warm_pool_reaping", key=key, pid=proc.pid)
247
+ with contextlib.suppress(ProcessLookupError, OSError):
248
+ os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
249
+ with contextlib.suppress(Exception):
250
+ await asyncio.wait_for(proc.wait(), timeout=grace_s)
251
+ if proc.returncode is None:
252
+ with contextlib.suppress(ProcessLookupError, OSError):
253
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
254
+ with contextlib.suppress(Exception):
255
+ await proc.wait()
File without changes
harness/api/router.py ADDED
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from harness.api.v1 import (
6
+ adapters,
7
+ approvals,
8
+ artifacts,
9
+ attachments,
10
+ doctor,
11
+ health,
12
+ mcp,
13
+ memory,
14
+ policies,
15
+ project,
16
+ runs,
17
+ sessions,
18
+ stream,
19
+ tasks,
20
+ usage,
21
+ )
22
+
23
+ api_router = APIRouter(prefix="/api/v1")
24
+ api_router.include_router(tasks.router)
25
+ api_router.include_router(runs.router)
26
+ api_router.include_router(doctor.router)
27
+ api_router.include_router(sessions.router)
28
+ api_router.include_router(memory.router)
29
+ api_router.include_router(adapters.router)
30
+ api_router.include_router(mcp.router)
31
+ api_router.include_router(policies.router)
32
+ api_router.include_router(stream.router)
33
+ api_router.include_router(health.router)
34
+ api_router.include_router(project.router)
35
+ api_router.include_router(artifacts.router)
36
+ api_router.include_router(attachments.router)
37
+ api_router.include_router(approvals.router)
38
+ api_router.include_router(usage.router)
File without changes
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+ from pydantic import BaseModel
5
+
6
+ from harness.deps import CurrentPrincipal, DbSession
7
+ from harness.schemas.adapter import AdapterOut, AdapterTestResult
8
+ from harness.services.adapters_health import (
9
+ create_provider,
10
+ delete_provider,
11
+ list_models,
12
+ list_provider_configs,
13
+ managed_provider_names,
14
+ set_model,
15
+ test_adapter,
16
+ )
17
+
18
+ router = APIRouter(prefix="/adapters", tags=["adapters"])
19
+
20
+
21
+ class SetModelRequest(BaseModel):
22
+ model: str
23
+
24
+
25
+ class CreateProviderRequest(BaseModel):
26
+ name: str
27
+ base_url: str
28
+ model: str | None = None
29
+ api_key: str | None = None
30
+ tool_calling: bool = False
31
+ params: dict = {}
32
+
33
+
34
+ @router.get("", response_model=list[AdapterOut])
35
+ async def index(db: DbSession, _: CurrentPrincipal) -> list[AdapterOut]:
36
+ managed = managed_provider_names()
37
+ return [
38
+ AdapterOut(
39
+ id=pc.id, name=pc.name, kind=pc.kind, model=pc.model,
40
+ base_url=pc.base_url, enabled=pc.enabled, capabilities=pc.capabilities or {},
41
+ managed=pc.name in managed,
42
+ )
43
+ for pc in await list_provider_configs(db)
44
+ ]
45
+
46
+
47
+ @router.post("", status_code=201)
48
+ async def create(req: CreateProviderRequest, db: DbSession, principal: CurrentPrincipal) -> dict:
49
+ """Add an OpenAI-compatible provider (durable: overlay file + 0600 file secret)."""
50
+ try:
51
+ return await create_provider(
52
+ db, name=req.name, base_url=req.base_url, model=req.model,
53
+ api_key=req.api_key, tool_calling=req.tool_calling, params=req.params,
54
+ actor=principal.subject,
55
+ )
56
+ except ValueError as exc:
57
+ raise HTTPException(422, str(exc)) from exc
58
+
59
+ @router.put("/{name}")
60
+ async def update(name: str, req: CreateProviderRequest, db: DbSession, principal: CurrentPrincipal) -> dict:
61
+ """Update an existing OpenAI-compatible provider."""
62
+ try:
63
+ return await create_provider(
64
+ db, name=name, base_url=req.base_url, model=req.model,
65
+ api_key=req.api_key, tool_calling=req.tool_calling, params=req.params,
66
+ actor=principal.subject, update=True,
67
+ )
68
+ except ValueError as exc:
69
+ raise HTTPException(422, str(exc)) from exc
70
+
71
+
72
+ @router.delete("/{name}")
73
+ async def remove(name: str, db: DbSession, principal: CurrentPrincipal) -> dict:
74
+ try:
75
+ return await delete_provider(db, name, actor=principal.subject)
76
+ except ValueError as exc:
77
+ raise HTTPException(404, str(exc)) from exc
78
+
79
+
80
+ @router.post("/{name}/test", response_model=AdapterTestResult)
81
+ async def test(name: str, db: DbSession, _: CurrentPrincipal) -> AdapterTestResult:
82
+ try:
83
+ return await test_adapter(db, name)
84
+ except ValueError as exc:
85
+ raise HTTPException(404, str(exc)) from exc
86
+
87
+
88
+ @router.get("/{name}/models")
89
+ async def models(name: str, db: DbSession, _: CurrentPrincipal) -> dict:
90
+ try:
91
+ return await list_models(db, name)
92
+ except ValueError as exc:
93
+ raise HTTPException(404, str(exc)) from exc
94
+
95
+
96
+ @router.post("/{name}/model")
97
+ async def select_model(
98
+ name: str, req: SetModelRequest, db: DbSession, principal: CurrentPrincipal
99
+ ) -> dict:
100
+ try:
101
+ return await set_model(db, name, req.model, actor=principal.subject)
102
+ except ValueError as exc:
103
+ # 404 for unknown provider, 422 for an invalid model choice.
104
+ code = 404 if "not found" in str(exc) else 422
105
+ raise HTTPException(code, str(exc)) from exc