codex-autorunner 1.0.0__py3-none-any.whl → 1.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.
- codex_autorunner/__init__.py +12 -1
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/constants.py +3 -0
- codex_autorunner/agents/opencode/harness.py +6 -1
- codex_autorunner/agents/opencode/runtime.py +59 -18
- codex_autorunner/agents/registry.py +22 -3
- codex_autorunner/bootstrap.py +7 -3
- codex_autorunner/cli.py +5 -1174
- codex_autorunner/codex_cli.py +20 -84
- codex_autorunner/core/__init__.py +4 -0
- codex_autorunner/core/about_car.py +6 -1
- codex_autorunner/core/app_server_ids.py +59 -0
- codex_autorunner/core/app_server_threads.py +11 -2
- codex_autorunner/core/app_server_utils.py +165 -0
- codex_autorunner/core/archive.py +349 -0
- codex_autorunner/core/codex_runner.py +6 -2
- codex_autorunner/core/config.py +197 -3
- codex_autorunner/core/drafts.py +58 -4
- codex_autorunner/core/engine.py +1329 -680
- codex_autorunner/core/exceptions.py +4 -0
- codex_autorunner/core/flows/controller.py +25 -1
- codex_autorunner/core/flows/models.py +13 -0
- codex_autorunner/core/flows/reasons.py +52 -0
- codex_autorunner/core/flows/reconciler.py +131 -0
- codex_autorunner/core/flows/runtime.py +35 -4
- codex_autorunner/core/flows/store.py +83 -0
- codex_autorunner/core/flows/transition.py +5 -0
- codex_autorunner/core/flows/ux_helpers.py +257 -0
- codex_autorunner/core/git_utils.py +62 -0
- codex_autorunner/core/hub.py +121 -7
- codex_autorunner/core/notifications.py +14 -2
- codex_autorunner/core/ports/__init__.py +28 -0
- codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +11 -3
- codex_autorunner/core/ports/backend_orchestrator.py +41 -0
- codex_autorunner/{integrations/agents → core/ports}/run_event.py +22 -2
- codex_autorunner/core/state_roots.py +57 -0
- codex_autorunner/core/supervisor_protocol.py +15 -0
- codex_autorunner/core/text_delta_coalescer.py +54 -0
- codex_autorunner/core/ticket_linter_cli.py +201 -0
- codex_autorunner/core/ticket_manager_cli.py +432 -0
- codex_autorunner/core/update.py +4 -5
- codex_autorunner/core/update_paths.py +28 -0
- codex_autorunner/core/usage.py +164 -12
- codex_autorunner/core/utils.py +91 -9
- codex_autorunner/flows/review/__init__.py +17 -0
- codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
- codex_autorunner/flows/ticket_flow/definition.py +9 -2
- codex_autorunner/integrations/agents/__init__.py +9 -19
- codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
- codex_autorunner/integrations/agents/codex_adapter.py +90 -0
- codex_autorunner/integrations/agents/codex_backend.py +158 -17
- codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
- codex_autorunner/integrations/agents/opencode_backend.py +305 -32
- codex_autorunner/integrations/agents/runner.py +91 -0
- codex_autorunner/integrations/agents/wiring.py +271 -0
- codex_autorunner/integrations/app_server/client.py +7 -60
- codex_autorunner/integrations/app_server/env.py +2 -107
- codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
- codex_autorunner/integrations/telegram/adapter.py +65 -0
- codex_autorunner/integrations/telegram/config.py +46 -0
- codex_autorunner/integrations/telegram/constants.py +1 -1
- codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +1203 -66
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +4 -3
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +8 -2
- codex_autorunner/integrations/telegram/handlers/messages.py +1 -0
- codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
- codex_autorunner/integrations/telegram/helpers.py +24 -1
- codex_autorunner/integrations/telegram/service.py +15 -10
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +329 -40
- codex_autorunner/integrations/telegram/transport.py +3 -1
- codex_autorunner/routes/__init__.py +37 -76
- codex_autorunner/routes/agents.py +2 -137
- codex_autorunner/routes/analytics.py +2 -238
- codex_autorunner/routes/app_server.py +2 -131
- codex_autorunner/routes/base.py +2 -596
- codex_autorunner/routes/file_chat.py +4 -833
- codex_autorunner/routes/flows.py +4 -977
- codex_autorunner/routes/messages.py +4 -456
- codex_autorunner/routes/repos.py +2 -196
- codex_autorunner/routes/review.py +2 -147
- codex_autorunner/routes/sessions.py +2 -175
- codex_autorunner/routes/settings.py +2 -168
- codex_autorunner/routes/shared.py +2 -275
- codex_autorunner/routes/system.py +4 -193
- codex_autorunner/routes/usage.py +2 -86
- codex_autorunner/routes/voice.py +2 -119
- codex_autorunner/routes/workspace.py +2 -270
- codex_autorunner/server.py +2 -2
- codex_autorunner/static/agentControls.js +40 -11
- codex_autorunner/static/app.js +11 -3
- codex_autorunner/static/archive.js +826 -0
- codex_autorunner/static/archiveApi.js +37 -0
- codex_autorunner/static/autoRefresh.js +7 -7
- codex_autorunner/static/dashboard.js +224 -171
- codex_autorunner/static/hub.js +112 -94
- codex_autorunner/static/index.html +80 -33
- codex_autorunner/static/messages.js +486 -83
- codex_autorunner/static/preserve.js +17 -0
- codex_autorunner/static/settings.js +125 -6
- codex_autorunner/static/smartRefresh.js +52 -0
- codex_autorunner/static/styles.css +1373 -101
- codex_autorunner/static/tabs.js +152 -11
- codex_autorunner/static/terminal.js +18 -0
- codex_autorunner/static/ticketEditor.js +99 -5
- codex_autorunner/static/tickets.js +760 -87
- codex_autorunner/static/utils.js +11 -0
- codex_autorunner/static/workspace.js +133 -40
- codex_autorunner/static/workspaceFileBrowser.js +9 -9
- codex_autorunner/surfaces/__init__.py +5 -0
- codex_autorunner/surfaces/cli/__init__.py +6 -0
- codex_autorunner/surfaces/cli/cli.py +1224 -0
- codex_autorunner/surfaces/cli/codex_cli.py +20 -0
- codex_autorunner/surfaces/telegram/__init__.py +3 -0
- codex_autorunner/surfaces/web/__init__.py +1 -0
- codex_autorunner/surfaces/web/app.py +2019 -0
- codex_autorunner/surfaces/web/hub_jobs.py +192 -0
- codex_autorunner/surfaces/web/middleware.py +587 -0
- codex_autorunner/surfaces/web/pty_session.py +370 -0
- codex_autorunner/surfaces/web/review.py +6 -0
- codex_autorunner/surfaces/web/routes/__init__.py +78 -0
- codex_autorunner/surfaces/web/routes/agents.py +138 -0
- codex_autorunner/surfaces/web/routes/analytics.py +277 -0
- codex_autorunner/surfaces/web/routes/app_server.py +132 -0
- codex_autorunner/surfaces/web/routes/archive.py +357 -0
- codex_autorunner/surfaces/web/routes/base.py +615 -0
- codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
- codex_autorunner/surfaces/web/routes/flows.py +1164 -0
- codex_autorunner/surfaces/web/routes/messages.py +459 -0
- codex_autorunner/surfaces/web/routes/repos.py +197 -0
- codex_autorunner/surfaces/web/routes/review.py +148 -0
- codex_autorunner/surfaces/web/routes/sessions.py +176 -0
- codex_autorunner/surfaces/web/routes/settings.py +169 -0
- codex_autorunner/surfaces/web/routes/shared.py +280 -0
- codex_autorunner/surfaces/web/routes/system.py +196 -0
- codex_autorunner/surfaces/web/routes/usage.py +89 -0
- codex_autorunner/surfaces/web/routes/voice.py +120 -0
- codex_autorunner/surfaces/web/routes/workspace.py +271 -0
- codex_autorunner/surfaces/web/runner_manager.py +25 -0
- codex_autorunner/surfaces/web/schemas.py +417 -0
- codex_autorunner/surfaces/web/static_assets.py +490 -0
- codex_autorunner/surfaces/web/static_refresh.py +86 -0
- codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
- codex_autorunner/tickets/__init__.py +8 -1
- codex_autorunner/tickets/agent_pool.py +26 -4
- codex_autorunner/tickets/files.py +6 -2
- codex_autorunner/tickets/models.py +3 -1
- codex_autorunner/tickets/outbox.py +12 -0
- codex_autorunner/tickets/runner.py +63 -5
- codex_autorunner/web/__init__.py +5 -1
- codex_autorunner/web/app.py +2 -1949
- codex_autorunner/web/hub_jobs.py +2 -191
- codex_autorunner/web/middleware.py +2 -586
- codex_autorunner/web/pty_session.py +2 -369
- codex_autorunner/web/runner_manager.py +2 -24
- codex_autorunner/web/schemas.py +2 -376
- codex_autorunner/web/static_assets.py +4 -441
- codex_autorunner/web/static_refresh.py +2 -85
- codex_autorunner/web/terminal_sessions.py +2 -77
- codex_autorunner/workspace/paths.py +49 -33
- codex_autorunner-1.1.0.dist-info/METADATA +154 -0
- codex_autorunner-1.1.0.dist-info/RECORD +308 -0
- codex_autorunner/core/static_assets.py +0 -55
- codex_autorunner-1.0.0.dist-info/METADATA +0 -246
- codex_autorunner-1.0.0.dist-info/RECORD +0 -251
- /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Callable, Optional, Protocol
|
|
6
|
+
|
|
7
|
+
from ...tickets.files import list_ticket_paths
|
|
8
|
+
from .models import FlowEventType, FlowRunRecord
|
|
9
|
+
from .store import FlowStore
|
|
10
|
+
from .worker_process import (
|
|
11
|
+
check_worker_health,
|
|
12
|
+
clear_worker_metadata,
|
|
13
|
+
spawn_flow_worker,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class BootstrapCheckResult:
|
|
19
|
+
status: str
|
|
20
|
+
github_available: Optional[bool] = None
|
|
21
|
+
repo_slug: Optional[str] = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class IssueSeedResult:
|
|
26
|
+
content: str
|
|
27
|
+
issue_number: int
|
|
28
|
+
repo_slug: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GitHubServiceProtocol(Protocol):
|
|
32
|
+
def gh_available(self) -> bool: ...
|
|
33
|
+
|
|
34
|
+
def gh_authenticated(self) -> bool: ...
|
|
35
|
+
|
|
36
|
+
def repo_info(self) -> Any: ...
|
|
37
|
+
|
|
38
|
+
def validate_issue_same_repo(self, issue_ref: str) -> int: ...
|
|
39
|
+
|
|
40
|
+
def issue_view(self, number: int) -> dict: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def issue_md_path(repo_root: Path) -> Path:
|
|
44
|
+
return repo_root.resolve() / ".codex-autorunner" / "ISSUE.md"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def issue_md_has_content(repo_root: Path) -> bool:
|
|
48
|
+
issue_path = issue_md_path(repo_root)
|
|
49
|
+
if not issue_path.exists():
|
|
50
|
+
return False
|
|
51
|
+
try:
|
|
52
|
+
return bool(issue_path.read_text(encoding="utf-8").strip())
|
|
53
|
+
except OSError:
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _ticket_dir(repo_root: Path) -> Path:
|
|
58
|
+
return repo_root.resolve() / ".codex-autorunner" / "tickets"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def bootstrap_check(
|
|
62
|
+
repo_root: Path,
|
|
63
|
+
github_service_factory: Optional[Callable[[Path], GitHubServiceProtocol]] = None,
|
|
64
|
+
) -> BootstrapCheckResult:
|
|
65
|
+
if list_ticket_paths(_ticket_dir(repo_root)):
|
|
66
|
+
return BootstrapCheckResult(status="ready")
|
|
67
|
+
|
|
68
|
+
if issue_md_has_content(repo_root):
|
|
69
|
+
return BootstrapCheckResult(status="ready")
|
|
70
|
+
|
|
71
|
+
gh_available = False
|
|
72
|
+
repo_slug: Optional[str] = None
|
|
73
|
+
if github_service_factory is not None:
|
|
74
|
+
try:
|
|
75
|
+
gh = github_service_factory(repo_root)
|
|
76
|
+
gh_available = gh.gh_available() and gh.gh_authenticated()
|
|
77
|
+
if gh_available:
|
|
78
|
+
repo_info = gh.repo_info()
|
|
79
|
+
repo_slug = getattr(repo_info, "name_with_owner", None)
|
|
80
|
+
except Exception:
|
|
81
|
+
gh_available = False
|
|
82
|
+
repo_slug = None
|
|
83
|
+
|
|
84
|
+
return BootstrapCheckResult(
|
|
85
|
+
status="needs_issue", github_available=gh_available, repo_slug=repo_slug
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def format_issue_as_markdown(issue: dict, repo_slug: Optional[str] = None) -> str:
|
|
90
|
+
number = issue.get("number")
|
|
91
|
+
title = issue.get("title") or ""
|
|
92
|
+
url = issue.get("url") or ""
|
|
93
|
+
state = issue.get("state") or ""
|
|
94
|
+
author = issue.get("author") or {}
|
|
95
|
+
author_name = (
|
|
96
|
+
author.get("login") if isinstance(author, dict) else str(author or "unknown")
|
|
97
|
+
)
|
|
98
|
+
labels = issue.get("labels")
|
|
99
|
+
label_names: list[str] = []
|
|
100
|
+
if isinstance(labels, list):
|
|
101
|
+
for label in labels:
|
|
102
|
+
if isinstance(label, dict):
|
|
103
|
+
name = label.get("name")
|
|
104
|
+
else:
|
|
105
|
+
name = label
|
|
106
|
+
if name:
|
|
107
|
+
label_names.append(str(name))
|
|
108
|
+
comments = issue.get("comments")
|
|
109
|
+
comment_count = None
|
|
110
|
+
if isinstance(comments, dict):
|
|
111
|
+
total = comments.get("totalCount")
|
|
112
|
+
if isinstance(total, int):
|
|
113
|
+
comment_count = total
|
|
114
|
+
|
|
115
|
+
body = issue.get("body") or "(no description)"
|
|
116
|
+
lines = [
|
|
117
|
+
f"# Issue #{number}: {title}".strip(),
|
|
118
|
+
"",
|
|
119
|
+
f"**Repo:** {repo_slug or 'unknown'}",
|
|
120
|
+
f"**URL:** {url}",
|
|
121
|
+
f"**State:** {state}",
|
|
122
|
+
f"**Author:** {author_name}",
|
|
123
|
+
]
|
|
124
|
+
if label_names:
|
|
125
|
+
lines.append(f"**Labels:** {', '.join(label_names)}")
|
|
126
|
+
if comment_count is not None:
|
|
127
|
+
lines.append(f"**Comments:** {comment_count}")
|
|
128
|
+
lines.extend(["", "## Description", "", str(body).strip(), ""])
|
|
129
|
+
return "\n".join(lines)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def seed_issue_from_github(
|
|
133
|
+
repo_root: Path,
|
|
134
|
+
issue_ref: str,
|
|
135
|
+
github_service_factory: Optional[Callable[[Path], GitHubServiceProtocol]] = None,
|
|
136
|
+
) -> IssueSeedResult:
|
|
137
|
+
if github_service_factory is None:
|
|
138
|
+
raise RuntimeError("GitHub service unavailable.")
|
|
139
|
+
gh = github_service_factory(repo_root)
|
|
140
|
+
if not (gh.gh_available() and gh.gh_authenticated()):
|
|
141
|
+
raise RuntimeError("GitHub CLI is not available or not authenticated.")
|
|
142
|
+
number = gh.validate_issue_same_repo(issue_ref)
|
|
143
|
+
issue = gh.issue_view(number=number)
|
|
144
|
+
repo_info = gh.repo_info()
|
|
145
|
+
content = format_issue_as_markdown(issue, repo_info.name_with_owner)
|
|
146
|
+
return IssueSeedResult(
|
|
147
|
+
content=content, issue_number=number, repo_slug=repo_info.name_with_owner
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def seed_issue_from_text(plan_text: str) -> str:
|
|
152
|
+
return f"# Issue\n\n{plan_text.strip()}\n"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _derive_effective_current_ticket(
|
|
156
|
+
record: FlowRunRecord, store: Optional[FlowStore]
|
|
157
|
+
) -> Optional[str]:
|
|
158
|
+
if store is None:
|
|
159
|
+
return None
|
|
160
|
+
try:
|
|
161
|
+
if (
|
|
162
|
+
getattr(record, "flow_type", None) != "ticket_flow"
|
|
163
|
+
or not record.status.is_active()
|
|
164
|
+
):
|
|
165
|
+
return None
|
|
166
|
+
last_started = store.get_last_event_seq_by_types(
|
|
167
|
+
record.id, [FlowEventType.STEP_STARTED]
|
|
168
|
+
)
|
|
169
|
+
last_finished = store.get_last_event_seq_by_types(
|
|
170
|
+
record.id, [FlowEventType.STEP_COMPLETED, FlowEventType.STEP_FAILED]
|
|
171
|
+
)
|
|
172
|
+
in_progress = bool(
|
|
173
|
+
last_started is not None
|
|
174
|
+
and (last_finished is None or last_started > last_finished)
|
|
175
|
+
)
|
|
176
|
+
if not in_progress:
|
|
177
|
+
return None
|
|
178
|
+
return store.get_latest_step_progress_current_ticket(
|
|
179
|
+
record.id, after_seq=last_finished
|
|
180
|
+
)
|
|
181
|
+
except Exception:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def build_flow_status_snapshot(
|
|
186
|
+
repo_root: Path, record: FlowRunRecord, store: Optional[FlowStore]
|
|
187
|
+
) -> dict:
|
|
188
|
+
last_event_seq = None
|
|
189
|
+
last_event_at = None
|
|
190
|
+
if store:
|
|
191
|
+
try:
|
|
192
|
+
last_event_seq, last_event_at = store.get_last_event_meta(record.id)
|
|
193
|
+
except Exception:
|
|
194
|
+
last_event_seq, last_event_at = None, None
|
|
195
|
+
health = check_worker_health(repo_root, record.id)
|
|
196
|
+
|
|
197
|
+
state = record.state or {}
|
|
198
|
+
current_ticket = None
|
|
199
|
+
if isinstance(state, dict):
|
|
200
|
+
ticket_engine = state.get("ticket_engine")
|
|
201
|
+
if isinstance(ticket_engine, dict):
|
|
202
|
+
current_ticket = ticket_engine.get("current_ticket")
|
|
203
|
+
if not (isinstance(current_ticket, str) and current_ticket.strip()):
|
|
204
|
+
current_ticket = None
|
|
205
|
+
effective_ticket = current_ticket
|
|
206
|
+
if not effective_ticket:
|
|
207
|
+
effective_ticket = _derive_effective_current_ticket(record, store)
|
|
208
|
+
|
|
209
|
+
updated_state: Optional[dict] = None
|
|
210
|
+
if effective_ticket and not current_ticket and isinstance(state, dict):
|
|
211
|
+
ticket_engine = state.get("ticket_engine")
|
|
212
|
+
ticket_engine = dict(ticket_engine) if isinstance(ticket_engine, dict) else {}
|
|
213
|
+
ticket_engine["current_ticket"] = effective_ticket
|
|
214
|
+
updated_state = dict(state)
|
|
215
|
+
updated_state["ticket_engine"] = ticket_engine
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
"last_event_seq": last_event_seq,
|
|
219
|
+
"last_event_at": last_event_at,
|
|
220
|
+
"worker_health": health,
|
|
221
|
+
"effective_current_ticket": effective_ticket,
|
|
222
|
+
"state": updated_state,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def ensure_worker(repo_root: Path, run_id: str) -> dict:
|
|
227
|
+
health = check_worker_health(repo_root, run_id)
|
|
228
|
+
if health.status in {"dead", "mismatch", "invalid"}:
|
|
229
|
+
try:
|
|
230
|
+
clear_worker_metadata(health.artifact_path.parent)
|
|
231
|
+
except Exception:
|
|
232
|
+
pass
|
|
233
|
+
if health.is_alive:
|
|
234
|
+
return {"status": "reused", "health": health}
|
|
235
|
+
|
|
236
|
+
proc, stdout_handle, stderr_handle = spawn_flow_worker(repo_root, run_id)
|
|
237
|
+
return {
|
|
238
|
+
"status": "spawned",
|
|
239
|
+
"health": health,
|
|
240
|
+
"proc": proc,
|
|
241
|
+
"stdout": stdout_handle,
|
|
242
|
+
"stderr": stderr_handle,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
__all__ = [
|
|
247
|
+
"BootstrapCheckResult",
|
|
248
|
+
"IssueSeedResult",
|
|
249
|
+
"bootstrap_check",
|
|
250
|
+
"build_flow_status_snapshot",
|
|
251
|
+
"ensure_worker",
|
|
252
|
+
"format_issue_as_markdown",
|
|
253
|
+
"issue_md_has_content",
|
|
254
|
+
"issue_md_path",
|
|
255
|
+
"seed_issue_from_github",
|
|
256
|
+
"seed_issue_from_text",
|
|
257
|
+
]
|
|
@@ -232,3 +232,65 @@ def git_default_branch(repo_root: Path) -> Optional[str]:
|
|
|
232
232
|
if raw.startswith("origin/"):
|
|
233
233
|
return raw.split("/", 1)[1]
|
|
234
234
|
return raw
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def git_diff_stats(
|
|
238
|
+
repo_root: Path, from_ref: Optional[str] = None, *, include_staged: bool = True
|
|
239
|
+
) -> Optional[dict]:
|
|
240
|
+
"""
|
|
241
|
+
Get diff statistics (insertions/deletions) for changes.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
repo_root: Repository root path
|
|
245
|
+
from_ref: Compare against this ref (e.g., a commit SHA). If None, compares
|
|
246
|
+
working tree against HEAD.
|
|
247
|
+
include_staged: When from_ref is None, include staged changes in the diff.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Dict with insertions, deletions, files_changed, or None on error.
|
|
251
|
+
Example: {"insertions": 47, "deletions": 12, "files_changed": 5}
|
|
252
|
+
"""
|
|
253
|
+
try:
|
|
254
|
+
if from_ref:
|
|
255
|
+
# Compare from_ref to working tree (includes all changes: committed + staged + unstaged)
|
|
256
|
+
proc = run_git(["diff", "--numstat", from_ref], repo_root)
|
|
257
|
+
elif include_staged:
|
|
258
|
+
# Working tree + staged vs HEAD
|
|
259
|
+
proc = run_git(["diff", "--numstat", "HEAD"], repo_root)
|
|
260
|
+
else:
|
|
261
|
+
# Only unstaged changes
|
|
262
|
+
proc = run_git(["diff", "--numstat"], repo_root)
|
|
263
|
+
except GitError:
|
|
264
|
+
return None
|
|
265
|
+
if proc.returncode != 0:
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
insertions = 0
|
|
269
|
+
deletions = 0
|
|
270
|
+
files_changed = 0
|
|
271
|
+
|
|
272
|
+
for line in (proc.stdout or "").strip().splitlines():
|
|
273
|
+
if not line:
|
|
274
|
+
continue
|
|
275
|
+
parts = line.split("\t")
|
|
276
|
+
if len(parts) < 2:
|
|
277
|
+
continue
|
|
278
|
+
# Binary files show "-" for both counts
|
|
279
|
+
add_str, del_str = parts[0], parts[1]
|
|
280
|
+
if add_str != "-":
|
|
281
|
+
try:
|
|
282
|
+
insertions += int(add_str)
|
|
283
|
+
except ValueError:
|
|
284
|
+
pass
|
|
285
|
+
if del_str != "-":
|
|
286
|
+
try:
|
|
287
|
+
deletions += int(del_str)
|
|
288
|
+
except ValueError:
|
|
289
|
+
pass
|
|
290
|
+
files_changed += 1
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
"insertions": insertions,
|
|
294
|
+
"deletions": deletions,
|
|
295
|
+
"files_changed": files_changed,
|
|
296
|
+
}
|
codex_autorunner/core/hub.py
CHANGED
|
@@ -5,7 +5,7 @@ import re
|
|
|
5
5
|
import shutil
|
|
6
6
|
import time
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import Dict, List, Optional, Tuple
|
|
8
|
+
from typing import Callable, Dict, List, Optional, Tuple
|
|
9
9
|
|
|
10
10
|
from ..bootstrap import seed_repo_files
|
|
11
11
|
from ..discovery import DiscoveryRecord, discover_and_init
|
|
@@ -16,12 +16,15 @@ from ..manifest import (
|
|
|
16
16
|
sanitize_repo_id,
|
|
17
17
|
save_manifest,
|
|
18
18
|
)
|
|
19
|
+
from .archive import archive_worktree_snapshot, build_snapshot_id
|
|
19
20
|
from .config import HubConfig, RepoConfig, derive_repo_config, load_hub_config
|
|
20
|
-
from .engine import Engine
|
|
21
|
+
from .engine import AppServerSupervisorFactory, BackendFactory, Engine
|
|
21
22
|
from .git_utils import (
|
|
22
23
|
GitError,
|
|
23
24
|
git_available,
|
|
25
|
+
git_branch,
|
|
24
26
|
git_default_branch,
|
|
27
|
+
git_head_sha,
|
|
25
28
|
git_is_clean,
|
|
26
29
|
git_upstream_status,
|
|
27
30
|
run_git,
|
|
@@ -33,6 +36,9 @@ from .utils import atomic_write
|
|
|
33
36
|
|
|
34
37
|
logger = logging.getLogger("codex_autorunner.hub")
|
|
35
38
|
|
|
39
|
+
BackendFactoryBuilder = Callable[[Path, RepoConfig], BackendFactory]
|
|
40
|
+
AppServerSupervisorFactoryBuilder = Callable[[RepoConfig], AppServerSupervisorFactory]
|
|
41
|
+
|
|
36
42
|
|
|
37
43
|
def _git_failure_detail(proc) -> str:
|
|
38
44
|
return (proc.stderr or proc.stdout or "").strip() or f"exit {proc.returncode}"
|
|
@@ -195,9 +201,30 @@ class RepoRunner:
|
|
|
195
201
|
*,
|
|
196
202
|
repo_config: RepoConfig,
|
|
197
203
|
spawn_fn: Optional[SpawnRunnerFn] = None,
|
|
204
|
+
backend_factory_builder: Optional[BackendFactoryBuilder] = None,
|
|
205
|
+
app_server_supervisor_factory_builder: Optional[
|
|
206
|
+
AppServerSupervisorFactoryBuilder
|
|
207
|
+
] = None,
|
|
208
|
+
agent_id_validator: Optional[Callable[[str], str]] = None,
|
|
198
209
|
):
|
|
199
210
|
self.repo_id = repo_id
|
|
200
|
-
|
|
211
|
+
backend_factory = (
|
|
212
|
+
backend_factory_builder(repo_root, repo_config)
|
|
213
|
+
if backend_factory_builder is not None
|
|
214
|
+
else None
|
|
215
|
+
)
|
|
216
|
+
app_server_supervisor_factory = (
|
|
217
|
+
app_server_supervisor_factory_builder(repo_config)
|
|
218
|
+
if app_server_supervisor_factory_builder is not None
|
|
219
|
+
else None
|
|
220
|
+
)
|
|
221
|
+
self._engine = Engine(
|
|
222
|
+
repo_root,
|
|
223
|
+
config=repo_config,
|
|
224
|
+
backend_factory=backend_factory,
|
|
225
|
+
app_server_supervisor_factory=app_server_supervisor_factory,
|
|
226
|
+
agent_id_validator=agent_id_validator,
|
|
227
|
+
)
|
|
201
228
|
self._controller = ProcessRunnerController(self._engine, spawn_fn=spawn_fn)
|
|
202
229
|
|
|
203
230
|
@property
|
|
@@ -219,21 +246,46 @@ class RepoRunner:
|
|
|
219
246
|
|
|
220
247
|
class HubSupervisor:
|
|
221
248
|
def __init__(
|
|
222
|
-
self,
|
|
249
|
+
self,
|
|
250
|
+
hub_config: HubConfig,
|
|
251
|
+
*,
|
|
252
|
+
spawn_fn: Optional[SpawnRunnerFn] = None,
|
|
253
|
+
backend_factory_builder: Optional[BackendFactoryBuilder] = None,
|
|
254
|
+
app_server_supervisor_factory_builder: Optional[
|
|
255
|
+
AppServerSupervisorFactoryBuilder
|
|
256
|
+
] = None,
|
|
257
|
+
agent_id_validator: Optional[Callable[[str], str]] = None,
|
|
223
258
|
):
|
|
224
259
|
self.hub_config = hub_config
|
|
225
260
|
self.state_path = hub_config.root / ".codex-autorunner" / "hub_state.json"
|
|
226
261
|
self._runners: Dict[str, RepoRunner] = {}
|
|
227
262
|
self._spawn_fn = spawn_fn
|
|
263
|
+
self._backend_factory_builder = backend_factory_builder
|
|
264
|
+
self._app_server_supervisor_factory_builder = (
|
|
265
|
+
app_server_supervisor_factory_builder
|
|
266
|
+
)
|
|
267
|
+
self._agent_id_validator = agent_id_validator
|
|
228
268
|
self.state = load_hub_state(self.state_path, self.hub_config.root)
|
|
229
269
|
self._list_cache_at: Optional[float] = None
|
|
230
270
|
self._list_cache: Optional[List[RepoSnapshot]] = None
|
|
231
271
|
self._reconcile_startup()
|
|
232
272
|
|
|
233
273
|
@classmethod
|
|
234
|
-
def from_path(
|
|
274
|
+
def from_path(
|
|
275
|
+
cls,
|
|
276
|
+
path: Path,
|
|
277
|
+
*,
|
|
278
|
+
backend_factory_builder: Optional[BackendFactoryBuilder] = None,
|
|
279
|
+
app_server_supervisor_factory_builder: Optional[
|
|
280
|
+
AppServerSupervisorFactoryBuilder
|
|
281
|
+
] = None,
|
|
282
|
+
) -> "HubSupervisor":
|
|
235
283
|
config = load_hub_config(path)
|
|
236
|
-
return cls(
|
|
284
|
+
return cls(
|
|
285
|
+
config,
|
|
286
|
+
backend_factory_builder=backend_factory_builder,
|
|
287
|
+
app_server_supervisor_factory_builder=app_server_supervisor_factory_builder,
|
|
288
|
+
)
|
|
237
289
|
|
|
238
290
|
def scan(self) -> List[RepoSnapshot]:
|
|
239
291
|
self._invalidate_list_cache()
|
|
@@ -268,8 +320,24 @@ class HubSupervisor:
|
|
|
268
320
|
repo_config = derive_repo_config(
|
|
269
321
|
self.hub_config, record.absolute_path, load_env=False
|
|
270
322
|
)
|
|
323
|
+
backend_factory = (
|
|
324
|
+
self._backend_factory_builder(record.absolute_path, repo_config)
|
|
325
|
+
if self._backend_factory_builder is not None
|
|
326
|
+
else None
|
|
327
|
+
)
|
|
328
|
+
app_server_supervisor_factory = (
|
|
329
|
+
self._app_server_supervisor_factory_builder(repo_config)
|
|
330
|
+
if self._app_server_supervisor_factory_builder is not None
|
|
331
|
+
else None
|
|
332
|
+
)
|
|
271
333
|
controller = ProcessRunnerController(
|
|
272
|
-
Engine(
|
|
334
|
+
Engine(
|
|
335
|
+
record.absolute_path,
|
|
336
|
+
config=repo_config,
|
|
337
|
+
backend_factory=backend_factory,
|
|
338
|
+
app_server_supervisor_factory=app_server_supervisor_factory,
|
|
339
|
+
agent_id_validator=self._agent_id_validator,
|
|
340
|
+
)
|
|
273
341
|
)
|
|
274
342
|
controller.reconcile()
|
|
275
343
|
except Exception as exc:
|
|
@@ -593,6 +661,9 @@ class HubSupervisor:
|
|
|
593
661
|
worktree_repo_id: str,
|
|
594
662
|
delete_branch: bool = False,
|
|
595
663
|
delete_remote: bool = False,
|
|
664
|
+
archive: bool = True,
|
|
665
|
+
force_archive: bool = False,
|
|
666
|
+
archive_note: Optional[str] = None,
|
|
596
667
|
) -> None:
|
|
597
668
|
self._invalidate_list_cache()
|
|
598
669
|
manifest = load_manifest(self.hub_config.manifest_path, self.hub_config.root)
|
|
@@ -613,6 +684,44 @@ class HubSupervisor:
|
|
|
613
684
|
if runner:
|
|
614
685
|
runner.stop()
|
|
615
686
|
|
|
687
|
+
if archive:
|
|
688
|
+
branch_name = entry.branch or git_branch(worktree_path) or "unknown"
|
|
689
|
+
head_sha = git_head_sha(worktree_path) or "unknown"
|
|
690
|
+
snapshot_id = build_snapshot_id(branch_name, head_sha)
|
|
691
|
+
logger.info(
|
|
692
|
+
"Hub archive worktree start id=%s snapshot_id=%s",
|
|
693
|
+
worktree_repo_id,
|
|
694
|
+
snapshot_id,
|
|
695
|
+
)
|
|
696
|
+
try:
|
|
697
|
+
result = archive_worktree_snapshot(
|
|
698
|
+
base_repo_root=base_path,
|
|
699
|
+
base_repo_id=base.id,
|
|
700
|
+
worktree_repo_root=worktree_path,
|
|
701
|
+
worktree_repo_id=worktree_repo_id,
|
|
702
|
+
branch=branch_name,
|
|
703
|
+
worktree_of=entry.worktree_of,
|
|
704
|
+
note=archive_note,
|
|
705
|
+
snapshot_id=snapshot_id,
|
|
706
|
+
head_sha=head_sha,
|
|
707
|
+
source_path=entry.path,
|
|
708
|
+
)
|
|
709
|
+
except Exception as exc:
|
|
710
|
+
logger.exception(
|
|
711
|
+
"Hub archive worktree failed id=%s snapshot_id=%s",
|
|
712
|
+
worktree_repo_id,
|
|
713
|
+
snapshot_id,
|
|
714
|
+
)
|
|
715
|
+
if not force_archive:
|
|
716
|
+
raise ValueError(f"Worktree archive failed: {exc}") from exc
|
|
717
|
+
else:
|
|
718
|
+
logger.info(
|
|
719
|
+
"Hub archive worktree complete id=%s snapshot_id=%s status=%s",
|
|
720
|
+
worktree_repo_id,
|
|
721
|
+
result.snapshot_id,
|
|
722
|
+
result.status,
|
|
723
|
+
)
|
|
724
|
+
|
|
616
725
|
# Remove worktree from base repo.
|
|
617
726
|
try:
|
|
618
727
|
proc = run_git(
|
|
@@ -777,6 +886,11 @@ class HubSupervisor:
|
|
|
777
886
|
repo_root,
|
|
778
887
|
repo_config=repo_config,
|
|
779
888
|
spawn_fn=self._spawn_fn,
|
|
889
|
+
backend_factory_builder=self._backend_factory_builder,
|
|
890
|
+
app_server_supervisor_factory_builder=(
|
|
891
|
+
self._app_server_supervisor_factory_builder
|
|
892
|
+
),
|
|
893
|
+
agent_id_validator=self._agent_id_validator,
|
|
780
894
|
)
|
|
781
895
|
self._runners[repo_id] = runner
|
|
782
896
|
return runner
|
|
@@ -23,6 +23,18 @@ class NotificationManager:
|
|
|
23
23
|
self._warned_missing: set[str] = set()
|
|
24
24
|
self._enabled_mode = self._parse_enabled(self._cfg.get("enabled"))
|
|
25
25
|
self._events = self._normalize_events(self._cfg.get("events"))
|
|
26
|
+
timeout_raw = self._cfg.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS)
|
|
27
|
+
try:
|
|
28
|
+
timeout_seconds = (
|
|
29
|
+
float(timeout_raw)
|
|
30
|
+
if timeout_raw is not None
|
|
31
|
+
else DEFAULT_TIMEOUT_SECONDS
|
|
32
|
+
)
|
|
33
|
+
except (TypeError, ValueError):
|
|
34
|
+
timeout_seconds = DEFAULT_TIMEOUT_SECONDS
|
|
35
|
+
if timeout_seconds <= 0:
|
|
36
|
+
timeout_seconds = DEFAULT_TIMEOUT_SECONDS
|
|
37
|
+
self._timeout_seconds = timeout_seconds
|
|
26
38
|
self._warn_unknown_events(self._events)
|
|
27
39
|
discord_cfg = self._cfg.get("discord")
|
|
28
40
|
self._discord: Dict[str, Any] = (
|
|
@@ -202,7 +214,7 @@ class NotificationManager:
|
|
|
202
214
|
if not targets:
|
|
203
215
|
return
|
|
204
216
|
try:
|
|
205
|
-
with httpx.Client(timeout=
|
|
217
|
+
with httpx.Client(timeout=self._timeout_seconds) as client:
|
|
206
218
|
self._send_sync(client, targets, message)
|
|
207
219
|
except Exception as exc:
|
|
208
220
|
self._log_warning("Notification delivery failed", exc)
|
|
@@ -216,7 +228,7 @@ class NotificationManager:
|
|
|
216
228
|
if not targets:
|
|
217
229
|
return
|
|
218
230
|
try:
|
|
219
|
-
async with httpx.AsyncClient(timeout=
|
|
231
|
+
async with httpx.AsyncClient(timeout=self._timeout_seconds) as client:
|
|
220
232
|
await self._send_async(client, targets, message)
|
|
221
233
|
except Exception as exc:
|
|
222
234
|
self._log_warning("Notification delivery failed", exc)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from .agent_backend import AgentBackend, AgentEvent, AgentEventType, now_iso
|
|
2
|
+
from .run_event import (
|
|
3
|
+
ApprovalRequested,
|
|
4
|
+
Completed,
|
|
5
|
+
Failed,
|
|
6
|
+
OutputDelta,
|
|
7
|
+
RunEvent,
|
|
8
|
+
RunNotice,
|
|
9
|
+
Started,
|
|
10
|
+
TokenUsage,
|
|
11
|
+
ToolCall,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AgentBackend",
|
|
16
|
+
"AgentEvent",
|
|
17
|
+
"AgentEventType",
|
|
18
|
+
"now_iso",
|
|
19
|
+
"RunEvent",
|
|
20
|
+
"Started",
|
|
21
|
+
"OutputDelta",
|
|
22
|
+
"ToolCall",
|
|
23
|
+
"ApprovalRequested",
|
|
24
|
+
"TokenUsage",
|
|
25
|
+
"RunNotice",
|
|
26
|
+
"Completed",
|
|
27
|
+
"Failed",
|
|
28
|
+
]
|
|
@@ -117,15 +117,15 @@ class AgentBackend:
|
|
|
117
117
|
async def start_session(self, target: dict, context: dict) -> str:
|
|
118
118
|
raise NotImplementedError
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
def run_turn(
|
|
121
121
|
self, session_id: str, message: str
|
|
122
122
|
) -> AsyncGenerator[AgentEvent, None]:
|
|
123
123
|
raise NotImplementedError
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
def stream_events(self, session_id: str) -> AsyncGenerator[AgentEvent, None]:
|
|
126
126
|
raise NotImplementedError
|
|
127
127
|
|
|
128
|
-
|
|
128
|
+
def run_turn_events(
|
|
129
129
|
self, session_id: str, message: str
|
|
130
130
|
) -> AsyncGenerator[Any, None]:
|
|
131
131
|
raise NotImplementedError
|
|
@@ -140,3 +140,11 @@ class AgentBackend:
|
|
|
140
140
|
self, description: str, context: Optional[Dict[str, Any]] = None
|
|
141
141
|
) -> bool:
|
|
142
142
|
raise NotImplementedError
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
__all__ = [
|
|
146
|
+
"AgentBackend",
|
|
147
|
+
"AgentEvent",
|
|
148
|
+
"AgentEventType",
|
|
149
|
+
"now_iso",
|
|
150
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Protocol for backend orchestrators used by the Engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, AsyncGenerator, Optional, Protocol
|
|
6
|
+
|
|
7
|
+
from .run_event import RunEvent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BackendOrchestrator(Protocol):
|
|
11
|
+
def run_turn(
|
|
12
|
+
self,
|
|
13
|
+
*,
|
|
14
|
+
agent_id: str,
|
|
15
|
+
state: Any,
|
|
16
|
+
prompt: str,
|
|
17
|
+
model: Optional[str],
|
|
18
|
+
reasoning: Optional[str],
|
|
19
|
+
session_key: str,
|
|
20
|
+
) -> AsyncGenerator[RunEvent, None]: ...
|
|
21
|
+
|
|
22
|
+
async def interrupt(self, agent_id: str, state: Any) -> None: ...
|
|
23
|
+
|
|
24
|
+
def get_thread_id(self, session_key: str) -> Optional[str]: ...
|
|
25
|
+
|
|
26
|
+
def set_thread_id(self, session_key: str, thread_id: str) -> None: ...
|
|
27
|
+
|
|
28
|
+
def build_app_server_supervisor(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
event_prefix: str,
|
|
32
|
+
notification_handler: Optional[Any] = None,
|
|
33
|
+
) -> Optional[Any]: ...
|
|
34
|
+
|
|
35
|
+
def ensure_opencode_supervisor(self) -> Optional[Any]: ...
|
|
36
|
+
|
|
37
|
+
def get_last_turn_id(self) -> Optional[str]: ...
|
|
38
|
+
|
|
39
|
+
def get_last_thread_info(self) -> Optional[dict[str, Any]]: ...
|
|
40
|
+
|
|
41
|
+
def get_last_token_total(self) -> Optional[dict[str, Any]]: ...
|