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,33 @@
1
+ """Deterministic handoff block injected when a session switches provider."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ _WRITE_TOOLS = ("write_file", "replace_in_file")
7
+ _TOOL_RE = re.compile(r"ran tool '([^']+)' with .*?'path': '([^']+)'")
8
+ _MAX_RECENT = 12
9
+
10
+
11
+ def files_touched_from_blocks(blocks: list[str]) -> list[str]:
12
+ seen: dict[str, None] = {}
13
+ for b in blocks:
14
+ m = _TOOL_RE.search(b)
15
+ if m and m.group(1) in _WRITE_TOOLS:
16
+ seen.setdefault(m.group(2))
17
+ return list(seen)
18
+
19
+
20
+ def build_handoff_block(
21
+ *, from_provider: str, summary: str,
22
+ recent_blocks: list[str], files_touched: list[str],
23
+ ) -> str:
24
+ parts = [f"## Handoff from {from_provider}",
25
+ "You are taking over an in-progress task from another agent."]
26
+ if summary:
27
+ parts += ["", "### Task state", summary]
28
+ if files_touched:
29
+ parts += ["", "### Files modified so far", *[f"- {f}" for f in files_touched]]
30
+ if recent_blocks:
31
+ parts += ["", "### Most recent turns", *recent_blocks[-_MAX_RECENT:]]
32
+ parts += ["", "Continue the task; do not restart work that is already done."]
33
+ return "\n".join(parts)
@@ -0,0 +1,69 @@
1
+ """Run lifecycle helpers: retries with backoff, timeouts, and cancellation flags.
2
+
3
+ Cancellation is cooperative: the worker checks ``is_cancelled(run_id)`` between
4
+ events using a process-local set (API and executor share one process)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ from collections.abc import Awaitable, Callable
10
+ from typing import TypeVar
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ async def run_with_timeout(coro: Awaitable[T], timeout_s: float) -> T:
16
+ return await asyncio.wait_for(coro, timeout=timeout_s)
17
+
18
+
19
+ async def retry_async(
20
+ fn: Callable[[], Awaitable[T]],
21
+ *,
22
+ attempts: int = 3,
23
+ base_delay: float = 0.5,
24
+ max_delay: float = 8.0,
25
+ retriable: tuple[type[BaseException], ...] = (Exception,),
26
+ ) -> T:
27
+ last: BaseException | None = None
28
+ for i in range(attempts):
29
+ try:
30
+ return await fn()
31
+ except retriable as exc: # noqa: PERF203
32
+ last = exc
33
+ if i == attempts - 1:
34
+ break
35
+ await asyncio.sleep(min(base_delay * (2**i), max_delay))
36
+ assert last is not None
37
+ raise last
38
+
39
+
40
+ class InMemoryCancellationRegistry:
41
+ """Process-local cancellation for this daemonless setup. A module-level
42
+ singleton (``get_canceller``) makes the flag visible to both the cancel endpoint
43
+ and the in-process executor, which share one event loop."""
44
+
45
+ def __init__(self) -> None:
46
+ self._cancelled: set[str] = set()
47
+
48
+ async def request_cancel(self, run_id: str) -> None:
49
+ self._cancelled.add(run_id)
50
+
51
+ async def is_cancelled(self, run_id: str) -> bool:
52
+ return run_id in self._cancelled
53
+
54
+ async def clear(self, run_id: str) -> None:
55
+ self._cancelled.discard(run_id)
56
+
57
+ async def aclose(self) -> None:
58
+ return None
59
+
60
+
61
+ _local_canceller: InMemoryCancellationRegistry | None = None
62
+
63
+
64
+ def get_canceller():
65
+ """Cancellation registry: process-local in-memory store."""
66
+ global _local_canceller
67
+ if _local_canceller is None:
68
+ _local_canceller = InMemoryCancellationRegistry()
69
+ return _local_canceller
@@ -0,0 +1,93 @@
1
+ """Decide when a node should resume its provider's native session.
2
+
3
+ Same-provider consecutive turns resume in place (the CLI keeps its own context,
4
+ so the harness skips transcript re-injection). A provider switch starts a fresh
5
+ native session and triggers a structured handoff block instead.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+ from harness.models.enums import AdapterKind
12
+
13
+ # Providers whose native-resume flag is verified. Antigravity joins via the
14
+ # capability probe (Task 12) or an explicit `params.native_sessions: true`.
15
+ _DEFAULT_ON = {AdapterKind.claude_code}
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ResumeDecision:
20
+ native_id: str | None
21
+ is_switch: bool
22
+
23
+
24
+ def decide_resume(
25
+ *,
26
+ provider_name: str,
27
+ adapter_kind: AdapterKind,
28
+ native_sessions: dict,
29
+ last_provider: str | None,
30
+ params: dict,
31
+ probed_flags: set[str] | None = None,
32
+ ) -> ResumeDecision:
33
+ is_switch = last_provider is not None and last_provider != provider_name
34
+ native_id = (native_sessions or {}).get(provider_name)
35
+
36
+ # Determine whether native-resume is enabled for this adapter.
37
+ explicit = params.get("native_sessions") # None if not set, True/False if set
38
+ if explicit is not None:
39
+ # Explicit params.native_sessions always wins (both ways).
40
+ enabled = bool(explicit)
41
+ elif adapter_kind == AdapterKind.antigravity:
42
+ # Antigravity: auto-enable when the CLI advertises --conversation, otherwise off.
43
+ # Explicit params.native_sessions overrides (handled above).
44
+ enabled = probed_flags is not None and "--conversation" in probed_flags
45
+ else:
46
+ # All other adapters: use the _DEFAULT_ON set.
47
+ enabled = adapter_kind in _DEFAULT_ON
48
+
49
+ if not enabled or is_switch or last_provider is None:
50
+ return ResumeDecision(native_id=None, is_switch=is_switch)
51
+ return ResumeDecision(native_id=native_id, is_switch=False)
52
+
53
+
54
+ def context_meta_for(
55
+ *,
56
+ resume: ResumeDecision,
57
+ node_memory: str,
58
+ transcript_blocks: list[str],
59
+ window: int,
60
+ ) -> dict:
61
+ """Build the context_meta dict to persist on a Run row.
62
+
63
+ Encodes the resume mode decision and the key sizing stats that drove the
64
+ injected context for this node. Purely a data-shape helper — no I/O.
65
+
66
+ Mode mapping:
67
+ native_id set → "native" (same-provider, resumed in place)
68
+ is_switch → "handoff" (provider switch, structured handoff block)
69
+ else → "replay" (first turn or non-resumable provider)
70
+ """
71
+ if resume.native_id is not None:
72
+ mode = "native"
73
+ elif resume.is_switch:
74
+ mode = "handoff"
75
+ else:
76
+ mode = "replay"
77
+
78
+ return {
79
+ "resume": mode,
80
+ "memory_chars": len(node_memory),
81
+ "transcript_blocks": len(transcript_blocks),
82
+ "window": window,
83
+ }
84
+
85
+
86
+ def extract_native_id(events: list, adapter_kind: AdapterKind) -> str | None:
87
+ """Pull the provider-native session id out of a run's normalized events."""
88
+ for ev in events:
89
+ if getattr(ev, "type", None) == "adapter_meta":
90
+ nsid = (ev.data or {}).get("session_id") or (ev.data or {}).get("conversation_id")
91
+ if nsid:
92
+ return str(nsid)
93
+ return None
@@ -0,0 +1,101 @@
1
+ """Routing & execution policies.
2
+
3
+ A policy compiles a task into a **run graph**: a set of ``RunNodeSpec`` nodes
4
+ (each = one provider execution) with dependencies and a role. The engine executes
5
+ independent nodes concurrently and respects dependencies. Policies are declared in
6
+ ``config.yaml`` under ``routing.policies`` and resolved here.
7
+
8
+ Supported modes: single, fanout, fallback, judge_select, propose_merge,
9
+ cheap_first_escalate, local_first_escalate.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+
16
+ from harness.models.enums import RoutingMode, RunRole
17
+
18
+
19
+ @dataclass
20
+ class RunNodeSpec:
21
+ node_id: str
22
+ provider: str
23
+ role: RunRole = RunRole.primary
24
+ depends_on: list[str] = field(default_factory=list)
25
+
26
+
27
+ @dataclass
28
+ class MergeSpec:
29
+ strategy: str = "merge" # merge | judge | manual
30
+ by: str | None = None # provider that performs the merge, if any
31
+ persist: bool = False # persist merged result as workspace memory
32
+
33
+
34
+ @dataclass
35
+ class CompiledPolicy:
36
+ name: str
37
+ mode: RoutingMode
38
+ nodes: list[RunNodeSpec]
39
+ merge: MergeSpec | None = None
40
+ escalate_to: str | None = None
41
+
42
+
43
+ def _role(value: str | None) -> RunRole:
44
+ try:
45
+ return RunRole(value) if value else RunRole.primary
46
+ except ValueError:
47
+ return RunRole.primary
48
+
49
+
50
+ def compile_policy(
51
+ spec: dict,
52
+ *,
53
+ override_providers: list[str] | None = None,
54
+ ) -> CompiledPolicy:
55
+ """Compile a routing policy dict (from config) into a CompiledPolicy.
56
+
57
+ ``override_providers`` lets the CLI/API force a provider set for ``single`` /
58
+ ``fanout`` (e.g. ``run start --providers mock,local``)."""
59
+ name = spec.get("name", "single")
60
+ mode = RoutingMode(spec.get("mode", "single"))
61
+ merge = None
62
+ if (m := spec.get("merge")) is not None:
63
+ merge = MergeSpec(
64
+ strategy=m.get("strategy", "merge"),
65
+ by=m.get("by"),
66
+ persist=bool(m.get("persist", False)),
67
+ )
68
+
69
+ # Explicit node graph wins.
70
+ if spec.get("nodes"):
71
+ nodes = [
72
+ RunNodeSpec(
73
+ node_id=n.get("id") or f"{n['provider']}-{i}",
74
+ provider=n["provider"],
75
+ role=_role(n.get("role")),
76
+ depends_on=list(n.get("depends_on", [])),
77
+ )
78
+ for i, n in enumerate(spec["nodes"])
79
+ ]
80
+ return CompiledPolicy(name, mode, nodes, merge, spec.get("escalate_to"))
81
+
82
+ # Otherwise derive from mode + primary/override.
83
+ if override_providers:
84
+ providers = override_providers
85
+ elif mode in (RoutingMode.single, RoutingMode.local_first_escalate,
86
+ RoutingMode.cheap_first_escalate, RoutingMode.fallback):
87
+ providers = [spec.get("primary", "local")]
88
+ else:
89
+ providers = [spec.get("primary", "local")]
90
+
91
+ nodes = [
92
+ RunNodeSpec(node_id=f"{p}-{i}", provider=p, role=RunRole.primary)
93
+ for i, p in enumerate(providers)
94
+ ]
95
+ return CompiledPolicy(name, mode, nodes, merge, spec.get("escalate_to"))
96
+
97
+
98
+ def load_policy_specs(config: dict) -> dict[str, dict]:
99
+ """Index routing policy specs from config by name."""
100
+ policies = (config.get("routing") or {}).get("policies", [])
101
+ return {p["name"]: p for p in policies}
@@ -0,0 +1,39 @@
1
+ """Mark runs orphaned by a process crash/restart as failed, at startup."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import UTC, datetime
5
+
6
+ from sqlalchemy import select, update
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from harness.models.enums import RunStatus, SessionStatus
10
+ from harness.models.run import Run
11
+ from harness.models.session import Session
12
+ from harness.observability.logging import get_logger
13
+
14
+ log = get_logger("reconcile")
15
+ _ORPHAN = (RunStatus.queued, RunStatus.running, RunStatus.streaming)
16
+
17
+
18
+ async def reconcile_interrupted(db: AsyncSession) -> int:
19
+ orphaned = (await db.execute(select(Run).where(Run.status.in_(_ORPHAN)))).scalars().all()
20
+ for r in orphaned:
21
+ r.status = RunStatus.failed
22
+ r.error = "interrupted by server restart"
23
+ r.finished_at = datetime.now(UTC)
24
+ await db.flush()
25
+
26
+ # Sessions parked waiting on a human approval are not orphaned — exclude
27
+ # them so a restart doesn't fail a run sitting in needs_approval.
28
+ parked_session_ids = (
29
+ await db.execute(
30
+ select(Run.session_id).where(Run.status == RunStatus.needs_approval).distinct()
31
+ )
32
+ ).scalars().all()
33
+ stmt = update(Session).where(Session.status == SessionStatus.running)
34
+ if parked_session_ids:
35
+ stmt = stmt.where(Session.id.notin_(parked_session_ids))
36
+ await db.execute(stmt.values(status=SessionStatus.failed))
37
+ if orphaned:
38
+ log.warning("reconciled_interrupted_runs", count=len(orphaned))
39
+ return len(orphaned)
@@ -0,0 +1,62 @@
1
+ """Run graph: a DAG over ``RunNodeSpec`` with topological-level scheduling.
2
+
3
+ The engine asks the graph for successive "waves" of nodes whose dependencies are
4
+ all satisfied, executing each wave concurrently. Cycles are rejected at build."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+
10
+ from harness.orchestrator.policies import RunNodeSpec
11
+
12
+
13
+ @dataclass
14
+ class RunGraph:
15
+ nodes: dict[str, RunNodeSpec]
16
+
17
+ @classmethod
18
+ def from_specs(cls, specs: list[RunNodeSpec]) -> RunGraph:
19
+ nodes = {s.node_id: s for s in specs}
20
+ # Validate dependencies exist and the graph is acyclic.
21
+ for s in specs:
22
+ for dep in s.depends_on:
23
+ if dep not in nodes:
24
+ raise ValueError(f"node {s.node_id!r} depends on unknown {dep!r}")
25
+ graph = cls(nodes=nodes)
26
+ graph._assert_acyclic()
27
+ return graph
28
+
29
+ def _assert_acyclic(self) -> None:
30
+ WHITE, GRAY, BLACK = 0, 1, 2
31
+ color = dict.fromkeys(self.nodes, WHITE)
32
+
33
+ def visit(nid: str) -> None:
34
+ color[nid] = GRAY
35
+ for dep in self.nodes[nid].depends_on:
36
+ if color[dep] == GRAY:
37
+ raise ValueError(f"cycle detected at {nid!r} -> {dep!r}")
38
+ if color[dep] == WHITE:
39
+ visit(dep)
40
+ color[nid] = BLACK
41
+
42
+ for nid in self.nodes:
43
+ if color[nid] == WHITE:
44
+ visit(nid)
45
+
46
+ def waves(self) -> list[list[RunNodeSpec]]:
47
+ """Return execution waves: each inner list can run concurrently."""
48
+ remaining = dict(self.nodes)
49
+ done: set[str] = set()
50
+ result: list[list[RunNodeSpec]] = []
51
+ while remaining:
52
+ ready = [
53
+ s for s in remaining.values()
54
+ if all(dep in done for dep in s.depends_on)
55
+ ]
56
+ if not ready: # pragma: no cover — guarded by _assert_acyclic
57
+ raise ValueError("deadlock: no ready nodes but graph non-empty")
58
+ result.append(ready)
59
+ for s in ready:
60
+ done.add(s.node_id)
61
+ del remaining[s.node_id]
62
+ return result
@@ -0,0 +1,49 @@
1
+ """Non-invasive git snapshot of a project tree before a write-mode run.
2
+
3
+ Uses a throwaway index (GIT_INDEX_FILE) so the user's staging area and HEAD
4
+ are never touched. Fails soft: any git error returns None."""
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import os
9
+ import subprocess
10
+ import tempfile
11
+ from pathlib import Path
12
+
13
+ from harness.observability.logging import get_logger
14
+
15
+ log = get_logger("snapshot")
16
+
17
+
18
+ def _snapshot_sync(project_dir: str) -> str | None:
19
+ root = Path(project_dir)
20
+ if not (root / ".git").exists():
21
+ return None
22
+ with tempfile.NamedTemporaryFile(prefix="ags-idx-") as idx:
23
+ env = {**os.environ, "GIT_INDEX_FILE": idx.name}
24
+
25
+ def git(*args: str) -> str:
26
+ r = subprocess.run(["git", *args], shell=(os.name == "nt"), cwd=root, env=env,
27
+ capture_output=True, text=True, timeout=60)
28
+ if r.returncode != 0:
29
+ raise RuntimeError(r.stderr.strip())
30
+ return r.stdout.strip()
31
+
32
+ try:
33
+ head = git("rev-parse", "--verify", "HEAD")
34
+ except RuntimeError:
35
+ head = None # empty repo: snapshot with no parent
36
+ try:
37
+ if head:
38
+ git("read-tree", head)
39
+ git("add", "-A", ".")
40
+ tree = git("write-tree")
41
+ parent = ["-p", head] if head else []
42
+ return git("commit-tree", tree, *parent, "-m", "ags pre-run snapshot")
43
+ except (RuntimeError, subprocess.TimeoutExpired) as exc:
44
+ log.warning("git_snapshot_failed", error=str(exc))
45
+ return None
46
+
47
+
48
+ async def git_snapshot(project_dir: str) -> str | None:
49
+ return await asyncio.to_thread(_snapshot_sync, project_dir)
@@ -0,0 +1 @@
1
+ """Pydantic request/response DTOs (transport schemas) — decoupled from ORM models."""
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from harness.models.enums import AdapterKind
8
+
9
+
10
+ class AdapterOut(BaseModel):
11
+ id: uuid.UUID
12
+ name: str
13
+ kind: AdapterKind
14
+ model: str | None = None
15
+ base_url: str | None = None
16
+ enabled: bool
17
+ capabilities: dict
18
+ managed: bool = False # added via the GUI (overlay), so deletable from the GUI
19
+
20
+
21
+ class AdapterTestResult(BaseModel):
22
+ name: str
23
+ kind: AdapterKind
24
+ ok: bool
25
+ latency_ms: float
26
+ detail: dict
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+
6
+ from pydantic import BaseModel, ConfigDict
7
+
8
+ from harness.models.enums import ApprovalActionClass, ApprovalStatus
9
+
10
+
11
+ class ApprovalOut(BaseModel):
12
+ id: uuid.UUID
13
+ session_id: uuid.UUID
14
+ run_id: uuid.UUID | None = None
15
+ action_class: ApprovalActionClass
16
+ payload: dict
17
+ status: ApprovalStatus
18
+ decided_by: str | None = None
19
+ decided_at: datetime | None = None
20
+
21
+ model_config = ConfigDict(from_attributes=True)
22
+
23
+
24
+ class ApprovalDecideRequest(BaseModel):
25
+ decision: str # "approved" or "denied"
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+
5
+ from harness.models.enums import ArtifactKind
6
+ from harness.schemas.common import IdModel
7
+
8
+
9
+ class ArtifactOut(IdModel):
10
+ workspace_id: uuid.UUID
11
+ session_id: uuid.UUID | None = None
12
+ run_id: uuid.UUID | None = None
13
+ kind: ArtifactKind
14
+ uri: str
15
+ sha256: str
16
+ size: int
17
+ meta: dict
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+
6
+ from pydantic import BaseModel, ConfigDict
7
+
8
+
9
+ class ORMModel(BaseModel):
10
+ model_config = ConfigDict(from_attributes=True)
11
+
12
+
13
+ class IdModel(ORMModel):
14
+ id: uuid.UUID
15
+ created_at: datetime | None = None
16
+ updated_at: datetime | None = None
17
+
18
+
19
+ class Message(BaseModel):
20
+ message: str
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from harness.models.enums import MemoryScope, MemoryType
8
+
9
+
10
+ class MemoryIngestRequest(BaseModel):
11
+ workspace: str = "default"
12
+ content: str
13
+ type: MemoryType = MemoryType.fact
14
+ scope: MemoryScope = MemoryScope.workspace
15
+ session_id: str | None = None
16
+ project: str | None = None
17
+ tags: list[str] = Field(default_factory=list)
18
+
19
+
20
+ class MemoryItemOut(BaseModel):
21
+ id: uuid.UUID
22
+ namespace: str
23
+ scope: MemoryScope
24
+ type: MemoryType
25
+ content: str
26
+ tags: list[str]
27
+ redacted: bool
28
+
29
+
30
+ class MemorySearchRequest(BaseModel):
31
+ workspace: str = "default"
32
+ query: str
33
+ session_id: str | None = None
34
+ project: str | None = None
35
+ top_k: int = 8
36
+
37
+
38
+ class MemoryHit(BaseModel):
39
+ item_id: str
40
+ namespace: str
41
+ type: str
42
+ content: str
43
+ similarity: float
44
+ score: float
45
+
46
+
47
+ class MemorySearchResponse(BaseModel):
48
+ query: str
49
+ hits: list[MemoryHit]
50
+ context_block: str
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from harness.models.enums import ApprovalStatus, PolicyKind
8
+
9
+
10
+ class PolicySetRequest(BaseModel):
11
+ workspace: str = "default"
12
+ name: str
13
+ kind: PolicyKind
14
+ spec: dict = Field(default_factory=dict)
15
+ priority: int = 0
16
+
17
+
18
+ class PolicyOut(BaseModel):
19
+ id: uuid.UUID
20
+ workspace_id: uuid.UUID
21
+ name: str
22
+ kind: PolicyKind
23
+ spec: dict
24
+ priority: int
25
+
26
+
27
+ class ApprovalDecision(BaseModel):
28
+ status: ApprovalStatus
29
+ decided_by: str = "cli"
30
+
31
+
32
+ class ApprovalOut(BaseModel):
33
+ id: uuid.UUID
34
+ session_id: uuid.UUID
35
+ run_id: uuid.UUID | None = None
36
+ action_class: str
37
+ payload: dict
38
+ status: ApprovalStatus
39
+ decided_by: str | None = None