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,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()
|
harness/api/__init__.py
ADDED
|
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
|