ags-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ags_cli-0.1.0.dist-info/METADATA +332 -0
- ags_cli-0.1.0.dist-info/RECORD +156 -0
- ags_cli-0.1.0.dist-info/WHEEL +5 -0
- ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ags_cli-0.1.0.dist-info/top_level.txt +2 -0
- cli/__init__.py +0 -0
- cli/__main__.py +4 -0
- cli/client.py +145 -0
- cli/commands/__init__.py +0 -0
- cli/commands/adapter.py +33 -0
- cli/commands/artifact.py +21 -0
- cli/commands/ask.py +212 -0
- cli/commands/chat.py +649 -0
- cli/commands/diff.py +49 -0
- cli/commands/doctor.py +93 -0
- cli/commands/gui.py +45 -0
- cli/commands/init.py +20 -0
- cli/commands/mcp.py +50 -0
- cli/commands/memory.py +65 -0
- cli/commands/model.py +73 -0
- cli/commands/policy.py +52 -0
- cli/commands/project.py +122 -0
- cli/commands/quota.py +107 -0
- cli/commands/run.py +84 -0
- cli/commands/serve.py +171 -0
- cli/commands/service.py +236 -0
- cli/commands/session.py +219 -0
- cli/commands/stats.py +96 -0
- cli/commands/status.py +36 -0
- cli/commands/task.py +37 -0
- cli/commands/usage.py +86 -0
- cli/local.py +84 -0
- cli/main.py +149 -0
- harness/__init__.py +4 -0
- harness/adapters/__init__.py +26 -0
- harness/adapters/antigravity.py +301 -0
- harness/adapters/claude_code.py +522 -0
- harness/adapters/local_openai.py +534 -0
- harness/adapters/mock.py +112 -0
- harness/adapters/probe.py +65 -0
- harness/adapters/registry.py +56 -0
- harness/adapters/warm_pool.py +255 -0
- harness/api/__init__.py +0 -0
- harness/api/router.py +38 -0
- harness/api/v1/__init__.py +0 -0
- harness/api/v1/adapters.py +105 -0
- harness/api/v1/approvals.py +173 -0
- harness/api/v1/artifacts.py +44 -0
- harness/api/v1/attachments.py +48 -0
- harness/api/v1/doctor.py +22 -0
- harness/api/v1/health.py +37 -0
- harness/api/v1/mcp.py +131 -0
- harness/api/v1/memory.py +80 -0
- harness/api/v1/policies.py +49 -0
- harness/api/v1/project.py +302 -0
- harness/api/v1/runs.py +98 -0
- harness/api/v1/sessions.py +248 -0
- harness/api/v1/stream.py +110 -0
- harness/api/v1/tasks.py +30 -0
- harness/api/v1/usage.py +58 -0
- harness/bootstrap.py +216 -0
- harness/db.py +164 -0
- harness/deps.py +58 -0
- harness/eventbus/__init__.py +20 -0
- harness/eventbus/inprocess_bus.py +85 -0
- harness/main.py +144 -0
- harness/mcp/__init__.py +22 -0
- harness/mcp/client.py +139 -0
- harness/mcp/config_gen.py +77 -0
- harness/mcp/registry.py +156 -0
- harness/mcp/tools.py +81 -0
- harness/memory/__init__.py +13 -0
- harness/memory/context_budget.py +116 -0
- harness/memory/embed.py +217 -0
- harness/memory/extract.py +74 -0
- harness/memory/ingest.py +106 -0
- harness/memory/namespaces.py +57 -0
- harness/memory/ranking.py +31 -0
- harness/memory/redact.py +37 -0
- harness/memory/service.py +320 -0
- harness/memory/sqlite_vec_backend.py +266 -0
- harness/memory/summarize.py +68 -0
- harness/models/__init__.py +35 -0
- harness/models/adapter_health.py +27 -0
- harness/models/approval.py +37 -0
- harness/models/artifact.py +35 -0
- harness/models/audit_log.py +33 -0
- harness/models/comparison.py +33 -0
- harness/models/enums.py +146 -0
- harness/models/mcp_server_config.py +49 -0
- harness/models/memory.py +76 -0
- harness/models/policy.py +29 -0
- harness/models/provider_config.py +35 -0
- harness/models/run.py +46 -0
- harness/models/run_event.py +35 -0
- harness/models/session.py +50 -0
- harness/models/task.py +30 -0
- harness/models/types.py +63 -0
- harness/models/workspace.py +27 -0
- harness/observability/__init__.py +4 -0
- harness/observability/audit.py +88 -0
- harness/observability/logging.py +47 -0
- harness/observability/metrics.py +43 -0
- harness/observability/tracing.py +38 -0
- harness/orchestrator/__init__.py +19 -0
- harness/orchestrator/engine.py +821 -0
- harness/orchestrator/handoff.py +33 -0
- harness/orchestrator/lifecycle.py +69 -0
- harness/orchestrator/native_resume.py +93 -0
- harness/orchestrator/policies.py +101 -0
- harness/orchestrator/reconcile.py +39 -0
- harness/orchestrator/run_graph.py +62 -0
- harness/orchestrator/snapshot.py +49 -0
- harness/schemas/__init__.py +1 -0
- harness/schemas/adapter.py +26 -0
- harness/schemas/approval.py +25 -0
- harness/schemas/artifact.py +17 -0
- harness/schemas/common.py +20 -0
- harness/schemas/memory.py +50 -0
- harness/schemas/policy.py +39 -0
- harness/schemas/run.py +116 -0
- harness/schemas/session.py +93 -0
- harness/schemas/task.py +24 -0
- harness/security/__init__.py +5 -0
- harness/security/approval_policy.py +107 -0
- harness/security/auth.py +106 -0
- harness/security/permissions.py +59 -0
- harness/security/risk.py +59 -0
- harness/security/secrets.py +49 -0
- harness/services/__init__.py +1 -0
- harness/services/adapters_health.py +212 -0
- harness/services/approvals.py +84 -0
- harness/services/artifacts.py +78 -0
- harness/services/attachments.py +228 -0
- harness/services/comparison.py +345 -0
- harness/services/doctor.py +156 -0
- harness/services/files.py +287 -0
- harness/services/mcp_servers.py +179 -0
- harness/services/project_git.py +101 -0
- harness/services/retention.py +97 -0
- harness/services/runs.py +155 -0
- harness/services/runs_diff.py +201 -0
- harness/services/sessions.py +242 -0
- harness/services/ssh.py +300 -0
- harness/services/stats.py +132 -0
- harness/services/tasks.py +41 -0
- harness/services/workspaces.py +184 -0
- harness/services/worktrees.py +439 -0
- harness/settings.py +186 -0
- harness/skills/__init__.py +11 -0
- harness/skills/manager.py +141 -0
- harness/tools/__init__.py +1 -0
- harness/tools/fs_tools.py +295 -0
- harness/workers/__init__.py +0 -0
- harness/workers/dispatch.py +26 -0
- harness/workers/inprocess.py +135 -0
|
@@ -0,0 +1,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
|