devvy 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.
- cli/__init__.py +1 -0
- cli/__main__.py +9 -0
- cli/ado_client.py +212 -0
- cli/commands/__init__.py +1 -0
- cli/commands/init.py +107 -0
- cli/commands/logs.py +36 -0
- cli/commands/ps.py +72 -0
- cli/commands/resume.py +105 -0
- cli/commands/run.py +135 -0
- cli/commands/status.py +35 -0
- cli/config.py +69 -0
- cli/db.py +138 -0
- cli/fsm.py +87 -0
- cli/local_runner/__init__.py +85 -0
- cli/local_runner/credentials.py +101 -0
- cli/local_runner/docker_primitives.py +142 -0
- cli/local_runner/repo_detection.py +182 -0
- cli/local_runner/validation.py +203 -0
- cli/local_runner/workspace.py +295 -0
- cli/main.py +22 -0
- cli/models.py +83 -0
- cli/orchestrator.py +530 -0
- cli/prompts.py +42 -0
- cli/ui/__init__.py +55 -0
- cli/ui/picker.py +179 -0
- cli/ui/rendering.py +350 -0
- devvy-0.1.0.dist-info/METADATA +260 -0
- devvy-0.1.0.dist-info/RECORD +31 -0
- devvy-0.1.0.dist-info/WHEEL +4 -0
- devvy-0.1.0.dist-info/entry_points.txt +2 -0
- devvy-0.1.0.dist-info/licenses/LICENSE +21 -0
cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI package."""
|
cli/__main__.py
ADDED
cli/ado_client.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Azure DevOps REST API helpers for the CLI orchestrator.
|
|
2
|
+
|
|
3
|
+
All functions are module-level and stateless — they receive credentials
|
|
4
|
+
explicitly so they can be used independently of the Orchestrator class and
|
|
5
|
+
tested in isolation.
|
|
6
|
+
|
|
7
|
+
Auth: ADO uses HTTP Basic with an empty username and a PAT as the password,
|
|
8
|
+
Base64-encoded as ``:<pat>``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_ADO_API_VERSION = "7.1"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def ado_headers(pat: str) -> dict[str, str]:
|
|
23
|
+
token = base64.b64encode(f":{pat}".encode()).decode()
|
|
24
|
+
return {"Authorization": f"Basic {token}", "Content-Type": "application/json"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def extract_repo_name(repo_url: str) -> str:
|
|
28
|
+
return urlparse(repo_url).path.rstrip("/").split("/")[-1]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def api_base(org_url: str, project: str, repo_name: str) -> str:
|
|
32
|
+
return f"{org_url.rstrip('/')}/{project}/_apis/git/repositories/{repo_name}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def get_existing_pr(
|
|
36
|
+
repo_url: str,
|
|
37
|
+
branch: str,
|
|
38
|
+
ado_org_url: str,
|
|
39
|
+
ado_project: str,
|
|
40
|
+
ado_pat: str,
|
|
41
|
+
) -> tuple[int, str] | None:
|
|
42
|
+
"""Look up an open PR for *branch* in ADO.
|
|
43
|
+
|
|
44
|
+
Returns (pr_number, pr_url) if one is found, otherwise None.
|
|
45
|
+
"""
|
|
46
|
+
repo_name = extract_repo_name(repo_url)
|
|
47
|
+
url = (
|
|
48
|
+
f"{api_base(ado_org_url, ado_project, repo_name)}/pullrequests"
|
|
49
|
+
f"?sourceRefName=refs/heads/{branch}&status=active&api-version={_ADO_API_VERSION}"
|
|
50
|
+
)
|
|
51
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
52
|
+
resp = await client.get(url, headers=ado_headers(ado_pat))
|
|
53
|
+
resp.raise_for_status()
|
|
54
|
+
data = resp.json()
|
|
55
|
+
prs = data.get("value", [])
|
|
56
|
+
if not prs:
|
|
57
|
+
return None
|
|
58
|
+
pr = prs[0]
|
|
59
|
+
pr_number = pr["pullRequestId"]
|
|
60
|
+
pr_url = f"{ado_org_url.rstrip('/')}/{ado_project}/_git/{repo_name}/pullrequest/{pr_number}"
|
|
61
|
+
return pr_number, pr_url
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def create_pr(
|
|
65
|
+
repo_url: str,
|
|
66
|
+
branch: str,
|
|
67
|
+
title: str,
|
|
68
|
+
description: str,
|
|
69
|
+
ado_org_url: str,
|
|
70
|
+
ado_project: str,
|
|
71
|
+
ado_pat: str,
|
|
72
|
+
target_branch: str = "main",
|
|
73
|
+
) -> tuple[int, str]:
|
|
74
|
+
"""Create a PR and return (pr_number, pr_url).
|
|
75
|
+
|
|
76
|
+
If ADO returns 400/409 (e.g. a PR already exists for this branch), looks
|
|
77
|
+
up and returns the existing PR instead of raising.
|
|
78
|
+
"""
|
|
79
|
+
repo_name = extract_repo_name(repo_url)
|
|
80
|
+
url = f"{api_base(ado_org_url, ado_project, repo_name)}/pullrequests?api-version={_ADO_API_VERSION}"
|
|
81
|
+
payload = {
|
|
82
|
+
"title": title,
|
|
83
|
+
"description": description,
|
|
84
|
+
"sourceRefName": f"refs/heads/{branch}",
|
|
85
|
+
"targetRefName": f"refs/heads/{target_branch}",
|
|
86
|
+
}
|
|
87
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
88
|
+
resp = await client.post(url, json=payload, headers=ado_headers(ado_pat))
|
|
89
|
+
if resp.status_code in (400, 409):
|
|
90
|
+
# PR likely already exists for this branch — look it up.
|
|
91
|
+
existing = await get_existing_pr(
|
|
92
|
+
repo_url, branch, ado_org_url, ado_project, ado_pat
|
|
93
|
+
)
|
|
94
|
+
if existing:
|
|
95
|
+
return existing
|
|
96
|
+
# No existing PR found — surface the original error.
|
|
97
|
+
resp.raise_for_status()
|
|
98
|
+
else:
|
|
99
|
+
resp.raise_for_status()
|
|
100
|
+
data = resp.json()
|
|
101
|
+
pr_number = data["pullRequestId"]
|
|
102
|
+
pr_url = f"{ado_org_url.rstrip('/')}/{ado_project}/_git/{repo_name}/pullrequest/{pr_number}"
|
|
103
|
+
return pr_number, pr_url
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def get_pr_status(
|
|
107
|
+
repo_url: str,
|
|
108
|
+
pr_number: int,
|
|
109
|
+
ado_org_url: str,
|
|
110
|
+
ado_project: str,
|
|
111
|
+
ado_pat: str,
|
|
112
|
+
) -> str:
|
|
113
|
+
repo_name = extract_repo_name(repo_url)
|
|
114
|
+
url = (
|
|
115
|
+
f"{api_base(ado_org_url, ado_project, repo_name)}"
|
|
116
|
+
f"/pullrequests/{pr_number}?api-version={_ADO_API_VERSION}"
|
|
117
|
+
)
|
|
118
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
119
|
+
resp = await client.get(url, headers=ado_headers(ado_pat))
|
|
120
|
+
resp.raise_for_status()
|
|
121
|
+
return resp.json().get("status", "unknown")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def get_pr_comments(
|
|
125
|
+
repo_url: str,
|
|
126
|
+
pr_number: int,
|
|
127
|
+
ado_org_url: str,
|
|
128
|
+
ado_project: str,
|
|
129
|
+
ado_pat: str,
|
|
130
|
+
) -> list[dict]:
|
|
131
|
+
"""Return one dict per non-deleted, non-system thread.
|
|
132
|
+
|
|
133
|
+
Each dict has the shape::
|
|
134
|
+
|
|
135
|
+
{
|
|
136
|
+
"thread_id": "64",
|
|
137
|
+
"file_path": "src/app/foo.py", # or None for general comments
|
|
138
|
+
"comments": [
|
|
139
|
+
{"comment_id": 1, "author": "Alice", "body": "..."},
|
|
140
|
+
{"comment_id": 2, "author": "devvy", "body": "..."},
|
|
141
|
+
],
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
Comments within each thread are ordered by ``id`` ascending so callers
|
|
145
|
+
can treat the last entry as the most recent.
|
|
146
|
+
"""
|
|
147
|
+
repo_name = extract_repo_name(repo_url)
|
|
148
|
+
url = (
|
|
149
|
+
f"{api_base(ado_org_url, ado_project, repo_name)}"
|
|
150
|
+
f"/pullrequests/{pr_number}/threads?api-version={_ADO_API_VERSION}"
|
|
151
|
+
)
|
|
152
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
153
|
+
resp = await client.get(url, headers=ado_headers(ado_pat))
|
|
154
|
+
resp.raise_for_status()
|
|
155
|
+
data = resp.json()
|
|
156
|
+
|
|
157
|
+
threads: list[dict] = []
|
|
158
|
+
for thread in data.get("value", []):
|
|
159
|
+
if thread.get("isDeleted"):
|
|
160
|
+
continue
|
|
161
|
+
thread_id = str(thread.get("id", ""))
|
|
162
|
+
file_path = (thread.get("threadContext") or {}).get("filePath")
|
|
163
|
+
comments = []
|
|
164
|
+
for comment in sorted(thread.get("comments", []), key=lambda c: c.get("id", 0)):
|
|
165
|
+
if comment.get("isDeleted") or comment.get("commentType") == "system":
|
|
166
|
+
continue
|
|
167
|
+
comments.append(
|
|
168
|
+
{
|
|
169
|
+
"comment_id": comment.get("id", 0),
|
|
170
|
+
"author": comment.get("author", {}).get("displayName", "unknown"),
|
|
171
|
+
"body": comment.get("content", ""),
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
if not comments:
|
|
175
|
+
continue
|
|
176
|
+
threads.append(
|
|
177
|
+
{
|
|
178
|
+
"thread_id": thread_id,
|
|
179
|
+
"file_path": file_path,
|
|
180
|
+
"comments": comments,
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
return threads
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def post_thread_reply(
|
|
187
|
+
repo_url: str,
|
|
188
|
+
pr_number: int,
|
|
189
|
+
thread_id: str,
|
|
190
|
+
message: str,
|
|
191
|
+
ado_org_url: str,
|
|
192
|
+
ado_project: str,
|
|
193
|
+
ado_pat: str,
|
|
194
|
+
) -> int:
|
|
195
|
+
"""Post a reply comment to an existing PR thread.
|
|
196
|
+
|
|
197
|
+
parentCommentId=1 attaches the reply to the first (root) comment in the
|
|
198
|
+
thread, which is what ADO expects for a standard reply.
|
|
199
|
+
|
|
200
|
+
Returns the ``id`` of the newly created comment so callers can advance
|
|
201
|
+
the high-water mark for that thread.
|
|
202
|
+
"""
|
|
203
|
+
repo_name = extract_repo_name(repo_url)
|
|
204
|
+
url = (
|
|
205
|
+
f"{api_base(ado_org_url, ado_project, repo_name)}"
|
|
206
|
+
f"/pullrequests/{pr_number}/threads/{thread_id}/comments?api-version={_ADO_API_VERSION}"
|
|
207
|
+
)
|
|
208
|
+
payload = {"content": message, "parentCommentId": 1}
|
|
209
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
210
|
+
resp = await client.post(url, json=payload, headers=ado_headers(ado_pat))
|
|
211
|
+
resp.raise_for_status()
|
|
212
|
+
return int(resp.json().get("id", 0))
|
cli/commands/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI commands package."""
|
cli/commands/init.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""coding-agent init — configure the local CLI client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from cli.config import CONFIG_PATH, DEFAULT_MODEL
|
|
10
|
+
from cli.ui import console, pick_model
|
|
11
|
+
|
|
12
|
+
# Ordered list of models shown in the picker (provider/model format).
|
|
13
|
+
# Kept in sync with `opencode models` output for the github-copilot provider.
|
|
14
|
+
_MODELS = [
|
|
15
|
+
"github-copilot/claude-sonnet-4.6",
|
|
16
|
+
"github-copilot/claude-sonnet-4.5",
|
|
17
|
+
"github-copilot/claude-opus-4.6",
|
|
18
|
+
"github-copilot/claude-opus-4.5",
|
|
19
|
+
"github-copilot/claude-haiku-4.5",
|
|
20
|
+
"github-copilot/gpt-5",
|
|
21
|
+
"github-copilot/gpt-5.1",
|
|
22
|
+
"github-copilot/gpt-5.2",
|
|
23
|
+
"github-copilot/gpt-4.1",
|
|
24
|
+
"github-copilot/gpt-4o",
|
|
25
|
+
"github-copilot/gpt-5-mini",
|
|
26
|
+
"github-copilot/gpt-5.1-codex",
|
|
27
|
+
"github-copilot/gpt-5.1-codex-mini",
|
|
28
|
+
"github-copilot/gemini-2.5-pro",
|
|
29
|
+
"github-copilot/gemini-3-flash-preview",
|
|
30
|
+
"github-copilot/gemini-3-pro-preview",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(help="Initialise the coding-agent CLI configuration.")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.callback(invoke_without_command=True)
|
|
38
|
+
def init(ctx: typer.Context) -> None:
|
|
39
|
+
"""
|
|
40
|
+
Interactively configure the coding-agent CLI.
|
|
41
|
+
|
|
42
|
+
Writes all credentials and settings to ~/.coding-agent.toml.
|
|
43
|
+
"""
|
|
44
|
+
console.print("\n[bold]Initialising coding-agent configuration…[/bold]\n")
|
|
45
|
+
|
|
46
|
+
# Load existing config so we can show current values as defaults
|
|
47
|
+
existing: dict = {}
|
|
48
|
+
if CONFIG_PATH.exists():
|
|
49
|
+
try:
|
|
50
|
+
with CONFIG_PATH.open("rb") as f:
|
|
51
|
+
existing = tomllib.load(f).get("coding_agent", {})
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
current_model = existing.get("opencode_model", DEFAULT_MODEL)
|
|
56
|
+
default_model = current_model if current_model in _MODELS else DEFAULT_MODEL
|
|
57
|
+
|
|
58
|
+
opencode_model = pick_model(_MODELS, default_model)
|
|
59
|
+
console.print(
|
|
60
|
+
f"\n [bold green]✓[/bold green] Selected: [bold]{opencode_model}[/bold]\n"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
ado_org_url = typer.prompt(
|
|
64
|
+
"Azure DevOps org URL (e.g. https://dev.azure.com/myorg)",
|
|
65
|
+
default=existing.get("ado_org_url", ""),
|
|
66
|
+
)
|
|
67
|
+
ado_project = typer.prompt(
|
|
68
|
+
"Azure DevOps project name",
|
|
69
|
+
default=existing.get("ado_project", ""),
|
|
70
|
+
)
|
|
71
|
+
ado_pat = typer.prompt(
|
|
72
|
+
"Azure DevOps personal access token",
|
|
73
|
+
hide_input=True,
|
|
74
|
+
)
|
|
75
|
+
repo_url = typer.prompt(
|
|
76
|
+
"Repository URL (e.g. https://dev.azure.com/myorg/myproject/_git/myrepo)",
|
|
77
|
+
default=existing.get("repo_url", ""),
|
|
78
|
+
)
|
|
79
|
+
env_file = typer.prompt(
|
|
80
|
+
"Path to repo .env file to inject into the workspace (leave blank to skip)",
|
|
81
|
+
default=existing.get("env_file", ""),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
config = {
|
|
85
|
+
"opencode_model": opencode_model,
|
|
86
|
+
"ado_org_url": ado_org_url,
|
|
87
|
+
"ado_project": ado_project,
|
|
88
|
+
"ado_pat": ado_pat,
|
|
89
|
+
"repo_url": repo_url,
|
|
90
|
+
"env_file": env_file,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# Write as TOML manually (tomllib is read-only; avoid extra dep on tomli-w)
|
|
94
|
+
lines = ["[coding_agent]\n"]
|
|
95
|
+
for key, value in config.items():
|
|
96
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
97
|
+
lines.append(f'{key} = "{escaped}"\n')
|
|
98
|
+
|
|
99
|
+
CONFIG_PATH.write_text("".join(lines))
|
|
100
|
+
|
|
101
|
+
# Lock down permissions — config contains secrets
|
|
102
|
+
CONFIG_PATH.chmod(0o600)
|
|
103
|
+
|
|
104
|
+
console.print(
|
|
105
|
+
f"\n[bold green]✓[/bold green] Configuration written to [bold]{CONFIG_PATH}[/bold]"
|
|
106
|
+
)
|
|
107
|
+
console.print("[dim] Permissions set to 600 (owner read/write only).[/dim]")
|
cli/commands/logs.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""coding-agent logs <id> — print orchestrator logs for a ticket from local DB."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from sqlalchemy import select
|
|
9
|
+
|
|
10
|
+
import cli.models # noqa: F401 — ensure tables are registered on Base.metadata
|
|
11
|
+
from cli.db import SessionManager, init_db
|
|
12
|
+
from cli.models import GraphContext, Ticket
|
|
13
|
+
from cli.ui import err_console, render_logs
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def logs(ticket_id: str = typer.Argument(..., help="Ticket ID")) -> None:
|
|
17
|
+
"""Print the agent logs for a ticket."""
|
|
18
|
+
asyncio.run(_logs_async(ticket_id))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def _logs_async(ticket_id: str) -> None:
|
|
22
|
+
await init_db()
|
|
23
|
+
|
|
24
|
+
async with SessionManager.session() as session:
|
|
25
|
+
ticket = await session.get(Ticket, ticket_id)
|
|
26
|
+
if ticket is None:
|
|
27
|
+
err_console.print(f"[red]Ticket {ticket_id!r} not found.[/red]")
|
|
28
|
+
raise typer.Exit(1)
|
|
29
|
+
|
|
30
|
+
result = await session.execute(
|
|
31
|
+
select(GraphContext).where(GraphContext.ticket_id == ticket_id)
|
|
32
|
+
)
|
|
33
|
+
ctx = result.scalar_one_or_none()
|
|
34
|
+
|
|
35
|
+
log_text = (ctx.logs if ctx else "").strip()
|
|
36
|
+
render_logs(log_text)
|
cli/commands/ps.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""devvy ps — list all in-progress (and recently crashed) tickets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from sqlalchemy import select
|
|
9
|
+
|
|
10
|
+
import cli.models # noqa: F401 — ensure tables are registered on Base.metadata
|
|
11
|
+
from cli.db import SessionManager, init_db
|
|
12
|
+
from cli.fsm import TicketState
|
|
13
|
+
from cli.models import GraphContext, Ticket
|
|
14
|
+
from cli.ui import _pid_alive, console, state_text
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ps() -> None:
|
|
18
|
+
"""List all tickets that are not yet in a terminal state."""
|
|
19
|
+
asyncio.run(_ps_async())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def _ps_async() -> None:
|
|
23
|
+
await init_db()
|
|
24
|
+
|
|
25
|
+
terminal_values = {s.value for s in TicketState if s.is_terminal}
|
|
26
|
+
|
|
27
|
+
async with SessionManager.session() as session:
|
|
28
|
+
result = await session.execute(
|
|
29
|
+
select(Ticket, GraphContext)
|
|
30
|
+
.join(GraphContext, GraphContext.ticket_id == Ticket.id)
|
|
31
|
+
.where(Ticket.state.notin_(terminal_values))
|
|
32
|
+
.order_by(Ticket.created_at)
|
|
33
|
+
)
|
|
34
|
+
rows = result.all()
|
|
35
|
+
|
|
36
|
+
if not rows:
|
|
37
|
+
console.print("[dim]No active tickets.[/dim]")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
table = Table(show_header=True, header_style="bold dim", box=None, padding=(0, 2))
|
|
41
|
+
table.add_column("ID", style="dim", no_wrap=True)
|
|
42
|
+
table.add_column("TITLE")
|
|
43
|
+
table.add_column("STATE")
|
|
44
|
+
table.add_column("PROCESS")
|
|
45
|
+
table.add_column("BRANCH", style="dim")
|
|
46
|
+
|
|
47
|
+
for ticket, ctx in rows:
|
|
48
|
+
pid = ctx.worker_pid
|
|
49
|
+
|
|
50
|
+
if pid is not None:
|
|
51
|
+
if _pid_alive(pid):
|
|
52
|
+
process_label = (
|
|
53
|
+
f"[bold green]Running[/bold green] [dim](pid {pid})[/dim]"
|
|
54
|
+
)
|
|
55
|
+
else:
|
|
56
|
+
process_label = f"[bold red]Crashed[/bold red] [dim](pid {pid})[/dim]"
|
|
57
|
+
else:
|
|
58
|
+
process_label = "[dim yellow]Idle / resumable[/dim yellow]"
|
|
59
|
+
|
|
60
|
+
table.add_row(
|
|
61
|
+
ticket.id[:8],
|
|
62
|
+
ticket.title,
|
|
63
|
+
state_text(ticket.state),
|
|
64
|
+
process_label,
|
|
65
|
+
ticket.branch_name or "—",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
console.print(table)
|
|
69
|
+
console.print(
|
|
70
|
+
f"\n[dim]{len(rows)} active ticket{'s' if len(rows) != 1 else ''}. "
|
|
71
|
+
"Use [bold]devvy logs <id>[/bold] or [bold]devvy status <id>[/bold] for details.[/dim]"
|
|
72
|
+
)
|
cli/commands/resume.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""devvy resume <ticket-id> — re-attach to an existing ticket and continue running."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from sqlalchemy import select
|
|
9
|
+
|
|
10
|
+
import cli.models # noqa: F401 — ensure tables are registered on Base.metadata
|
|
11
|
+
from cli.config import Config, ConfigNotFoundError, load_config
|
|
12
|
+
from cli.db import SessionManager, init_db
|
|
13
|
+
from cli.fsm import TicketState
|
|
14
|
+
from cli.models import GraphContext, Ticket
|
|
15
|
+
from cli.orchestrator import Orchestrator
|
|
16
|
+
from cli import local_runner
|
|
17
|
+
from cli.ui import ProgressTracker, console, err_console, print_resume_banner
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resume(
|
|
21
|
+
ticket_id: str = typer.Argument(..., help="Ticket ID to resume"),
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Resume a previously started ticket from its last persisted state."""
|
|
24
|
+
try:
|
|
25
|
+
config = load_config()
|
|
26
|
+
except ConfigNotFoundError as exc:
|
|
27
|
+
typer.echo(str(exc), err=True)
|
|
28
|
+
raise typer.Exit(1)
|
|
29
|
+
asyncio.run(_resume_async(ticket_id, config))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def _resume_async(ticket_id: str, config: Config) -> None:
|
|
33
|
+
await init_db()
|
|
34
|
+
|
|
35
|
+
async with SessionManager.session() as session:
|
|
36
|
+
ticket = await session.get(Ticket, ticket_id)
|
|
37
|
+
if ticket is None:
|
|
38
|
+
err_console.print(f"[red]Ticket {ticket_id!r} not found.[/red]")
|
|
39
|
+
raise typer.Exit(1)
|
|
40
|
+
|
|
41
|
+
result = await session.execute(
|
|
42
|
+
select(GraphContext).where(GraphContext.ticket_id == ticket_id)
|
|
43
|
+
)
|
|
44
|
+
ctx = result.scalar_one_or_none()
|
|
45
|
+
if ctx is None:
|
|
46
|
+
err_console.print(f"[red]No context found for ticket {ticket_id!r}.[/red]")
|
|
47
|
+
raise typer.Exit(1)
|
|
48
|
+
|
|
49
|
+
state = TicketState(ctx.current_state)
|
|
50
|
+
if state.is_terminal:
|
|
51
|
+
err_console.print(
|
|
52
|
+
f"[yellow]Ticket {ticket_id[:8]} is already in terminal state "
|
|
53
|
+
f"{state.value} — nothing to resume.[/yellow]"
|
|
54
|
+
)
|
|
55
|
+
raise typer.Exit(1)
|
|
56
|
+
|
|
57
|
+
print_resume_banner(
|
|
58
|
+
ticket_id=ticket_id,
|
|
59
|
+
title=ticket.title,
|
|
60
|
+
state=ctx.current_state,
|
|
61
|
+
branch=ticket.branch_name,
|
|
62
|
+
pr_number=ctx.pr_number,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Re-attach to the running container or spawn a fresh one over the
|
|
66
|
+
# existing workspace. If the workspace is gone, recover_container()
|
|
67
|
+
# raises a RuntimeError with a clear message.
|
|
68
|
+
try:
|
|
69
|
+
new_container_id, fresh = await local_runner.recover_container(
|
|
70
|
+
ticket_id=ticket_id,
|
|
71
|
+
container_id=ctx.container_id,
|
|
72
|
+
repo_url=ticket.repo_url,
|
|
73
|
+
ado_pat=config.ado_pat,
|
|
74
|
+
)
|
|
75
|
+
except RuntimeError as exc:
|
|
76
|
+
err_console.print(f"[red]Cannot resume: {exc}[/red]")
|
|
77
|
+
raise typer.Exit(1)
|
|
78
|
+
|
|
79
|
+
if fresh:
|
|
80
|
+
console.print(
|
|
81
|
+
f" [dim]Spawned fresh container [bold]{new_container_id[:12]}[/bold] "
|
|
82
|
+
f"(old container was gone).[/dim]"
|
|
83
|
+
)
|
|
84
|
+
ctx.container_id = new_container_id
|
|
85
|
+
# Stale session ID is useless without the old container's opencode state.
|
|
86
|
+
ctx.opencode_session_id = None
|
|
87
|
+
async with SessionManager.session() as session:
|
|
88
|
+
session.add(ctx)
|
|
89
|
+
session.add(ticket)
|
|
90
|
+
else:
|
|
91
|
+
console.print(
|
|
92
|
+
f" [dim]Re-attached to existing container [bold]{new_container_id[:12]}[/bold].[/dim]"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
console.print(
|
|
96
|
+
f" [dim]Running from state: [bold]{ctx.current_state}[/bold][/dim]\n"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
tracker = ProgressTracker()
|
|
100
|
+
orchestrator = Orchestrator(ticket, ctx, config)
|
|
101
|
+
await orchestrator.run(status_callback=tracker.on_state_change)
|
|
102
|
+
tracker.finish(ticket)
|
|
103
|
+
|
|
104
|
+
if ticket.state != TicketState.MERGED.value:
|
|
105
|
+
raise typer.Exit(1)
|
cli/commands/run.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""coding-agent run — submit a coding task and run it locally."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from cli.config import Config, ConfigNotFoundError, load_config
|
|
15
|
+
from cli.db import SessionManager, init_db
|
|
16
|
+
from cli.fsm import TicketState
|
|
17
|
+
from cli.models import GraphContext, Ticket
|
|
18
|
+
from cli.orchestrator import Orchestrator
|
|
19
|
+
from cli.ui import ProgressTracker, console, print_run_banner
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run(
|
|
23
|
+
title: str = typer.Option(None, "--title", "-t", help="Ticket title"),
|
|
24
|
+
description: str = typer.Option(
|
|
25
|
+
None, "--description", "-D", help="Ticket description"
|
|
26
|
+
),
|
|
27
|
+
background: bool = typer.Option(
|
|
28
|
+
False,
|
|
29
|
+
"--background",
|
|
30
|
+
"-d",
|
|
31
|
+
is_flag=True,
|
|
32
|
+
help="Detach and run in the background; returns the ticket ID immediately.",
|
|
33
|
+
),
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Run a coding task locally — no server required."""
|
|
36
|
+
try:
|
|
37
|
+
config = load_config()
|
|
38
|
+
except ConfigNotFoundError as exc:
|
|
39
|
+
typer.echo(str(exc), err=True)
|
|
40
|
+
raise typer.Exit(1)
|
|
41
|
+
|
|
42
|
+
if not title:
|
|
43
|
+
title = typer.prompt("Ticket title")
|
|
44
|
+
if not description:
|
|
45
|
+
description = typer.prompt("Ticket description")
|
|
46
|
+
|
|
47
|
+
if background:
|
|
48
|
+
_run_in_background(title, description)
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
asyncio.run(_run_async(title, description, config))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _run_in_background(title: str, description: str) -> None:
|
|
55
|
+
"""Fork a detached child process that runs the orchestrator and return immediately.
|
|
56
|
+
|
|
57
|
+
Re-executes the same ``devvy run`` command *without* ``--background`` so the
|
|
58
|
+
child goes through the normal foreground code path. ``start_new_session=True``
|
|
59
|
+
detaches the child from the controlling terminal on Linux and macOS, meaning it
|
|
60
|
+
keeps running after the parent (and its terminal) exit.
|
|
61
|
+
"""
|
|
62
|
+
cmd = [
|
|
63
|
+
sys.executable,
|
|
64
|
+
"-m",
|
|
65
|
+
"cli", # invokes src/cli/__main__.py
|
|
66
|
+
"run",
|
|
67
|
+
"--title",
|
|
68
|
+
title,
|
|
69
|
+
"--description",
|
|
70
|
+
description,
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
# Prefer the installed entry-point script when available — it's more robust
|
|
74
|
+
# than relying on -m cli.main_runner being importable.
|
|
75
|
+
devvy_exe = shutil.which("devvy")
|
|
76
|
+
if devvy_exe:
|
|
77
|
+
cmd = [devvy_exe, "run", "--title", title, "--description", description]
|
|
78
|
+
|
|
79
|
+
proc = subprocess.Popen(
|
|
80
|
+
cmd,
|
|
81
|
+
stdin=subprocess.DEVNULL,
|
|
82
|
+
stdout=subprocess.DEVNULL,
|
|
83
|
+
stderr=subprocess.DEVNULL,
|
|
84
|
+
start_new_session=True, # detach from controlling terminal
|
|
85
|
+
close_fds=True,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
console.print(
|
|
89
|
+
f"[bold green]Started background ticket[/bold green] (pid [dim]{proc.pid}[/dim])\n"
|
|
90
|
+
f" [dim]title:[/dim] {title}\n\n"
|
|
91
|
+
f" Check progress:\n"
|
|
92
|
+
f" [bold]devvy ps[/bold]\n"
|
|
93
|
+
f" [bold]devvy status <ticket-id>[/bold]\n"
|
|
94
|
+
f" [bold]devvy logs <ticket-id>[/bold]"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def _run_async(title: str, description: str, config: Config) -> None:
|
|
99
|
+
await init_db()
|
|
100
|
+
|
|
101
|
+
now = datetime.now(UTC)
|
|
102
|
+
ticket_id = str(uuid.uuid4())
|
|
103
|
+
|
|
104
|
+
ticket = Ticket(
|
|
105
|
+
id=ticket_id,
|
|
106
|
+
title=title,
|
|
107
|
+
description=description,
|
|
108
|
+
repo_url=config.repo_url,
|
|
109
|
+
state=TicketState.RECEIVED.value,
|
|
110
|
+
created_at=now,
|
|
111
|
+
updated_at=now,
|
|
112
|
+
)
|
|
113
|
+
context = GraphContext(
|
|
114
|
+
id=str(uuid.uuid4()),
|
|
115
|
+
ticket_id=ticket_id,
|
|
116
|
+
current_state=TicketState.RECEIVED.value,
|
|
117
|
+
retry_count=0,
|
|
118
|
+
logs="",
|
|
119
|
+
updated_at=now,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
async with SessionManager.session() as session:
|
|
123
|
+
session.add(ticket)
|
|
124
|
+
session.add(context)
|
|
125
|
+
|
|
126
|
+
print_run_banner(ticket_id, title, config.repo_url)
|
|
127
|
+
console.print("[dim]Starting agent…[/dim]\n")
|
|
128
|
+
|
|
129
|
+
tracker = ProgressTracker()
|
|
130
|
+
orchestrator = Orchestrator(ticket, context, config)
|
|
131
|
+
await orchestrator.run(status_callback=tracker.on_state_change)
|
|
132
|
+
tracker.finish(ticket)
|
|
133
|
+
|
|
134
|
+
if ticket.state != TicketState.MERGED.value:
|
|
135
|
+
raise typer.Exit(1)
|