codex-autorunner 1.1.0__py3-none-any.whl → 1.2.1__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/agents/opencode/client.py +113 -4
- codex_autorunner/agents/opencode/supervisor.py +4 -0
- codex_autorunner/agents/registry.py +17 -7
- codex_autorunner/bootstrap.py +219 -1
- codex_autorunner/core/__init__.py +17 -1
- codex_autorunner/core/about_car.py +124 -11
- codex_autorunner/core/app_server_threads.py +6 -0
- codex_autorunner/core/config.py +238 -3
- codex_autorunner/core/context_awareness.py +39 -0
- codex_autorunner/core/docs.py +0 -122
- codex_autorunner/core/filebox.py +265 -0
- codex_autorunner/core/flows/controller.py +71 -1
- codex_autorunner/core/flows/reconciler.py +4 -1
- codex_autorunner/core/flows/runtime.py +22 -0
- codex_autorunner/core/flows/store.py +61 -9
- codex_autorunner/core/flows/transition.py +23 -16
- codex_autorunner/core/flows/ux_helpers.py +18 -3
- codex_autorunner/core/flows/worker_process.py +32 -6
- codex_autorunner/core/hub.py +198 -41
- codex_autorunner/core/lifecycle_events.py +253 -0
- codex_autorunner/core/path_utils.py +2 -1
- codex_autorunner/core/pma_audit.py +224 -0
- codex_autorunner/core/pma_context.py +683 -0
- codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
- codex_autorunner/core/pma_lifecycle.py +527 -0
- codex_autorunner/core/pma_queue.py +367 -0
- codex_autorunner/core/pma_safety.py +221 -0
- codex_autorunner/core/pma_state.py +115 -0
- codex_autorunner/core/ports/agent_backend.py +2 -5
- codex_autorunner/core/ports/run_event.py +1 -4
- codex_autorunner/core/prompt.py +0 -80
- codex_autorunner/core/prompts.py +56 -172
- codex_autorunner/core/redaction.py +0 -4
- codex_autorunner/core/review_context.py +11 -9
- codex_autorunner/core/runner_controller.py +35 -33
- codex_autorunner/core/runner_state.py +147 -0
- codex_autorunner/core/runtime.py +829 -0
- codex_autorunner/core/sqlite_utils.py +13 -4
- codex_autorunner/core/state.py +7 -10
- codex_autorunner/core/state_roots.py +5 -0
- codex_autorunner/core/templates/__init__.py +39 -0
- codex_autorunner/core/templates/git_mirror.py +234 -0
- codex_autorunner/core/templates/provenance.py +56 -0
- codex_autorunner/core/templates/scan_cache.py +120 -0
- codex_autorunner/core/ticket_linter_cli.py +17 -0
- codex_autorunner/core/ticket_manager_cli.py +154 -92
- codex_autorunner/core/time_utils.py +11 -0
- codex_autorunner/core/types.py +18 -0
- codex_autorunner/core/utils.py +34 -6
- codex_autorunner/flows/review/service.py +23 -25
- codex_autorunner/flows/ticket_flow/definition.py +43 -1
- codex_autorunner/integrations/agents/__init__.py +2 -0
- codex_autorunner/integrations/agents/backend_orchestrator.py +18 -0
- codex_autorunner/integrations/agents/codex_backend.py +19 -8
- codex_autorunner/integrations/agents/runner.py +3 -8
- codex_autorunner/integrations/agents/wiring.py +8 -0
- codex_autorunner/integrations/telegram/adapter.py +1 -1
- codex_autorunner/integrations/telegram/config.py +1 -1
- codex_autorunner/integrations/telegram/doctor.py +228 -6
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
- codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +346 -58
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +202 -45
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +18 -7
- codex_autorunner/integrations/telegram/handlers/messages.py +34 -3
- codex_autorunner/integrations/telegram/helpers.py +1 -3
- codex_autorunner/integrations/telegram/runtime.py +9 -4
- codex_autorunner/integrations/telegram/service.py +30 -0
- codex_autorunner/integrations/telegram/state.py +38 -0
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +10 -4
- codex_autorunner/integrations/telegram/transport.py +10 -3
- codex_autorunner/integrations/templates/__init__.py +27 -0
- codex_autorunner/integrations/templates/scan_agent.py +312 -0
- codex_autorunner/server.py +2 -2
- codex_autorunner/static/agentControls.js +21 -5
- codex_autorunner/static/app.js +115 -11
- codex_autorunner/static/archive.js +274 -81
- codex_autorunner/static/archiveApi.js +21 -0
- codex_autorunner/static/chatUploads.js +137 -0
- codex_autorunner/static/constants.js +1 -1
- codex_autorunner/static/docChatCore.js +185 -13
- codex_autorunner/static/fileChat.js +68 -40
- codex_autorunner/static/fileboxUi.js +159 -0
- codex_autorunner/static/hub.js +46 -81
- codex_autorunner/static/index.html +303 -24
- codex_autorunner/static/messages.js +82 -4
- codex_autorunner/static/notifications.js +288 -0
- codex_autorunner/static/pma.js +1167 -0
- codex_autorunner/static/settings.js +3 -0
- codex_autorunner/static/streamUtils.js +57 -0
- codex_autorunner/static/styles.css +9141 -6742
- codex_autorunner/static/templateReposSettings.js +225 -0
- codex_autorunner/static/terminalManager.js +22 -3
- codex_autorunner/static/ticketChatActions.js +165 -3
- codex_autorunner/static/ticketChatStream.js +17 -119
- codex_autorunner/static/ticketEditor.js +41 -13
- codex_autorunner/static/ticketTemplates.js +798 -0
- codex_autorunner/static/tickets.js +69 -19
- codex_autorunner/static/turnEvents.js +27 -0
- codex_autorunner/static/turnResume.js +33 -0
- codex_autorunner/static/utils.js +28 -0
- codex_autorunner/static/workspace.js +258 -44
- codex_autorunner/static/workspaceFileBrowser.js +6 -4
- codex_autorunner/surfaces/cli/cli.py +1465 -155
- codex_autorunner/surfaces/cli/pma_cli.py +817 -0
- codex_autorunner/surfaces/web/app.py +253 -49
- codex_autorunner/surfaces/web/routes/__init__.py +4 -0
- codex_autorunner/surfaces/web/routes/analytics.py +29 -22
- codex_autorunner/surfaces/web/routes/archive.py +197 -0
- codex_autorunner/surfaces/web/routes/file_chat.py +297 -36
- codex_autorunner/surfaces/web/routes/filebox.py +227 -0
- codex_autorunner/surfaces/web/routes/flows.py +219 -29
- codex_autorunner/surfaces/web/routes/messages.py +70 -39
- codex_autorunner/surfaces/web/routes/pma.py +1652 -0
- codex_autorunner/surfaces/web/routes/repos.py +1 -1
- codex_autorunner/surfaces/web/routes/shared.py +0 -3
- codex_autorunner/surfaces/web/routes/templates.py +634 -0
- codex_autorunner/surfaces/web/runner_manager.py +2 -2
- codex_autorunner/surfaces/web/schemas.py +81 -18
- codex_autorunner/tickets/agent_pool.py +27 -0
- codex_autorunner/tickets/files.py +33 -16
- codex_autorunner/tickets/lint.py +50 -0
- codex_autorunner/tickets/models.py +3 -0
- codex_autorunner/tickets/outbox.py +41 -5
- codex_autorunner/tickets/runner.py +350 -69
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/METADATA +15 -19
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/RECORD +132 -101
- codex_autorunner/core/adapter_utils.py +0 -21
- codex_autorunner/core/engine.py +0 -3302
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from ..bootstrap import ensure_pma_docs
|
|
9
|
+
from ..tickets.files import list_ticket_paths, safe_relpath, ticket_is_done
|
|
10
|
+
from ..tickets.outbox import parse_dispatch, resolve_outbox_paths
|
|
11
|
+
from .config import load_hub_config, load_repo_config
|
|
12
|
+
from .flows.models import FlowRunStatus
|
|
13
|
+
from .flows.store import FlowStore
|
|
14
|
+
from .hub import HubSupervisor
|
|
15
|
+
from .state_roots import resolve_hub_templates_root
|
|
16
|
+
|
|
17
|
+
PMA_MAX_REPOS = 25
|
|
18
|
+
PMA_MAX_MESSAGES = 10
|
|
19
|
+
PMA_MAX_TEXT = 800
|
|
20
|
+
PMA_MAX_TEMPLATE_REPOS = 25
|
|
21
|
+
PMA_MAX_TEMPLATE_FIELD_CHARS = 120
|
|
22
|
+
PMA_MAX_PMA_FILES = 50
|
|
23
|
+
PMA_MAX_LIFECYCLE_EVENTS = 20
|
|
24
|
+
|
|
25
|
+
# Keep this short and stable; see ticket TICKET-001 for rationale.
|
|
26
|
+
PMA_FASTPATH = """<pma_fastpath>
|
|
27
|
+
You are PMA inside Codex Autorunner (CAR). Treat the filesystem as truth; prefer creating/updating CAR artifacts over "chat-only" plans.
|
|
28
|
+
|
|
29
|
+
First-turn routine:
|
|
30
|
+
1) Read <user_message> and <hub_snapshot>.
|
|
31
|
+
2) If hub_snapshot.inbox has entries, handle them first (these are paused runs needing input):
|
|
32
|
+
- Summarize the dispatch question.
|
|
33
|
+
- Answer it or propose the next minimal action.
|
|
34
|
+
- Include the item.open_url so the user can jump straight to the repo Inbox tab.
|
|
35
|
+
3) If the request is new work:
|
|
36
|
+
- Identify the target repo(s).
|
|
37
|
+
- Prefer hub-owned worktrees for changes.
|
|
38
|
+
- Create/adjust repo tickets under each repo's `.codex-autorunner/tickets/`.
|
|
39
|
+
|
|
40
|
+
Web UI map (user perspective):
|
|
41
|
+
- Hub root: `/` (repos list + global notifications).
|
|
42
|
+
- Repo view: `/repos/<repo_id>/` tabs: Tickets | Inbox | Workspace | Terminal | Analytics | Archive.
|
|
43
|
+
- Tickets: edit queue; Inbox: paused run dispatches; Workspace: active_context/spec/decisions.
|
|
44
|
+
</pma_fastpath>
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
# Defaults used when hub config is not available (should be rare).
|
|
48
|
+
PMA_DOCS_MAX_CHARS = 12_000
|
|
49
|
+
PMA_ACTIVE_CONTEXT_MAX_LINES = 200
|
|
50
|
+
PMA_CONTEXT_LOG_TAIL_LINES = 120
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _tail_lines(text: str, max_lines: int) -> str:
|
|
54
|
+
if max_lines <= 0:
|
|
55
|
+
return ""
|
|
56
|
+
lines = (text or "").splitlines()
|
|
57
|
+
if len(lines) <= max_lines:
|
|
58
|
+
return "\n".join(lines)
|
|
59
|
+
return "\n".join(lines[-max_lines:])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_pma_workspace_docs(hub_root: Path) -> dict[str, Any]:
|
|
63
|
+
"""Load hub-level PMA workspace docs for prompt injection.
|
|
64
|
+
|
|
65
|
+
These docs act as durable memory and working context for PMA.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
ensure_pma_docs(hub_root)
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
docs_max_chars = PMA_DOCS_MAX_CHARS
|
|
73
|
+
active_context_max_lines = PMA_ACTIVE_CONTEXT_MAX_LINES
|
|
74
|
+
context_log_tail_lines = PMA_CONTEXT_LOG_TAIL_LINES
|
|
75
|
+
try:
|
|
76
|
+
hub_config = load_hub_config(hub_root)
|
|
77
|
+
pma_cfg = getattr(hub_config, "pma", None)
|
|
78
|
+
if pma_cfg is not None:
|
|
79
|
+
docs_max_chars = int(getattr(pma_cfg, "docs_max_chars", docs_max_chars))
|
|
80
|
+
active_context_max_lines = int(
|
|
81
|
+
getattr(pma_cfg, "active_context_max_lines", active_context_max_lines)
|
|
82
|
+
)
|
|
83
|
+
context_log_tail_lines = int(
|
|
84
|
+
getattr(pma_cfg, "context_log_tail_lines", context_log_tail_lines)
|
|
85
|
+
)
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
pma_dir = hub_root / ".codex-autorunner" / "pma"
|
|
90
|
+
agents_path = pma_dir / "AGENTS.md"
|
|
91
|
+
active_context_path = pma_dir / "active_context.md"
|
|
92
|
+
context_log_path = pma_dir / "context_log.md"
|
|
93
|
+
|
|
94
|
+
def _read(path: Path) -> str:
|
|
95
|
+
try:
|
|
96
|
+
return path.read_text(encoding="utf-8")
|
|
97
|
+
except Exception:
|
|
98
|
+
return ""
|
|
99
|
+
|
|
100
|
+
agents = _truncate(_read(agents_path), docs_max_chars)
|
|
101
|
+
active_context = _read(active_context_path)
|
|
102
|
+
active_context_lines = len((active_context or "").splitlines())
|
|
103
|
+
active_context = _truncate(active_context, docs_max_chars)
|
|
104
|
+
context_log_tail = _tail_lines(_read(context_log_path), context_log_tail_lines)
|
|
105
|
+
context_log_tail = _truncate(context_log_tail, docs_max_chars)
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
"agents": agents,
|
|
109
|
+
"active_context": active_context,
|
|
110
|
+
"active_context_line_count": active_context_lines,
|
|
111
|
+
"active_context_max_lines": active_context_max_lines,
|
|
112
|
+
"context_log_tail": context_log_tail,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _truncate(text: Optional[str], limit: int) -> str:
|
|
117
|
+
raw = text or ""
|
|
118
|
+
if len(raw) <= limit:
|
|
119
|
+
return raw
|
|
120
|
+
return raw[: max(0, limit - 3)] + "..."
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _trim_extra(extra: Any, limit: int) -> Any:
|
|
124
|
+
if extra is None:
|
|
125
|
+
return None
|
|
126
|
+
if isinstance(extra, str):
|
|
127
|
+
return _truncate(extra, limit)
|
|
128
|
+
try:
|
|
129
|
+
raw = json.dumps(extra, ensure_ascii=True, sort_keys=True, default=str)
|
|
130
|
+
except Exception:
|
|
131
|
+
raw = str(extra)
|
|
132
|
+
if len(raw) <= limit:
|
|
133
|
+
return extra
|
|
134
|
+
return {
|
|
135
|
+
"_omitted": True,
|
|
136
|
+
"note": "extra omitted due to size",
|
|
137
|
+
"preview": _truncate(raw, limit),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _load_template_scan_summary(
|
|
142
|
+
hub_root: Optional[Path],
|
|
143
|
+
*,
|
|
144
|
+
max_field_chars: int = PMA_MAX_TEMPLATE_FIELD_CHARS,
|
|
145
|
+
) -> Optional[dict[str, Any]]:
|
|
146
|
+
if hub_root is None:
|
|
147
|
+
return None
|
|
148
|
+
try:
|
|
149
|
+
scans_root = resolve_hub_templates_root(hub_root) / "scans"
|
|
150
|
+
if not scans_root.exists():
|
|
151
|
+
return None
|
|
152
|
+
candidates = [
|
|
153
|
+
entry
|
|
154
|
+
for entry in scans_root.iterdir()
|
|
155
|
+
if entry.is_file() and entry.suffix == ".json"
|
|
156
|
+
]
|
|
157
|
+
if not candidates:
|
|
158
|
+
return None
|
|
159
|
+
newest = max(candidates, key=lambda entry: entry.stat().st_mtime)
|
|
160
|
+
payload = json.loads(newest.read_text(encoding="utf-8"))
|
|
161
|
+
if not isinstance(payload, dict):
|
|
162
|
+
return None
|
|
163
|
+
return {
|
|
164
|
+
"repo_id": _truncate(str(payload.get("repo_id", "")), max_field_chars),
|
|
165
|
+
"decision": _truncate(str(payload.get("decision", "")), max_field_chars),
|
|
166
|
+
"severity": _truncate(str(payload.get("severity", "")), max_field_chars),
|
|
167
|
+
"scanned_at": _truncate(
|
|
168
|
+
str(payload.get("scanned_at", "")), max_field_chars
|
|
169
|
+
),
|
|
170
|
+
}
|
|
171
|
+
except Exception:
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _build_templates_snapshot(
|
|
176
|
+
supervisor: HubSupervisor,
|
|
177
|
+
*,
|
|
178
|
+
hub_root: Optional[Path] = None,
|
|
179
|
+
max_repos: int = PMA_MAX_TEMPLATE_REPOS,
|
|
180
|
+
max_field_chars: int = PMA_MAX_TEMPLATE_FIELD_CHARS,
|
|
181
|
+
) -> dict[str, Any]:
|
|
182
|
+
hub_config = getattr(supervisor, "hub_config", None)
|
|
183
|
+
templates_cfg = getattr(hub_config, "templates", None)
|
|
184
|
+
if templates_cfg is None:
|
|
185
|
+
return {"enabled": False, "repos": []}
|
|
186
|
+
repos = []
|
|
187
|
+
for repo in templates_cfg.repos[: max(0, max_repos)]:
|
|
188
|
+
repos.append(
|
|
189
|
+
{
|
|
190
|
+
"id": _truncate(repo.id, max_field_chars),
|
|
191
|
+
"default_ref": _truncate(repo.default_ref, max_field_chars),
|
|
192
|
+
"trusted": bool(repo.trusted),
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
payload: dict[str, Any] = {
|
|
196
|
+
"enabled": bool(templates_cfg.enabled),
|
|
197
|
+
"repos": repos,
|
|
198
|
+
}
|
|
199
|
+
scan_summary = _load_template_scan_summary(
|
|
200
|
+
hub_root, max_field_chars=max_field_chars
|
|
201
|
+
)
|
|
202
|
+
if scan_summary:
|
|
203
|
+
payload["last_scan"] = scan_summary
|
|
204
|
+
return payload
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def load_pma_prompt(hub_root: Path) -> str:
|
|
208
|
+
path = hub_root / ".codex-autorunner" / "pma" / "prompt.md"
|
|
209
|
+
try:
|
|
210
|
+
ensure_pma_docs(hub_root)
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
try:
|
|
214
|
+
return path.read_text(encoding="utf-8")
|
|
215
|
+
except Exception:
|
|
216
|
+
return ""
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _render_ticket_flow_summary(summary: Optional[dict[str, Any]]) -> str:
|
|
220
|
+
if not summary:
|
|
221
|
+
return "null"
|
|
222
|
+
status = summary.get("status")
|
|
223
|
+
done_count = summary.get("done_count")
|
|
224
|
+
total_count = summary.get("total_count")
|
|
225
|
+
current_step = summary.get("current_step")
|
|
226
|
+
parts: list[str] = []
|
|
227
|
+
if status is not None:
|
|
228
|
+
parts.append(f"status={status}")
|
|
229
|
+
if done_count is not None and total_count is not None:
|
|
230
|
+
parts.append(f"done={done_count}/{total_count}")
|
|
231
|
+
if current_step is not None:
|
|
232
|
+
parts.append(f"step={current_step}")
|
|
233
|
+
if not parts:
|
|
234
|
+
return "null"
|
|
235
|
+
return " ".join(parts)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _render_hub_snapshot(
|
|
239
|
+
snapshot: dict[str, Any],
|
|
240
|
+
*,
|
|
241
|
+
max_repos: int = PMA_MAX_REPOS,
|
|
242
|
+
max_messages: int = PMA_MAX_MESSAGES,
|
|
243
|
+
max_text_chars: int = PMA_MAX_TEXT,
|
|
244
|
+
max_template_repos: int = PMA_MAX_TEMPLATE_REPOS,
|
|
245
|
+
max_field_chars: int = PMA_MAX_TEMPLATE_FIELD_CHARS,
|
|
246
|
+
max_pma_files: int = PMA_MAX_PMA_FILES,
|
|
247
|
+
max_lifecycle_events: int = PMA_MAX_LIFECYCLE_EVENTS,
|
|
248
|
+
) -> str:
|
|
249
|
+
lines: list[str] = []
|
|
250
|
+
|
|
251
|
+
inbox = snapshot.get("inbox") or []
|
|
252
|
+
if inbox:
|
|
253
|
+
lines.append("Inbox (paused runs needing attention):")
|
|
254
|
+
for item in list(inbox)[: max(0, max_messages)]:
|
|
255
|
+
repo_id = _truncate(str(item.get("repo_id", "")), max_field_chars)
|
|
256
|
+
run_id = _truncate(str(item.get("run_id", "")), max_field_chars)
|
|
257
|
+
seq = _truncate(str(item.get("seq", "")), max_field_chars)
|
|
258
|
+
dispatch = item.get("dispatch") or {}
|
|
259
|
+
mode = _truncate(str(dispatch.get("mode", "")), max_field_chars)
|
|
260
|
+
handoff = bool(dispatch.get("is_handoff"))
|
|
261
|
+
lines.append(
|
|
262
|
+
f"- repo_id={repo_id} run_id={run_id} seq={seq} mode={mode} "
|
|
263
|
+
f"handoff={str(handoff).lower()}"
|
|
264
|
+
)
|
|
265
|
+
title = dispatch.get("title")
|
|
266
|
+
if title:
|
|
267
|
+
lines.append(f" title: {_truncate(str(title), max_text_chars)}")
|
|
268
|
+
body = dispatch.get("body")
|
|
269
|
+
if body:
|
|
270
|
+
lines.append(f" body: {_truncate(str(body), max_text_chars)}")
|
|
271
|
+
files = item.get("files") or []
|
|
272
|
+
if files:
|
|
273
|
+
display = [
|
|
274
|
+
_truncate(str(name), max_field_chars)
|
|
275
|
+
for name in list(files)[: max(0, max_pma_files)]
|
|
276
|
+
]
|
|
277
|
+
lines.append(f" attachments: [{', '.join(display)}]")
|
|
278
|
+
open_url = item.get("open_url")
|
|
279
|
+
if open_url:
|
|
280
|
+
lines.append(f" open_url: {_truncate(str(open_url), max_field_chars)}")
|
|
281
|
+
lines.append("")
|
|
282
|
+
|
|
283
|
+
repos = snapshot.get("repos") or []
|
|
284
|
+
if repos:
|
|
285
|
+
lines.append("Repos:")
|
|
286
|
+
for repo in list(repos)[: max(0, max_repos)]:
|
|
287
|
+
repo_id = _truncate(str(repo.get("id", "")), max_field_chars)
|
|
288
|
+
display_name = _truncate(str(repo.get("display_name", "")), max_field_chars)
|
|
289
|
+
status = _truncate(str(repo.get("status", "")), max_field_chars)
|
|
290
|
+
last_run_id = _truncate(str(repo.get("last_run_id", "")), max_field_chars)
|
|
291
|
+
last_exit = _truncate(str(repo.get("last_exit_code", "")), max_field_chars)
|
|
292
|
+
ticket_flow = _render_ticket_flow_summary(repo.get("ticket_flow"))
|
|
293
|
+
lines.append(
|
|
294
|
+
f"- {repo_id} ({display_name}): status={status} "
|
|
295
|
+
f"last_run_id={last_run_id} last_exit_code={last_exit} "
|
|
296
|
+
f"ticket_flow={ticket_flow}"
|
|
297
|
+
)
|
|
298
|
+
lines.append("")
|
|
299
|
+
|
|
300
|
+
templates = snapshot.get("templates") or {}
|
|
301
|
+
template_repos = templates.get("repos") or []
|
|
302
|
+
template_scan = templates.get("last_scan")
|
|
303
|
+
if templates.get("enabled") or template_repos or template_scan:
|
|
304
|
+
enabled = bool(templates.get("enabled"))
|
|
305
|
+
lines.append("Templates:")
|
|
306
|
+
lines.append(f"- enabled={str(enabled).lower()}")
|
|
307
|
+
if template_repos:
|
|
308
|
+
items: list[str] = []
|
|
309
|
+
for repo in list(template_repos)[: max(0, max_template_repos)]:
|
|
310
|
+
repo_id = _truncate(str(repo.get("id", "")), max_field_chars)
|
|
311
|
+
default_ref = _truncate(
|
|
312
|
+
str(repo.get("default_ref", "")), max_field_chars
|
|
313
|
+
)
|
|
314
|
+
trusted = bool(repo.get("trusted"))
|
|
315
|
+
items.append(f"{repo_id}@{default_ref} trusted={str(trusted).lower()}")
|
|
316
|
+
lines.append(f"- repos: [{', '.join(items)}]")
|
|
317
|
+
if template_scan:
|
|
318
|
+
repo_id = _truncate(str(template_scan.get("repo_id", "")), max_field_chars)
|
|
319
|
+
decision = _truncate(
|
|
320
|
+
str(template_scan.get("decision", "")), max_field_chars
|
|
321
|
+
)
|
|
322
|
+
severity = _truncate(
|
|
323
|
+
str(template_scan.get("severity", "")), max_field_chars
|
|
324
|
+
)
|
|
325
|
+
scanned_at = _truncate(
|
|
326
|
+
str(template_scan.get("scanned_at", "")), max_field_chars
|
|
327
|
+
)
|
|
328
|
+
lines.append(
|
|
329
|
+
f"- last_scan: {repo_id} {decision} {severity} {scanned_at}".strip()
|
|
330
|
+
)
|
|
331
|
+
lines.append("")
|
|
332
|
+
|
|
333
|
+
pma_files = snapshot.get("pma_files") or {}
|
|
334
|
+
inbox_files = pma_files.get("inbox") or []
|
|
335
|
+
outbox_files = pma_files.get("outbox") or []
|
|
336
|
+
if inbox_files or outbox_files:
|
|
337
|
+
lines.append("PMA files:")
|
|
338
|
+
if inbox_files:
|
|
339
|
+
files = [
|
|
340
|
+
_truncate(str(name), max_field_chars)
|
|
341
|
+
for name in list(inbox_files)[: max(0, max_pma_files)]
|
|
342
|
+
]
|
|
343
|
+
lines.append(f"- inbox: [{', '.join(files)}]")
|
|
344
|
+
if outbox_files:
|
|
345
|
+
files = [
|
|
346
|
+
_truncate(str(name), max_field_chars)
|
|
347
|
+
for name in list(outbox_files)[: max(0, max_pma_files)]
|
|
348
|
+
]
|
|
349
|
+
lines.append(f"- outbox: [{', '.join(files)}]")
|
|
350
|
+
lines.append("")
|
|
351
|
+
|
|
352
|
+
lifecycle_events = snapshot.get("lifecycle_events") or []
|
|
353
|
+
if lifecycle_events:
|
|
354
|
+
lines.append("Lifecycle events (recent):")
|
|
355
|
+
for event in list(lifecycle_events)[: max(0, max_lifecycle_events)]:
|
|
356
|
+
timestamp = _truncate(str(event.get("timestamp", "")), max_field_chars)
|
|
357
|
+
event_type = _truncate(str(event.get("event_type", "")), max_field_chars)
|
|
358
|
+
repo_id = _truncate(str(event.get("repo_id", "")), max_field_chars)
|
|
359
|
+
run_id = _truncate(str(event.get("run_id", "")), max_field_chars)
|
|
360
|
+
lines.append(
|
|
361
|
+
f"- {timestamp} {event_type} repo_id={repo_id} run_id={run_id}"
|
|
362
|
+
)
|
|
363
|
+
lines.append("")
|
|
364
|
+
|
|
365
|
+
if lines and lines[-1] == "":
|
|
366
|
+
lines.pop()
|
|
367
|
+
return "\n".join(lines)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def format_pma_prompt(
|
|
371
|
+
base_prompt: str,
|
|
372
|
+
snapshot: dict[str, Any],
|
|
373
|
+
message: str,
|
|
374
|
+
hub_root: Optional[Path] = None,
|
|
375
|
+
) -> str:
|
|
376
|
+
limits = snapshot.get("limits") or {}
|
|
377
|
+
snapshot_text = _render_hub_snapshot(
|
|
378
|
+
snapshot,
|
|
379
|
+
max_repos=limits.get("max_repos", PMA_MAX_REPOS),
|
|
380
|
+
max_messages=limits.get("max_messages", PMA_MAX_MESSAGES),
|
|
381
|
+
max_text_chars=limits.get("max_text_chars", PMA_MAX_TEXT),
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
pma_docs: Optional[dict[str, Any]] = None
|
|
385
|
+
if hub_root is not None:
|
|
386
|
+
try:
|
|
387
|
+
pma_docs = load_pma_workspace_docs(hub_root)
|
|
388
|
+
except Exception:
|
|
389
|
+
pma_docs = None
|
|
390
|
+
|
|
391
|
+
prompt = f"{base_prompt}\n\n"
|
|
392
|
+
prompt += (
|
|
393
|
+
"Ops guide: `.codex-autorunner/pma/ABOUT_CAR.md`.\n"
|
|
394
|
+
"Durable guidance: `.codex-autorunner/pma/AGENTS.md`.\n"
|
|
395
|
+
"Working context: `.codex-autorunner/pma/active_context.md`.\n"
|
|
396
|
+
"History: `.codex-autorunner/pma/context_log.md`.\n"
|
|
397
|
+
"To send a file to the user, write it to `.codex-autorunner/pma/outbox/`.\n"
|
|
398
|
+
"User uploaded files are in `.codex-autorunner/pma/inbox/`.\n\n"
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
if pma_docs:
|
|
402
|
+
max_lines = pma_docs.get("active_context_max_lines")
|
|
403
|
+
line_count = pma_docs.get("active_context_line_count")
|
|
404
|
+
prompt += (
|
|
405
|
+
"<pma_workspace_docs>\n"
|
|
406
|
+
"<AGENTS_MD>\n"
|
|
407
|
+
f"{pma_docs.get('agents', '')}\n"
|
|
408
|
+
"</AGENTS_MD>\n"
|
|
409
|
+
"<ACTIVE_CONTEXT_MD>\n"
|
|
410
|
+
f"{pma_docs.get('active_context', '')}\n"
|
|
411
|
+
"</ACTIVE_CONTEXT_MD>\n"
|
|
412
|
+
f"<ACTIVE_CONTEXT_BUDGET lines='{max_lines}' current_lines='{line_count}' />\n"
|
|
413
|
+
"<CONTEXT_LOG_TAIL_MD>\n"
|
|
414
|
+
f"{pma_docs.get('context_log_tail', '')}\n"
|
|
415
|
+
"</CONTEXT_LOG_TAIL_MD>\n"
|
|
416
|
+
"</pma_workspace_docs>\n\n"
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
prompt += f"{PMA_FASTPATH}\n\n"
|
|
420
|
+
prompt += (
|
|
421
|
+
"<hub_snapshot>\n"
|
|
422
|
+
f"{snapshot_text}\n"
|
|
423
|
+
"</hub_snapshot>\n\n"
|
|
424
|
+
"<user_message>\n"
|
|
425
|
+
f"{message}\n"
|
|
426
|
+
"</user_message>\n"
|
|
427
|
+
)
|
|
428
|
+
return prompt
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _get_ticket_flow_summary(repo_path: Path) -> Optional[dict[str, Any]]:
|
|
432
|
+
db_path = repo_path / ".codex-autorunner" / "flows.db"
|
|
433
|
+
if not db_path.exists():
|
|
434
|
+
return None
|
|
435
|
+
try:
|
|
436
|
+
config = load_repo_config(repo_path)
|
|
437
|
+
with FlowStore(db_path, durable=config.durable_writes) as store:
|
|
438
|
+
runs = store.list_flow_runs(flow_type="ticket_flow")
|
|
439
|
+
if not runs:
|
|
440
|
+
return None
|
|
441
|
+
latest = runs[0]
|
|
442
|
+
|
|
443
|
+
ticket_dir = repo_path / ".codex-autorunner" / "tickets"
|
|
444
|
+
total = 0
|
|
445
|
+
done = 0
|
|
446
|
+
for path in list_ticket_paths(ticket_dir):
|
|
447
|
+
total += 1
|
|
448
|
+
try:
|
|
449
|
+
if ticket_is_done(path):
|
|
450
|
+
done += 1
|
|
451
|
+
except Exception:
|
|
452
|
+
continue
|
|
453
|
+
|
|
454
|
+
if total == 0:
|
|
455
|
+
return None
|
|
456
|
+
|
|
457
|
+
state = latest.state if isinstance(latest.state, dict) else {}
|
|
458
|
+
engine = state.get("ticket_engine") if isinstance(state, dict) else {}
|
|
459
|
+
engine = engine if isinstance(engine, dict) else {}
|
|
460
|
+
current_step = engine.get("total_turns")
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
"status": latest.status.value,
|
|
464
|
+
"done_count": done,
|
|
465
|
+
"total_count": total,
|
|
466
|
+
"current_step": current_step,
|
|
467
|
+
}
|
|
468
|
+
except Exception:
|
|
469
|
+
return None
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _latest_dispatch(
|
|
473
|
+
repo_root: Path, run_id: str, input_data: dict, *, max_text_chars: int
|
|
474
|
+
) -> Optional[dict[str, Any]]:
|
|
475
|
+
try:
|
|
476
|
+
workspace_root = Path(input_data.get("workspace_root") or repo_root)
|
|
477
|
+
runs_dir = Path(input_data.get("runs_dir") or ".codex-autorunner/runs")
|
|
478
|
+
outbox_paths = resolve_outbox_paths(
|
|
479
|
+
workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
|
|
480
|
+
)
|
|
481
|
+
history_dir = outbox_paths.dispatch_history_dir
|
|
482
|
+
if not history_dir.exists() or not history_dir.is_dir():
|
|
483
|
+
return None
|
|
484
|
+
seq_dirs: list[Path] = []
|
|
485
|
+
for child in history_dir.iterdir():
|
|
486
|
+
if not child.is_dir():
|
|
487
|
+
continue
|
|
488
|
+
name = child.name
|
|
489
|
+
if len(name) == 4 and name.isdigit():
|
|
490
|
+
seq_dirs.append(child)
|
|
491
|
+
if not seq_dirs:
|
|
492
|
+
return None
|
|
493
|
+
latest_dir = sorted(seq_dirs, key=lambda p: p.name)[-1]
|
|
494
|
+
seq = int(latest_dir.name)
|
|
495
|
+
dispatch_path = latest_dir / "DISPATCH.md"
|
|
496
|
+
dispatch, errors = parse_dispatch(dispatch_path)
|
|
497
|
+
if errors or dispatch is None:
|
|
498
|
+
return {
|
|
499
|
+
"seq": seq,
|
|
500
|
+
"dir": safe_relpath(latest_dir, repo_root),
|
|
501
|
+
"dispatch": None,
|
|
502
|
+
"errors": errors,
|
|
503
|
+
"files": [],
|
|
504
|
+
}
|
|
505
|
+
files: list[str] = []
|
|
506
|
+
for child in sorted(latest_dir.iterdir(), key=lambda p: p.name):
|
|
507
|
+
if child.name.startswith("."):
|
|
508
|
+
continue
|
|
509
|
+
if child.name == "DISPATCH.md":
|
|
510
|
+
continue
|
|
511
|
+
if child.is_file():
|
|
512
|
+
files.append(child.name)
|
|
513
|
+
dispatch_dict = {
|
|
514
|
+
"mode": dispatch.mode,
|
|
515
|
+
"title": _truncate(dispatch.title, max_text_chars),
|
|
516
|
+
"body": _truncate(dispatch.body, max_text_chars),
|
|
517
|
+
"extra": _trim_extra(dispatch.extra, max_text_chars),
|
|
518
|
+
"is_handoff": dispatch.is_handoff,
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
"seq": seq,
|
|
522
|
+
"dir": safe_relpath(latest_dir, repo_root),
|
|
523
|
+
"dispatch": dispatch_dict,
|
|
524
|
+
"errors": [],
|
|
525
|
+
"files": files,
|
|
526
|
+
}
|
|
527
|
+
except Exception:
|
|
528
|
+
return None
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _gather_inbox(
|
|
532
|
+
supervisor: HubSupervisor, *, max_text_chars: int
|
|
533
|
+
) -> list[dict[str, Any]]:
|
|
534
|
+
messages: list[dict[str, Any]] = []
|
|
535
|
+
try:
|
|
536
|
+
snapshots = supervisor.list_repos()
|
|
537
|
+
except Exception:
|
|
538
|
+
return []
|
|
539
|
+
for snap in snapshots:
|
|
540
|
+
if not (snap.initialized and snap.exists_on_disk):
|
|
541
|
+
continue
|
|
542
|
+
repo_root = snap.path
|
|
543
|
+
db_path = repo_root / ".codex-autorunner" / "flows.db"
|
|
544
|
+
if not db_path.exists():
|
|
545
|
+
continue
|
|
546
|
+
try:
|
|
547
|
+
config = load_repo_config(repo_root)
|
|
548
|
+
with FlowStore(db_path, durable=config.durable_writes) as store:
|
|
549
|
+
paused = store.list_flow_runs(
|
|
550
|
+
flow_type="ticket_flow", status=FlowRunStatus.PAUSED
|
|
551
|
+
)
|
|
552
|
+
except Exception:
|
|
553
|
+
continue
|
|
554
|
+
if not paused:
|
|
555
|
+
continue
|
|
556
|
+
for record in paused:
|
|
557
|
+
latest = _latest_dispatch(
|
|
558
|
+
repo_root,
|
|
559
|
+
str(record.id),
|
|
560
|
+
dict(record.input_data or {}),
|
|
561
|
+
max_text_chars=max_text_chars,
|
|
562
|
+
)
|
|
563
|
+
if not latest or not latest.get("dispatch"):
|
|
564
|
+
continue
|
|
565
|
+
messages.append(
|
|
566
|
+
{
|
|
567
|
+
"repo_id": snap.id,
|
|
568
|
+
"repo_display_name": snap.display_name,
|
|
569
|
+
"run_id": record.id,
|
|
570
|
+
"run_created_at": record.created_at,
|
|
571
|
+
"seq": latest["seq"],
|
|
572
|
+
"dispatch": latest["dispatch"],
|
|
573
|
+
"files": latest.get("files") or [],
|
|
574
|
+
"open_url": f"/repos/{snap.id}/?tab=inbox&run_id={record.id}",
|
|
575
|
+
}
|
|
576
|
+
)
|
|
577
|
+
messages.sort(key=lambda m: (m.get("run_created_at") or ""), reverse=True)
|
|
578
|
+
return messages
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _gather_lifecycle_events(
|
|
582
|
+
supervisor: HubSupervisor, limit: int = 20
|
|
583
|
+
) -> list[dict[str, Any]]:
|
|
584
|
+
events = supervisor.lifecycle_store.get_unprocessed(limit=limit)
|
|
585
|
+
result: list[dict[str, Any]] = []
|
|
586
|
+
for event in events[:limit]:
|
|
587
|
+
result.append(
|
|
588
|
+
{
|
|
589
|
+
"event_type": event.event_type.value,
|
|
590
|
+
"repo_id": event.repo_id,
|
|
591
|
+
"run_id": event.run_id,
|
|
592
|
+
"timestamp": event.timestamp,
|
|
593
|
+
"data": event.data,
|
|
594
|
+
}
|
|
595
|
+
)
|
|
596
|
+
return result
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
async def build_hub_snapshot(
|
|
600
|
+
supervisor: Optional[HubSupervisor],
|
|
601
|
+
hub_root: Optional[Path] = None,
|
|
602
|
+
) -> dict[str, Any]:
|
|
603
|
+
if supervisor is None:
|
|
604
|
+
return {
|
|
605
|
+
"repos": [],
|
|
606
|
+
"inbox": [],
|
|
607
|
+
"templates": {"enabled": False, "repos": []},
|
|
608
|
+
"lifecycle_events": [],
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
snapshots = await asyncio.to_thread(supervisor.list_repos)
|
|
612
|
+
snapshots = sorted(snapshots, key=lambda snap: snap.id)
|
|
613
|
+
pma_config = supervisor.hub_config.pma if supervisor else None
|
|
614
|
+
max_repos = (
|
|
615
|
+
pma_config.max_repos
|
|
616
|
+
if pma_config and pma_config.max_repos > 0
|
|
617
|
+
else PMA_MAX_REPOS
|
|
618
|
+
)
|
|
619
|
+
max_messages = (
|
|
620
|
+
pma_config.max_messages
|
|
621
|
+
if pma_config and pma_config.max_messages > 0
|
|
622
|
+
else PMA_MAX_MESSAGES
|
|
623
|
+
)
|
|
624
|
+
max_text_chars = (
|
|
625
|
+
pma_config.max_text_chars
|
|
626
|
+
if pma_config and pma_config.max_text_chars > 0
|
|
627
|
+
else PMA_MAX_TEXT
|
|
628
|
+
)
|
|
629
|
+
repos: list[dict[str, Any]] = []
|
|
630
|
+
for snap in snapshots[:max_repos]:
|
|
631
|
+
summary: dict[str, Any] = {
|
|
632
|
+
"id": snap.id,
|
|
633
|
+
"display_name": snap.display_name,
|
|
634
|
+
"status": snap.status.value,
|
|
635
|
+
"last_run_id": snap.last_run_id,
|
|
636
|
+
"last_run_started_at": snap.last_run_started_at,
|
|
637
|
+
"last_run_finished_at": snap.last_run_finished_at,
|
|
638
|
+
"last_exit_code": snap.last_exit_code,
|
|
639
|
+
"ticket_flow": None,
|
|
640
|
+
}
|
|
641
|
+
if snap.initialized and snap.exists_on_disk:
|
|
642
|
+
summary["ticket_flow"] = _get_ticket_flow_summary(snap.path)
|
|
643
|
+
repos.append(summary)
|
|
644
|
+
|
|
645
|
+
inbox = await asyncio.to_thread(
|
|
646
|
+
_gather_inbox, supervisor, max_text_chars=max_text_chars
|
|
647
|
+
)
|
|
648
|
+
inbox = inbox[:max_messages]
|
|
649
|
+
|
|
650
|
+
lifecycle_events = await asyncio.to_thread(
|
|
651
|
+
_gather_lifecycle_events, supervisor, limit=20
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
templates = _build_templates_snapshot(supervisor, hub_root=hub_root)
|
|
655
|
+
|
|
656
|
+
pma_files: dict[str, list[str]] = {"inbox": [], "outbox": []}
|
|
657
|
+
if hub_root:
|
|
658
|
+
try:
|
|
659
|
+
pma_dir = hub_root / ".codex-autorunner" / "pma"
|
|
660
|
+
for box in ["inbox", "outbox"]:
|
|
661
|
+
box_dir = pma_dir / box
|
|
662
|
+
if box_dir.exists():
|
|
663
|
+
files = [
|
|
664
|
+
f.name
|
|
665
|
+
for f in box_dir.iterdir()
|
|
666
|
+
if f.is_file() and not f.name.startswith(".")
|
|
667
|
+
]
|
|
668
|
+
pma_files[box] = sorted(files)
|
|
669
|
+
except Exception:
|
|
670
|
+
pass
|
|
671
|
+
|
|
672
|
+
return {
|
|
673
|
+
"repos": repos,
|
|
674
|
+
"inbox": inbox,
|
|
675
|
+
"templates": templates,
|
|
676
|
+
"pma_files": pma_files,
|
|
677
|
+
"lifecycle_events": lifecycle_events,
|
|
678
|
+
"limits": {
|
|
679
|
+
"max_repos": max_repos,
|
|
680
|
+
"max_messages": max_messages,
|
|
681
|
+
"max_text_chars": max_text_chars,
|
|
682
|
+
},
|
|
683
|
+
}
|