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,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")
@@ -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)
@@ -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