ags-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ags_cli-0.1.0.dist-info/METADATA +332 -0
- ags_cli-0.1.0.dist-info/RECORD +156 -0
- ags_cli-0.1.0.dist-info/WHEEL +5 -0
- ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ags_cli-0.1.0.dist-info/top_level.txt +2 -0
- cli/__init__.py +0 -0
- cli/__main__.py +4 -0
- cli/client.py +145 -0
- cli/commands/__init__.py +0 -0
- cli/commands/adapter.py +33 -0
- cli/commands/artifact.py +21 -0
- cli/commands/ask.py +212 -0
- cli/commands/chat.py +649 -0
- cli/commands/diff.py +49 -0
- cli/commands/doctor.py +93 -0
- cli/commands/gui.py +45 -0
- cli/commands/init.py +20 -0
- cli/commands/mcp.py +50 -0
- cli/commands/memory.py +65 -0
- cli/commands/model.py +73 -0
- cli/commands/policy.py +52 -0
- cli/commands/project.py +122 -0
- cli/commands/quota.py +107 -0
- cli/commands/run.py +84 -0
- cli/commands/serve.py +171 -0
- cli/commands/service.py +236 -0
- cli/commands/session.py +219 -0
- cli/commands/stats.py +96 -0
- cli/commands/status.py +36 -0
- cli/commands/task.py +37 -0
- cli/commands/usage.py +86 -0
- cli/local.py +84 -0
- cli/main.py +149 -0
- harness/__init__.py +4 -0
- harness/adapters/__init__.py +26 -0
- harness/adapters/antigravity.py +301 -0
- harness/adapters/claude_code.py +522 -0
- harness/adapters/local_openai.py +534 -0
- harness/adapters/mock.py +112 -0
- harness/adapters/probe.py +65 -0
- harness/adapters/registry.py +56 -0
- harness/adapters/warm_pool.py +255 -0
- harness/api/__init__.py +0 -0
- harness/api/router.py +38 -0
- harness/api/v1/__init__.py +0 -0
- harness/api/v1/adapters.py +105 -0
- harness/api/v1/approvals.py +173 -0
- harness/api/v1/artifacts.py +44 -0
- harness/api/v1/attachments.py +48 -0
- harness/api/v1/doctor.py +22 -0
- harness/api/v1/health.py +37 -0
- harness/api/v1/mcp.py +131 -0
- harness/api/v1/memory.py +80 -0
- harness/api/v1/policies.py +49 -0
- harness/api/v1/project.py +302 -0
- harness/api/v1/runs.py +98 -0
- harness/api/v1/sessions.py +248 -0
- harness/api/v1/stream.py +110 -0
- harness/api/v1/tasks.py +30 -0
- harness/api/v1/usage.py +58 -0
- harness/bootstrap.py +216 -0
- harness/db.py +164 -0
- harness/deps.py +58 -0
- harness/eventbus/__init__.py +20 -0
- harness/eventbus/inprocess_bus.py +85 -0
- harness/main.py +144 -0
- harness/mcp/__init__.py +22 -0
- harness/mcp/client.py +139 -0
- harness/mcp/config_gen.py +77 -0
- harness/mcp/registry.py +156 -0
- harness/mcp/tools.py +81 -0
- harness/memory/__init__.py +13 -0
- harness/memory/context_budget.py +116 -0
- harness/memory/embed.py +217 -0
- harness/memory/extract.py +74 -0
- harness/memory/ingest.py +106 -0
- harness/memory/namespaces.py +57 -0
- harness/memory/ranking.py +31 -0
- harness/memory/redact.py +37 -0
- harness/memory/service.py +320 -0
- harness/memory/sqlite_vec_backend.py +266 -0
- harness/memory/summarize.py +68 -0
- harness/models/__init__.py +35 -0
- harness/models/adapter_health.py +27 -0
- harness/models/approval.py +37 -0
- harness/models/artifact.py +35 -0
- harness/models/audit_log.py +33 -0
- harness/models/comparison.py +33 -0
- harness/models/enums.py +146 -0
- harness/models/mcp_server_config.py +49 -0
- harness/models/memory.py +76 -0
- harness/models/policy.py +29 -0
- harness/models/provider_config.py +35 -0
- harness/models/run.py +46 -0
- harness/models/run_event.py +35 -0
- harness/models/session.py +50 -0
- harness/models/task.py +30 -0
- harness/models/types.py +63 -0
- harness/models/workspace.py +27 -0
- harness/observability/__init__.py +4 -0
- harness/observability/audit.py +88 -0
- harness/observability/logging.py +47 -0
- harness/observability/metrics.py +43 -0
- harness/observability/tracing.py +38 -0
- harness/orchestrator/__init__.py +19 -0
- harness/orchestrator/engine.py +821 -0
- harness/orchestrator/handoff.py +33 -0
- harness/orchestrator/lifecycle.py +69 -0
- harness/orchestrator/native_resume.py +93 -0
- harness/orchestrator/policies.py +101 -0
- harness/orchestrator/reconcile.py +39 -0
- harness/orchestrator/run_graph.py +62 -0
- harness/orchestrator/snapshot.py +49 -0
- harness/schemas/__init__.py +1 -0
- harness/schemas/adapter.py +26 -0
- harness/schemas/approval.py +25 -0
- harness/schemas/artifact.py +17 -0
- harness/schemas/common.py +20 -0
- harness/schemas/memory.py +50 -0
- harness/schemas/policy.py +39 -0
- harness/schemas/run.py +116 -0
- harness/schemas/session.py +93 -0
- harness/schemas/task.py +24 -0
- harness/security/__init__.py +5 -0
- harness/security/approval_policy.py +107 -0
- harness/security/auth.py +106 -0
- harness/security/permissions.py +59 -0
- harness/security/risk.py +59 -0
- harness/security/secrets.py +49 -0
- harness/services/__init__.py +1 -0
- harness/services/adapters_health.py +212 -0
- harness/services/approvals.py +84 -0
- harness/services/artifacts.py +78 -0
- harness/services/attachments.py +228 -0
- harness/services/comparison.py +345 -0
- harness/services/doctor.py +156 -0
- harness/services/files.py +287 -0
- harness/services/mcp_servers.py +179 -0
- harness/services/project_git.py +101 -0
- harness/services/retention.py +97 -0
- harness/services/runs.py +155 -0
- harness/services/runs_diff.py +201 -0
- harness/services/sessions.py +242 -0
- harness/services/ssh.py +300 -0
- harness/services/stats.py +132 -0
- harness/services/tasks.py +41 -0
- harness/services/workspaces.py +184 -0
- harness/services/worktrees.py +439 -0
- harness/settings.py +186 -0
- harness/skills/__init__.py +11 -0
- harness/skills/manager.py +141 -0
- harness/tools/__init__.py +1 -0
- harness/tools/fs_tools.py +295 -0
- harness/workers/__init__.py +0 -0
- harness/workers/dispatch.py +26 -0
- harness/workers/inprocess.py +135 -0
harness/api/v1/stream.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Server-Sent Events tail of a run's normalized event stream.
|
|
2
|
+
|
|
3
|
+
The durable ``run_events`` ledger is the source of truth. The in-process bus buffers
|
|
4
|
+
every event per run, so the SSE endpoint *subscribes* for low-latency live push
|
|
5
|
+
(backlog + live tail), with a ledger catch-up and a final ledger sweep as the durable
|
|
6
|
+
backstop. Race-free: a client that connects late still gets the buffered backlog, and
|
|
7
|
+
an already-finished run is served from the ledger.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import uuid
|
|
15
|
+
|
|
16
|
+
from fastapi import APIRouter
|
|
17
|
+
from fastapi.responses import StreamingResponse
|
|
18
|
+
from sqlalchemy import select
|
|
19
|
+
|
|
20
|
+
from harness.deps import CurrentPrincipal, DbSession
|
|
21
|
+
from harness.eventbus import get_event_bus
|
|
22
|
+
from harness.models.enums import RunStatus
|
|
23
|
+
from harness.models.run import Run
|
|
24
|
+
from harness.models.run_event import RunEvent
|
|
25
|
+
|
|
26
|
+
router = APIRouter(tags=["stream"])
|
|
27
|
+
|
|
28
|
+
_TERMINAL = {
|
|
29
|
+
RunStatus.succeeded,
|
|
30
|
+
RunStatus.failed,
|
|
31
|
+
RunStatus.cancelled,
|
|
32
|
+
RunStatus.needs_approval,
|
|
33
|
+
}
|
|
34
|
+
_END = "event: end\ndata: {}\n\n"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _frame(e: RunEvent) -> str:
|
|
38
|
+
payload = {"type": e.type.value, "seq": e.seq, **(e.payload or {})}
|
|
39
|
+
return f"data: {json.dumps(payload, default=str)}\n\n"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _frame_dict(event: dict) -> str:
|
|
43
|
+
# Bus events are already in the wire shape ({type, seq, ...payload}).
|
|
44
|
+
return f"data: {json.dumps(event, default=str)}\n\n"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.get("/stream/{run_id}")
|
|
48
|
+
async def stream_run(
|
|
49
|
+
run_id: uuid.UUID,
|
|
50
|
+
db: DbSession,
|
|
51
|
+
_: CurrentPrincipal,
|
|
52
|
+
timeout_s: int = 600,
|
|
53
|
+
after_seq: int = -1,
|
|
54
|
+
) -> StreamingResponse:
|
|
55
|
+
async def _new_events(seen: int) -> list[RunEvent]:
|
|
56
|
+
return list(
|
|
57
|
+
(
|
|
58
|
+
await db.execute(
|
|
59
|
+
select(RunEvent)
|
|
60
|
+
.where(RunEvent.run_id == run_id, RunEvent.seq > seen)
|
|
61
|
+
.order_by(RunEvent.seq)
|
|
62
|
+
)
|
|
63
|
+
).scalars()
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
async def _status() -> RunStatus | None:
|
|
67
|
+
return (
|
|
68
|
+
await db.execute(select(Run.status).where(Run.id == run_id))
|
|
69
|
+
).scalar_one_or_none()
|
|
70
|
+
|
|
71
|
+
async def _push_source():
|
|
72
|
+
"""Live push via the in-process bus, ledger as backstop.
|
|
73
|
+
|
|
74
|
+
Catch-up starts at ``after_seq`` so a reconnecting client skips already-seen
|
|
75
|
+
events and resumes exactly where it left off.
|
|
76
|
+
"""
|
|
77
|
+
bus = get_event_bus()
|
|
78
|
+
seen = after_seq # _new_events fetches seq > seen; after_seq=-1 → all events
|
|
79
|
+
try:
|
|
80
|
+
async with asyncio.timeout(timeout_s):
|
|
81
|
+
status = await _status()
|
|
82
|
+
if status is None:
|
|
83
|
+
yield _END
|
|
84
|
+
return
|
|
85
|
+
# Already finished → serve the full ledger and end (buffer may be evicted).
|
|
86
|
+
if status in _TERMINAL:
|
|
87
|
+
for e in await _new_events(seen):
|
|
88
|
+
seen = e.seq
|
|
89
|
+
yield _frame(e)
|
|
90
|
+
yield _END
|
|
91
|
+
return
|
|
92
|
+
# In flight: catch up on anything already committed, then live-subscribe.
|
|
93
|
+
for e in await _new_events(seen):
|
|
94
|
+
seen = e.seq
|
|
95
|
+
yield _frame(e)
|
|
96
|
+
await db.rollback()
|
|
97
|
+
async for ev in bus.subscribe(str(run_id)):
|
|
98
|
+
if ev.get("seq", -1) > seen:
|
|
99
|
+
seen = ev["seq"]
|
|
100
|
+
yield _frame_dict(ev)
|
|
101
|
+
# Bus signalled done — final ledger sweep for completeness.
|
|
102
|
+
await db.rollback()
|
|
103
|
+
for e in await _new_events(seen):
|
|
104
|
+
seen = e.seq
|
|
105
|
+
yield _frame(e)
|
|
106
|
+
except TimeoutError:
|
|
107
|
+
yield 'data: {"type":"status","status":"stream_timeout"}\n\n'
|
|
108
|
+
yield _END
|
|
109
|
+
|
|
110
|
+
return StreamingResponse(_push_source(), media_type="text/event-stream")
|
harness/api/v1/tasks.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, HTTPException
|
|
6
|
+
|
|
7
|
+
from harness.deps import CurrentPrincipal, DbSession
|
|
8
|
+
from harness.schemas.task import TaskCreate, TaskOut
|
|
9
|
+
from harness.services.tasks import create_task, get_task, list_tasks
|
|
10
|
+
|
|
11
|
+
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@router.post("", response_model=TaskOut, status_code=201)
|
|
15
|
+
async def create(payload: TaskCreate, db: DbSession, principal: CurrentPrincipal) -> TaskOut:
|
|
16
|
+
task = await create_task(db, payload, actor=principal.subject)
|
|
17
|
+
return TaskOut.model_validate(task)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.get("", response_model=list[TaskOut])
|
|
21
|
+
async def index(db: DbSession, _: CurrentPrincipal, limit: int = 50) -> list[TaskOut]:
|
|
22
|
+
return [TaskOut.model_validate(t) for t in await list_tasks(db, limit=limit)]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.get("/{task_id}", response_model=TaskOut)
|
|
26
|
+
async def get(task_id: uuid.UUID, db: DbSession, _: CurrentPrincipal) -> TaskOut:
|
|
27
|
+
task = await get_task(db, task_id)
|
|
28
|
+
if task is None:
|
|
29
|
+
raise HTTPException(404, "task not found")
|
|
30
|
+
return TaskOut.model_validate(task)
|
harness/api/v1/usage.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""AgentS-tracked usage: per-provider run/token/cost totals from the ledger.
|
|
2
|
+
|
|
3
|
+
This is *AgentS's own* accounting (every run records a cost event with token
|
|
4
|
+
counts and, where the provider reports it, a USD figure). It is NOT the provider's
|
|
5
|
+
plan quota (5-hour / weekly limits) — those aren't exposed by the `claude`/`agy` CLIs
|
|
6
|
+
in a scriptable way. See `ags quota` for how plan limits are surfaced."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter
|
|
13
|
+
from sqlalchemy import select
|
|
14
|
+
|
|
15
|
+
from harness.deps import CurrentPrincipal, DbSession
|
|
16
|
+
from harness.models.provider_config import ProviderConfig
|
|
17
|
+
from harness.models.run import Run
|
|
18
|
+
from harness.models.session import Session
|
|
19
|
+
from harness.models.workspace import Workspace
|
|
20
|
+
|
|
21
|
+
router = APIRouter(prefix="/usage", tags=["usage"])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.get("")
|
|
25
|
+
async def usage(db: DbSession, _: CurrentPrincipal, workspace: str | None = None) -> list[dict]:
|
|
26
|
+
"""Per-provider totals across runs, optionally scoped to one workspace.
|
|
27
|
+
|
|
28
|
+
Aggregated in Python (cost is a JSON column) — fine for the volumes a single
|
|
29
|
+
instance sees, and dialect-agnostic across SQLite and Postgres."""
|
|
30
|
+
pc = {
|
|
31
|
+
row.id: (row.name, row.kind.value)
|
|
32
|
+
for row in (await db.execute(select(ProviderConfig))).scalars()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
stmt = select(Run)
|
|
36
|
+
if workspace:
|
|
37
|
+
stmt = (
|
|
38
|
+
stmt.join(Session, Session.id == Run.session_id)
|
|
39
|
+
.join(Workspace, Workspace.id == Session.workspace_id)
|
|
40
|
+
.where(Workspace.slug == workspace)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
agg: dict[str, dict] = defaultdict(
|
|
44
|
+
lambda: {"kind": "", "runs": 0, "prompt_tokens": 0,
|
|
45
|
+
"completion_tokens": 0, "total_tokens": 0, "usd": 0.0}
|
|
46
|
+
)
|
|
47
|
+
for r in (await db.execute(stmt)).scalars():
|
|
48
|
+
name, kind = pc.get(r.provider_config_id) or (r.adapter_kind.value, r.adapter_kind.value)
|
|
49
|
+
a = agg[name]
|
|
50
|
+
a["kind"] = kind
|
|
51
|
+
a["runs"] += 1
|
|
52
|
+
c = r.cost or {}
|
|
53
|
+
a["prompt_tokens"] += int(c.get("prompt_tokens") or 0)
|
|
54
|
+
a["completion_tokens"] += int(c.get("completion_tokens") or 0)
|
|
55
|
+
a["total_tokens"] += int(c.get("total_tokens") or 0)
|
|
56
|
+
a["usd"] += float(c.get("usd") or 0.0)
|
|
57
|
+
|
|
58
|
+
return [{"provider": name, **vals} for name, vals in sorted(agg.items())]
|
harness/bootstrap.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Daemonless local-profile bootstrap: config + SQLite schema + seed.
|
|
2
|
+
|
|
3
|
+
Self-contained (no dependency on the un-packaged ``scripts/`` dir) so it works from an
|
|
4
|
+
installed ``ags`` (``uv tool install``). Invoked unconditionally on app startup
|
|
5
|
+
(``harness/main.py``)."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import select
|
|
12
|
+
|
|
13
|
+
from harness.observability.logging import get_logger
|
|
14
|
+
from harness.settings import HOME_DIR, get_settings
|
|
15
|
+
|
|
16
|
+
log = get_logger("bootstrap")
|
|
17
|
+
|
|
18
|
+
# Default config written to ~/.ags/config.yaml on first run when none exists,
|
|
19
|
+
# so an installed CLI (outside the repo) has working workspaces + providers. Secrets
|
|
20
|
+
# are referenced by env var only, never stored here.
|
|
21
|
+
DEFAULT_CONFIG = """\
|
|
22
|
+
version: 1
|
|
23
|
+
|
|
24
|
+
defaults:
|
|
25
|
+
workspace: default
|
|
26
|
+
routing_policy: single
|
|
27
|
+
|
|
28
|
+
workspaces:
|
|
29
|
+
- slug: default
|
|
30
|
+
name: Default Workspace
|
|
31
|
+
trust_policy: restricted
|
|
32
|
+
default_routing_policy: single
|
|
33
|
+
|
|
34
|
+
# Only the two CLI-backed agents ship by default. Add an OpenAI-compatible
|
|
35
|
+
# endpoint (OpenAI / Groq / OpenRouter / Ollama / vLLM …) later from the GUI's
|
|
36
|
+
# Adapters page, or by adding a provider block here.
|
|
37
|
+
providers:
|
|
38
|
+
- name: claude
|
|
39
|
+
kind: claude_code
|
|
40
|
+
enabled: true
|
|
41
|
+
model: claude-opus-4-8
|
|
42
|
+
secret_ref: env:ANTHROPIC_API_KEY
|
|
43
|
+
params: { bin: claude, permission_mode: plan, timeout_s: 600 }
|
|
44
|
+
|
|
45
|
+
- name: antigravity
|
|
46
|
+
kind: antigravity
|
|
47
|
+
enabled: true
|
|
48
|
+
model: gemini-1.5-pro
|
|
49
|
+
secret_ref: env:ANTIGRAVITY_API_KEY
|
|
50
|
+
params: { bin: agy, pty: true, timeout_s: 600 }
|
|
51
|
+
|
|
52
|
+
routing:
|
|
53
|
+
policies:
|
|
54
|
+
- { name: single, mode: single, primary: claude }
|
|
55
|
+
|
|
56
|
+
memory:
|
|
57
|
+
session_summary_every_n_events: 20
|
|
58
|
+
ttl: { session_volatile_hours: 72, workspace_durable_hours: null }
|
|
59
|
+
retrieval: { top_k: 8, recency_weight: 0.3, relevance_weight: 0.7 }
|
|
60
|
+
|
|
61
|
+
# MCP servers agents may call (tools namespaced mcp__<name>__<tool>). Disabled by
|
|
62
|
+
# default; add `command`/`args` (stdio) or `url` (http) and set enabled: true.
|
|
63
|
+
mcp:
|
|
64
|
+
servers: []
|
|
65
|
+
|
|
66
|
+
# Harness-managed skill library provisioned to spawned CLI agents (claude/agy).
|
|
67
|
+
# The user's project .claude/skills/ load automatically regardless of this.
|
|
68
|
+
skills:
|
|
69
|
+
managed_dir: ~/.ags/skills
|
|
70
|
+
inject_index: true
|
|
71
|
+
bind: { workspaces: [], providers: [claude, antigravity] }
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def ensure_config() -> Path:
|
|
76
|
+
"""Write the default config to ~/.ags/config.yaml if no config exists yet.
|
|
77
|
+
Returns the resolved config path (may be a repo-local config.yaml if present)."""
|
|
78
|
+
settings = get_settings()
|
|
79
|
+
configured = Path(settings.config_file).expanduser()
|
|
80
|
+
if configured.exists():
|
|
81
|
+
return configured
|
|
82
|
+
home_cfg = HOME_DIR / "config.yaml"
|
|
83
|
+
if not home_cfg.exists():
|
|
84
|
+
HOME_DIR.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
home_cfg.write_text(DEFAULT_CONFIG)
|
|
86
|
+
log.info("default_config_written", path=str(home_cfg))
|
|
87
|
+
return home_cfg
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def _seed() -> None:
|
|
91
|
+
"""Seed workspaces + provider configs from config.yaml. Idempotent."""
|
|
92
|
+
from sqlalchemy import delete
|
|
93
|
+
|
|
94
|
+
from harness.db import get_sessionmaker
|
|
95
|
+
from harness.models.enums import McpTransport, TrustPolicy, adapter_kind_from_config
|
|
96
|
+
from harness.models.mcp_server_config import McpServerConfig
|
|
97
|
+
from harness.models.provider_config import ProviderConfig
|
|
98
|
+
from harness.models.workspace import Workspace
|
|
99
|
+
|
|
100
|
+
config = get_settings().config()
|
|
101
|
+
providers = config.get("providers", [])
|
|
102
|
+
mcp_servers = (config.get("mcp") or {}).get("servers", [])
|
|
103
|
+
async with get_sessionmaker()() as db:
|
|
104
|
+
for ws in config.get("workspaces", []):
|
|
105
|
+
exists = (
|
|
106
|
+
await db.execute(select(Workspace).where(Workspace.slug == ws["slug"]))
|
|
107
|
+
).scalar_one_or_none()
|
|
108
|
+
if exists is None:
|
|
109
|
+
db.add(Workspace(
|
|
110
|
+
slug=ws["slug"], name=ws.get("name", ws["slug"]),
|
|
111
|
+
trust_policy=TrustPolicy(ws.get("trust_policy", "restricted")),
|
|
112
|
+
default_routing_policy=ws.get("default_routing_policy", "single"),
|
|
113
|
+
))
|
|
114
|
+
for p in providers:
|
|
115
|
+
row = (
|
|
116
|
+
await db.execute(select(ProviderConfig).where(ProviderConfig.name == p["name"]))
|
|
117
|
+
).scalar_one_or_none()
|
|
118
|
+
if row is None:
|
|
119
|
+
row = ProviderConfig(name=p["name"])
|
|
120
|
+
db.add(row)
|
|
121
|
+
# config.yaml is the declarative source of truth for providers — re-seed
|
|
122
|
+
# updates existing rows too, so editing the file + restarting applies. (A
|
|
123
|
+
# runtime `ags model set` is a transient override until the next restart.)
|
|
124
|
+
# Friendly aliases for the OpenAI-compatible kind so remote endpoints read
|
|
125
|
+
# naturally (`kind: openai`); normalized to the canonical enum, no DB change.
|
|
126
|
+
row.kind = adapter_kind_from_config(p["kind"])
|
|
127
|
+
row.base_url = p.get("base_url")
|
|
128
|
+
row.model = p.get("model")
|
|
129
|
+
row.secret_ref = p.get("secret_ref")
|
|
130
|
+
row.params = p.get("params", {})
|
|
131
|
+
row.capabilities = p.get("capabilities", {})
|
|
132
|
+
row.enabled = p.get("enabled", True)
|
|
133
|
+
|
|
134
|
+
# Reconcile deletions: providers are created ONLY by seeding from config.yaml,
|
|
135
|
+
# so a row no longer in the file should be removed (the config is authoritative).
|
|
136
|
+
# Guard: only prune when the file actually lists providers, so an empty/missing
|
|
137
|
+
# config can't wipe the table. Runs reference providers via SET NULL, so dropping
|
|
138
|
+
# one leaves historical runs intact.
|
|
139
|
+
if providers:
|
|
140
|
+
keep = [p["name"] for p in providers]
|
|
141
|
+
await db.execute(
|
|
142
|
+
delete(ProviderConfig).where(ProviderConfig.name.not_in(keep))
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# MCP servers — same declarative-source-of-truth contract as providers:
|
|
146
|
+
# upsert from config.yaml, then prune rows the file no longer lists.
|
|
147
|
+
for m in mcp_servers:
|
|
148
|
+
row = (
|
|
149
|
+
await db.execute(
|
|
150
|
+
select(McpServerConfig).where(McpServerConfig.name == m["name"])
|
|
151
|
+
)
|
|
152
|
+
).scalar_one_or_none()
|
|
153
|
+
if row is None:
|
|
154
|
+
row = McpServerConfig(name=m["name"])
|
|
155
|
+
db.add(row)
|
|
156
|
+
row.transport = McpTransport(m.get("transport", "stdio"))
|
|
157
|
+
row.command = m.get("command")
|
|
158
|
+
row.args = m.get("args", [])
|
|
159
|
+
row.env = m.get("env", {})
|
|
160
|
+
row.url = m.get("url")
|
|
161
|
+
row.headers = m.get("headers", {})
|
|
162
|
+
row.secret_ref = m.get("secret_ref")
|
|
163
|
+
row.tool_allowlist = m.get("tool_allowlist", [])
|
|
164
|
+
row.bind = m.get("bind", {})
|
|
165
|
+
row.read_only = m.get("read_only", False)
|
|
166
|
+
row.enabled = m.get("enabled", True)
|
|
167
|
+
if mcp_servers:
|
|
168
|
+
keep_mcp = [m["name"] for m in mcp_servers]
|
|
169
|
+
await db.execute(
|
|
170
|
+
delete(McpServerConfig).where(McpServerConfig.name.not_in(keep_mcp))
|
|
171
|
+
)
|
|
172
|
+
await db.commit()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# Alembic has been removed from app startup (harness/main.py no longer runs
|
|
176
|
+
# migrations) and the alembic/ directory itself has been deleted. This function
|
|
177
|
+
# is therefore the SOLE schema-migration mechanism for the SQLite DB: any future
|
|
178
|
+
# schema change must add its own ALTER TABLE entry to `wanted` below.
|
|
179
|
+
async def _reconcile_sqlite_columns(conn) -> None:
|
|
180
|
+
"""Add columns that exist in the ORM but not in an older local DB (SQLite has
|
|
181
|
+
no bundled Alembic on wheel installs; create_all never ALTERs existing tables)."""
|
|
182
|
+
# (table, column, DDL type + default) — extend when new columns ship.
|
|
183
|
+
wanted = [
|
|
184
|
+
("sessions", "native_sessions", "JSON NOT NULL DEFAULT '{}'"),
|
|
185
|
+
("sessions", "settings", "JSON NOT NULL DEFAULT '{}'"),
|
|
186
|
+
("runs", "context_meta", "JSON NOT NULL DEFAULT '{}'"),
|
|
187
|
+
]
|
|
188
|
+
for table, col, ddl in wanted:
|
|
189
|
+
rows = (await conn.exec_driver_sql(f"PRAGMA table_info({table})")).fetchall()
|
|
190
|
+
names = {r[1] for r in rows}
|
|
191
|
+
if rows and col not in names:
|
|
192
|
+
await conn.exec_driver_sql(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def init_local() -> None:
|
|
196
|
+
"""Idempotently prepare the local SQLite instance: config, schema, seed data.
|
|
197
|
+
|
|
198
|
+
Invoked by ``ags init`` and (defensively) on every app startup, so the very
|
|
199
|
+
first ``ags`` command works with zero manual setup."""
|
|
200
|
+
import harness.models # noqa: F401 (register all tables on Base.metadata)
|
|
201
|
+
from harness.db import Base, get_engine
|
|
202
|
+
|
|
203
|
+
HOME_DIR.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
ensure_config()
|
|
205
|
+
# Clear the settings config cache so a freshly written config is picked up.
|
|
206
|
+
get_settings()._config_cache = None
|
|
207
|
+
|
|
208
|
+
engine = get_engine()
|
|
209
|
+
async with engine.begin() as conn:
|
|
210
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
211
|
+
# Reconcile columns missing from an older local DB (create_all never ALTERs
|
|
212
|
+
# existing tables); only applies to SQLite.
|
|
213
|
+
if "sqlite" in str(engine.url):
|
|
214
|
+
await _reconcile_sqlite_columns(conn)
|
|
215
|
+
await _seed()
|
|
216
|
+
log.info("local_db_ready", database_url=get_settings().database_url)
|
harness/db.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Async SQLAlchemy engine/session and declarative base.
|
|
2
|
+
|
|
3
|
+
A single ``AsyncSessionLocal`` factory is shared by the API (via DI) and the
|
|
4
|
+
in-process worker. Models live in ``harness.models`` and register against
|
|
5
|
+
``Base.metadata``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from collections.abc import AsyncIterator
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from sqlalchemy import DateTime, MetaData, event, func
|
|
17
|
+
from sqlalchemy.ext.asyncio import (
|
|
18
|
+
AsyncEngine,
|
|
19
|
+
AsyncSession,
|
|
20
|
+
async_sessionmaker,
|
|
21
|
+
create_async_engine,
|
|
22
|
+
)
|
|
23
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
24
|
+
|
|
25
|
+
from harness.settings import get_settings
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
import sqlite_vec as _sqlite_vec # type: ignore[import]
|
|
29
|
+
except ImportError:
|
|
30
|
+
_sqlite_vec = None # type: ignore[assignment]
|
|
31
|
+
|
|
32
|
+
# Predictable, collision-free constraint names across tables (index/unique/check/
|
|
33
|
+
# foreign-key/primary-key), independent of declaration order.
|
|
34
|
+
NAMING_CONVENTION = {
|
|
35
|
+
"ix": "ix_%(column_0_label)s",
|
|
36
|
+
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
|
37
|
+
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
|
38
|
+
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
|
39
|
+
"pk": "pk_%(table_name)s",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Base(DeclarativeBase):
|
|
44
|
+
"""Declarative base with shared metadata and timestamp mixin columns."""
|
|
45
|
+
|
|
46
|
+
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
|
47
|
+
type_annotation_map: dict[Any, Any] = {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TimestampMixin:
|
|
51
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
52
|
+
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
53
|
+
)
|
|
54
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
55
|
+
DateTime(timezone=True),
|
|
56
|
+
server_default=func.now(),
|
|
57
|
+
onupdate=func.now(),
|
|
58
|
+
nullable=False,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_engine: AsyncEngine | None = None
|
|
63
|
+
_sessionmaker: async_sessionmaker[AsyncSession] | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_engine() -> AsyncEngine:
|
|
67
|
+
global _engine
|
|
68
|
+
if _engine is None:
|
|
69
|
+
settings = get_settings()
|
|
70
|
+
_engine = create_async_engine(
|
|
71
|
+
settings.database_url,
|
|
72
|
+
pool_pre_ping=True,
|
|
73
|
+
future=True,
|
|
74
|
+
)
|
|
75
|
+
_enable_sqlite_pragmas(_engine)
|
|
76
|
+
return _engine
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _enable_sqlite_pragmas(engine: AsyncEngine) -> None:
|
|
80
|
+
"""WAL + foreign keys + a busy timeout on every SQLite connection.
|
|
81
|
+
|
|
82
|
+
WAL lets the SSE reader poll the run_events ledger while the in-process executor
|
|
83
|
+
writes to it (concurrent reader + writer), and the busy timeout absorbs brief
|
|
84
|
+
write-lock contention instead of erroring with 'database is locked'."""
|
|
85
|
+
|
|
86
|
+
@event.listens_for(engine.sync_engine, "connect")
|
|
87
|
+
def _set_pragmas(dbapi_conn, _record): # noqa: ANN001
|
|
88
|
+
cur = dbapi_conn.cursor()
|
|
89
|
+
cur.execute("PRAGMA journal_mode=WAL")
|
|
90
|
+
cur.execute("PRAGMA synchronous=NORMAL")
|
|
91
|
+
cur.execute("PRAGMA foreign_keys=ON")
|
|
92
|
+
cur.execute("PRAGMA busy_timeout=15000")
|
|
93
|
+
cur.close()
|
|
94
|
+
# Load sqlite-vec extension when available; failure is silent so the
|
|
95
|
+
# extension is purely optional — the brute-force path remains active.
|
|
96
|
+
# SQLAlchemy's aiosqlite adapter wraps the raw connection; we must
|
|
97
|
+
# use ``dbapi_conn.run_async`` to call the async aiosqlite methods.
|
|
98
|
+
if _sqlite_vec is not None:
|
|
99
|
+
try:
|
|
100
|
+
_path = _sqlite_vec.loadable_path()
|
|
101
|
+
|
|
102
|
+
async def _load_sqlite_vec(aiosqlite_conn) -> None: # noqa: ANN001
|
|
103
|
+
await aiosqlite_conn.enable_load_extension(True)
|
|
104
|
+
await aiosqlite_conn.load_extension(_path)
|
|
105
|
+
await aiosqlite_conn.enable_load_extension(False)
|
|
106
|
+
|
|
107
|
+
dbapi_conn.run_async(_load_sqlite_vec)
|
|
108
|
+
except Exception: # noqa: BLE001
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
|
|
113
|
+
global _sessionmaker
|
|
114
|
+
if _sessionmaker is None:
|
|
115
|
+
_sessionmaker = async_sessionmaker(
|
|
116
|
+
get_engine(), expire_on_commit=False, class_=AsyncSession
|
|
117
|
+
)
|
|
118
|
+
return _sessionmaker
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Strong refs to in-flight cleanup tasks: asyncio only holds weak refs to tasks, and
|
|
122
|
+
# a GC'd cleanup task would recreate exactly the leak this exists to prevent.
|
|
123
|
+
_cleanup_tasks: set[asyncio.Task] = set()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def _finish_session(session: AsyncSession, *, commit: bool) -> None:
|
|
127
|
+
"""Commit (or roll back) and close, as one unit the request task can't interrupt."""
|
|
128
|
+
try:
|
|
129
|
+
if commit:
|
|
130
|
+
await session.commit()
|
|
131
|
+
else:
|
|
132
|
+
await session.rollback()
|
|
133
|
+
finally:
|
|
134
|
+
await session.close()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def get_db_session() -> AsyncIterator[AsyncSession]:
|
|
138
|
+
"""FastAPI dependency yielding a transactional session.
|
|
139
|
+
|
|
140
|
+
Cleanup runs on its own shielded task. When a client disconnects mid-request
|
|
141
|
+
(SSE abort, GUI Stop), uvicorn cancels the request task and the CancelledError
|
|
142
|
+
can land inside an unshielded commit/rollback — interrupting the greenlet-driven
|
|
143
|
+
connection teardown and orphaning the aiosqlite connection mid-transaction with
|
|
144
|
+
its (thread-bound) sqlite3 handle open. That permanently wedges SQLite's single
|
|
145
|
+
write lock: every later write fails with 'database is locked' until restart.
|
|
146
|
+
SQLAlchemy shields ``AsyncSession.__aexit__`` close, but not commit/rollback —
|
|
147
|
+
so the whole finish sequence is moved off the request task here.
|
|
148
|
+
"""
|
|
149
|
+
session = get_sessionmaker()()
|
|
150
|
+
commit = True
|
|
151
|
+
try:
|
|
152
|
+
yield session
|
|
153
|
+
except BaseException:
|
|
154
|
+
commit = False
|
|
155
|
+
raise
|
|
156
|
+
finally:
|
|
157
|
+
cleanup = asyncio.ensure_future(_finish_session(session, commit=commit))
|
|
158
|
+
_cleanup_tasks.add(cleanup)
|
|
159
|
+
cleanup.add_done_callback(_cleanup_tasks.discard)
|
|
160
|
+
with suppress(asyncio.CancelledError):
|
|
161
|
+
# If the request is already cancelled this await raises immediately,
|
|
162
|
+
# but the cleanup task keeps running on the loop and still releases
|
|
163
|
+
# the connection. Real commit/rollback errors propagate to the caller.
|
|
164
|
+
await asyncio.shield(cleanup)
|
harness/deps.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""FastAPI dependency-injection providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
from fastapi import Depends, Header, HTTPException, status
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
11
|
+
from harness.db import get_db_session
|
|
12
|
+
from harness.eventbus import get_event_bus
|
|
13
|
+
from harness.eventbus.inprocess_bus import InProcessEventBus
|
|
14
|
+
from harness.security.auth import AuthProvider, Principal, get_auth_provider
|
|
15
|
+
from harness.settings import Settings, get_settings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def db_session() -> AsyncIterator[AsyncSession]:
|
|
19
|
+
async for s in get_db_session():
|
|
20
|
+
yield s
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def settings_dep() -> Settings:
|
|
24
|
+
return get_settings()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def auth_dep() -> AuthProvider:
|
|
28
|
+
return get_auth_provider()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def event_bus_dep() -> InProcessEventBus:
|
|
32
|
+
return get_event_bus()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
DbSession = Annotated[AsyncSession, Depends(db_session)]
|
|
36
|
+
SettingsDep = Annotated[Settings, Depends(settings_dep)]
|
|
37
|
+
AuthDep = Annotated[AuthProvider, Depends(auth_dep)]
|
|
38
|
+
EventBusDep = Annotated[InProcessEventBus, Depends(event_bus_dep)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def current_principal(
|
|
42
|
+
auth: AuthDep,
|
|
43
|
+
authorization: Annotated[str | None, Header()] = None,
|
|
44
|
+
) -> Principal:
|
|
45
|
+
"""Optional bearer-token auth. In dev the local provider is permissive about
|
|
46
|
+
missing tokens to keep the CLI frictionless; enable enforcement in prod by
|
|
47
|
+
requiring a token here."""
|
|
48
|
+
settings = get_settings()
|
|
49
|
+
if authorization and authorization.lower().startswith("bearer "):
|
|
50
|
+
principal = auth.verify_token(authorization.split(" ", 1)[1])
|
|
51
|
+
if principal:
|
|
52
|
+
return principal
|
|
53
|
+
if settings.env == "prod":
|
|
54
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "authentication required")
|
|
55
|
+
return Principal(subject="dev", roles=["admin", "user"])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
CurrentPrincipal = Annotated[Principal, Depends(current_principal)]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Event bus factory.
|
|
2
|
+
|
|
3
|
+
In-process buffered bus: single process; the SSE endpoint subscribes for low-latency
|
|
4
|
+
live streaming, with the ledger as the durable backstop."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
__all__ = ["get_event_bus"]
|
|
9
|
+
|
|
10
|
+
_bus = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_event_bus():
|
|
14
|
+
"""Cached event bus singleton."""
|
|
15
|
+
global _bus
|
|
16
|
+
if _bus is None:
|
|
17
|
+
from harness.eventbus.inprocess_bus import InProcessEventBus
|
|
18
|
+
|
|
19
|
+
_bus = InProcessEventBus()
|
|
20
|
+
return _bus
|