codex-autorunner 0.1.2__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.
Files changed (276) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/__main__.py +4 -0
  3. codex_autorunner/agents/codex/harness.py +1 -1
  4. codex_autorunner/agents/opencode/client.py +68 -35
  5. codex_autorunner/agents/opencode/constants.py +3 -0
  6. codex_autorunner/agents/opencode/harness.py +6 -1
  7. codex_autorunner/agents/opencode/logging.py +21 -5
  8. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  9. codex_autorunner/agents/opencode/runtime.py +176 -47
  10. codex_autorunner/agents/opencode/supervisor.py +36 -48
  11. codex_autorunner/agents/registry.py +155 -8
  12. codex_autorunner/api.py +25 -0
  13. codex_autorunner/bootstrap.py +22 -37
  14. codex_autorunner/cli.py +5 -1156
  15. codex_autorunner/codex_cli.py +20 -84
  16. codex_autorunner/core/__init__.py +4 -0
  17. codex_autorunner/core/about_car.py +49 -32
  18. codex_autorunner/core/adapter_utils.py +21 -0
  19. codex_autorunner/core/app_server_ids.py +59 -0
  20. codex_autorunner/core/app_server_logging.py +7 -3
  21. codex_autorunner/core/app_server_prompts.py +27 -260
  22. codex_autorunner/core/app_server_threads.py +26 -28
  23. codex_autorunner/core/app_server_utils.py +165 -0
  24. codex_autorunner/core/archive.py +349 -0
  25. codex_autorunner/core/codex_runner.py +12 -2
  26. codex_autorunner/core/config.py +587 -103
  27. codex_autorunner/core/docs.py +10 -2
  28. codex_autorunner/core/drafts.py +136 -0
  29. codex_autorunner/core/engine.py +1531 -866
  30. codex_autorunner/core/exceptions.py +4 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +202 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +88 -0
  35. codex_autorunner/core/flows/reasons.py +52 -0
  36. codex_autorunner/core/flows/reconciler.py +131 -0
  37. codex_autorunner/core/flows/runtime.py +382 -0
  38. codex_autorunner/core/flows/store.py +568 -0
  39. codex_autorunner/core/flows/transition.py +138 -0
  40. codex_autorunner/core/flows/ux_helpers.py +257 -0
  41. codex_autorunner/core/flows/worker_process.py +242 -0
  42. codex_autorunner/core/git_utils.py +62 -0
  43. codex_autorunner/core/hub.py +136 -16
  44. codex_autorunner/core/locks.py +4 -0
  45. codex_autorunner/core/notifications.py +14 -2
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/core/ports/agent_backend.py +150 -0
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/core/ports/run_event.py +91 -0
  50. codex_autorunner/core/prompt.py +15 -7
  51. codex_autorunner/core/redaction.py +29 -0
  52. codex_autorunner/core/review_context.py +5 -8
  53. codex_autorunner/core/run_index.py +6 -0
  54. codex_autorunner/core/runner_process.py +5 -2
  55. codex_autorunner/core/state.py +0 -88
  56. codex_autorunner/core/state_roots.py +57 -0
  57. codex_autorunner/core/supervisor_protocol.py +15 -0
  58. codex_autorunner/core/supervisor_utils.py +67 -0
  59. codex_autorunner/core/text_delta_coalescer.py +54 -0
  60. codex_autorunner/core/ticket_linter_cli.py +201 -0
  61. codex_autorunner/core/ticket_manager_cli.py +432 -0
  62. codex_autorunner/core/update.py +24 -16
  63. codex_autorunner/core/update_paths.py +28 -0
  64. codex_autorunner/core/update_runner.py +2 -0
  65. codex_autorunner/core/usage.py +164 -12
  66. codex_autorunner/core/utils.py +120 -11
  67. codex_autorunner/discovery.py +2 -4
  68. codex_autorunner/flows/review/__init__.py +17 -0
  69. codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
  70. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  71. codex_autorunner/flows/ticket_flow/definition.py +98 -0
  72. codex_autorunner/integrations/agents/__init__.py +17 -0
  73. codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
  74. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  75. codex_autorunner/integrations/agents/codex_backend.py +448 -0
  76. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  77. codex_autorunner/integrations/agents/opencode_backend.py +598 -0
  78. codex_autorunner/integrations/agents/runner.py +91 -0
  79. codex_autorunner/integrations/agents/wiring.py +271 -0
  80. codex_autorunner/integrations/app_server/client.py +583 -152
  81. codex_autorunner/integrations/app_server/env.py +2 -107
  82. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  83. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  84. codex_autorunner/integrations/telegram/adapter.py +204 -165
  85. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  86. codex_autorunner/integrations/telegram/config.py +221 -0
  87. codex_autorunner/integrations/telegram/constants.py +17 -2
  88. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  89. codex_autorunner/integrations/telegram/doctor.py +47 -0
  90. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -4
  91. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  92. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  93. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  94. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1364 -0
  95. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  96. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  97. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  98. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +137 -478
  99. codex_autorunner/integrations/telegram/handlers/commands_spec.py +17 -4
  100. codex_autorunner/integrations/telegram/handlers/messages.py +121 -9
  101. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  102. codex_autorunner/integrations/telegram/helpers.py +111 -16
  103. codex_autorunner/integrations/telegram/outbox.py +208 -37
  104. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  105. codex_autorunner/integrations/telegram/service.py +221 -42
  106. codex_autorunner/integrations/telegram/state.py +100 -2
  107. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +611 -0
  108. codex_autorunner/integrations/telegram/transport.py +39 -4
  109. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  110. codex_autorunner/manifest.py +2 -0
  111. codex_autorunner/plugin_api.py +22 -0
  112. codex_autorunner/routes/__init__.py +37 -67
  113. codex_autorunner/routes/agents.py +2 -137
  114. codex_autorunner/routes/analytics.py +3 -0
  115. codex_autorunner/routes/app_server.py +2 -131
  116. codex_autorunner/routes/base.py +2 -624
  117. codex_autorunner/routes/file_chat.py +7 -0
  118. codex_autorunner/routes/flows.py +7 -0
  119. codex_autorunner/routes/messages.py +7 -0
  120. codex_autorunner/routes/repos.py +2 -196
  121. codex_autorunner/routes/review.py +2 -147
  122. codex_autorunner/routes/sessions.py +2 -175
  123. codex_autorunner/routes/settings.py +2 -168
  124. codex_autorunner/routes/shared.py +2 -275
  125. codex_autorunner/routes/system.py +4 -188
  126. codex_autorunner/routes/usage.py +3 -0
  127. codex_autorunner/routes/voice.py +2 -119
  128. codex_autorunner/routes/workspace.py +3 -0
  129. codex_autorunner/server.py +3 -2
  130. codex_autorunner/static/agentControls.js +41 -11
  131. codex_autorunner/static/agentEvents.js +248 -0
  132. codex_autorunner/static/app.js +35 -24
  133. codex_autorunner/static/archive.js +826 -0
  134. codex_autorunner/static/archiveApi.js +37 -0
  135. codex_autorunner/static/autoRefresh.js +36 -8
  136. codex_autorunner/static/bootstrap.js +1 -0
  137. codex_autorunner/static/bus.js +1 -0
  138. codex_autorunner/static/cache.js +1 -0
  139. codex_autorunner/static/constants.js +20 -4
  140. codex_autorunner/static/dashboard.js +344 -325
  141. codex_autorunner/static/diffRenderer.js +37 -0
  142. codex_autorunner/static/docChatCore.js +324 -0
  143. codex_autorunner/static/docChatStorage.js +65 -0
  144. codex_autorunner/static/docChatVoice.js +65 -0
  145. codex_autorunner/static/docEditor.js +133 -0
  146. codex_autorunner/static/env.js +1 -0
  147. codex_autorunner/static/eventSummarizer.js +166 -0
  148. codex_autorunner/static/fileChat.js +182 -0
  149. codex_autorunner/static/health.js +155 -0
  150. codex_autorunner/static/hub.js +126 -185
  151. codex_autorunner/static/index.html +839 -863
  152. codex_autorunner/static/liveUpdates.js +1 -0
  153. codex_autorunner/static/loader.js +1 -0
  154. codex_autorunner/static/messages.js +873 -0
  155. codex_autorunner/static/mobileCompact.js +2 -1
  156. codex_autorunner/static/preserve.js +17 -0
  157. codex_autorunner/static/settings.js +149 -217
  158. codex_autorunner/static/smartRefresh.js +52 -0
  159. codex_autorunner/static/styles.css +8850 -3876
  160. codex_autorunner/static/tabs.js +175 -11
  161. codex_autorunner/static/terminal.js +32 -0
  162. codex_autorunner/static/terminalManager.js +34 -59
  163. codex_autorunner/static/ticketChatActions.js +333 -0
  164. codex_autorunner/static/ticketChatEvents.js +16 -0
  165. codex_autorunner/static/ticketChatStorage.js +16 -0
  166. codex_autorunner/static/ticketChatStream.js +264 -0
  167. codex_autorunner/static/ticketEditor.js +844 -0
  168. codex_autorunner/static/ticketVoice.js +9 -0
  169. codex_autorunner/static/tickets.js +1988 -0
  170. codex_autorunner/static/utils.js +43 -3
  171. codex_autorunner/static/voice.js +1 -0
  172. codex_autorunner/static/workspace.js +765 -0
  173. codex_autorunner/static/workspaceApi.js +53 -0
  174. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  175. codex_autorunner/surfaces/__init__.py +5 -0
  176. codex_autorunner/surfaces/cli/__init__.py +6 -0
  177. codex_autorunner/surfaces/cli/cli.py +1224 -0
  178. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  179. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  180. codex_autorunner/surfaces/web/__init__.py +1 -0
  181. codex_autorunner/surfaces/web/app.py +2019 -0
  182. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  183. codex_autorunner/surfaces/web/middleware.py +587 -0
  184. codex_autorunner/surfaces/web/pty_session.py +370 -0
  185. codex_autorunner/surfaces/web/review.py +6 -0
  186. codex_autorunner/surfaces/web/routes/__init__.py +78 -0
  187. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  188. codex_autorunner/surfaces/web/routes/analytics.py +277 -0
  189. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  190. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  191. codex_autorunner/surfaces/web/routes/base.py +615 -0
  192. codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
  193. codex_autorunner/surfaces/web/routes/flows.py +1164 -0
  194. codex_autorunner/surfaces/web/routes/messages.py +459 -0
  195. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  196. codex_autorunner/surfaces/web/routes/review.py +148 -0
  197. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  198. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  199. codex_autorunner/surfaces/web/routes/shared.py +280 -0
  200. codex_autorunner/surfaces/web/routes/system.py +196 -0
  201. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  202. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  203. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  204. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  205. codex_autorunner/surfaces/web/schemas.py +417 -0
  206. codex_autorunner/surfaces/web/static_assets.py +490 -0
  207. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  208. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  209. codex_autorunner/tickets/__init__.py +27 -0
  210. codex_autorunner/tickets/agent_pool.py +399 -0
  211. codex_autorunner/tickets/files.py +89 -0
  212. codex_autorunner/tickets/frontmatter.py +55 -0
  213. codex_autorunner/tickets/lint.py +102 -0
  214. codex_autorunner/tickets/models.py +97 -0
  215. codex_autorunner/tickets/outbox.py +244 -0
  216. codex_autorunner/tickets/replies.py +179 -0
  217. codex_autorunner/tickets/runner.py +881 -0
  218. codex_autorunner/tickets/spec_ingest.py +77 -0
  219. codex_autorunner/web/__init__.py +5 -1
  220. codex_autorunner/web/app.py +2 -1771
  221. codex_autorunner/web/hub_jobs.py +2 -191
  222. codex_autorunner/web/middleware.py +2 -587
  223. codex_autorunner/web/pty_session.py +2 -369
  224. codex_autorunner/web/runner_manager.py +2 -24
  225. codex_autorunner/web/schemas.py +2 -396
  226. codex_autorunner/web/static_assets.py +4 -484
  227. codex_autorunner/web/static_refresh.py +2 -85
  228. codex_autorunner/web/terminal_sessions.py +2 -77
  229. codex_autorunner/workspace/__init__.py +40 -0
  230. codex_autorunner/workspace/paths.py +335 -0
  231. codex_autorunner-1.1.0.dist-info/METADATA +154 -0
  232. codex_autorunner-1.1.0.dist-info/RECORD +308 -0
  233. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +1 -1
  234. codex_autorunner/agents/execution/policy.py +0 -292
  235. codex_autorunner/agents/factory.py +0 -52
  236. codex_autorunner/agents/orchestrator.py +0 -358
  237. codex_autorunner/core/doc_chat.py +0 -1446
  238. codex_autorunner/core/snapshot.py +0 -580
  239. codex_autorunner/integrations/github/chatops.py +0 -268
  240. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  241. codex_autorunner/routes/docs.py +0 -381
  242. codex_autorunner/routes/github.py +0 -327
  243. codex_autorunner/routes/runs.py +0 -250
  244. codex_autorunner/spec_ingest.py +0 -812
  245. codex_autorunner/static/docChatActions.js +0 -287
  246. codex_autorunner/static/docChatEvents.js +0 -300
  247. codex_autorunner/static/docChatRender.js +0 -205
  248. codex_autorunner/static/docChatStream.js +0 -361
  249. codex_autorunner/static/docs.js +0 -20
  250. codex_autorunner/static/docsClipboard.js +0 -69
  251. codex_autorunner/static/docsCrud.js +0 -257
  252. codex_autorunner/static/docsDocUpdates.js +0 -62
  253. codex_autorunner/static/docsDrafts.js +0 -16
  254. codex_autorunner/static/docsElements.js +0 -69
  255. codex_autorunner/static/docsInit.js +0 -285
  256. codex_autorunner/static/docsParse.js +0 -160
  257. codex_autorunner/static/docsSnapshot.js +0 -87
  258. codex_autorunner/static/docsSpecIngest.js +0 -263
  259. codex_autorunner/static/docsState.js +0 -127
  260. codex_autorunner/static/docsThreadRegistry.js +0 -44
  261. codex_autorunner/static/docsUi.js +0 -153
  262. codex_autorunner/static/docsVoice.js +0 -56
  263. codex_autorunner/static/github.js +0 -504
  264. codex_autorunner/static/logs.js +0 -678
  265. codex_autorunner/static/review.js +0 -157
  266. codex_autorunner/static/runs.js +0 -418
  267. codex_autorunner/static/snapshot.js +0 -124
  268. codex_autorunner/static/state.js +0 -94
  269. codex_autorunner/static/todoPreview.js +0 -27
  270. codex_autorunner/workspace.py +0 -16
  271. codex_autorunner-0.1.2.dist-info/METADATA +0 -249
  272. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  273. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  274. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
  275. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
  276. {codex_autorunner-0.1.2.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
+ ]
@@ -0,0 +1,242 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ import uuid
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import IO, Literal, Optional, Tuple
11
+
12
+ _WORKER_METADATA_FILENAME = "worker.json"
13
+
14
+
15
+ @dataclass
16
+ class FlowWorkerHealth:
17
+ status: Literal["absent", "alive", "dead", "invalid", "mismatch"]
18
+ pid: Optional[int]
19
+ cmdline: list[str]
20
+ artifact_path: Path
21
+ message: Optional[str] = None
22
+
23
+ @property
24
+ def is_alive(self) -> bool:
25
+ return self.status == "alive"
26
+
27
+
28
+ def _normalized_run_id(run_id: str) -> str:
29
+ return str(uuid.UUID(str(run_id)))
30
+
31
+
32
+ def _worker_artifacts_dir(
33
+ repo_root: Path, run_id: str, artifacts_root: Optional[Path] = None
34
+ ) -> Path:
35
+ repo_root = repo_root.resolve()
36
+ base_artifacts = (
37
+ artifacts_root
38
+ if artifacts_root is not None
39
+ else repo_root / ".codex-autorunner" / "flows"
40
+ )
41
+ artifacts_dir = base_artifacts / _normalized_run_id(run_id)
42
+ artifacts_dir.mkdir(parents=True, exist_ok=True)
43
+ return artifacts_dir
44
+
45
+
46
+ def _worker_metadata_path(artifacts_dir: Path) -> Path:
47
+ return artifacts_dir / _WORKER_METADATA_FILENAME
48
+
49
+
50
+ def _build_worker_cmd(entrypoint: str, run_id: str) -> list[str]:
51
+ normalized_run_id = _normalized_run_id(run_id)
52
+ return [
53
+ sys.executable,
54
+ "-m",
55
+ entrypoint,
56
+ "flow",
57
+ "worker",
58
+ "--run-id",
59
+ normalized_run_id,
60
+ ]
61
+
62
+
63
+ def _pid_is_running(pid: int) -> bool:
64
+ try:
65
+ os.kill(pid, 0)
66
+ except ProcessLookupError:
67
+ return False
68
+ except PermissionError:
69
+ # Process exists but we may not own it.
70
+ return True
71
+ except OSError:
72
+ return False
73
+ return True
74
+
75
+
76
+ def _read_process_cmdline(pid: int) -> list[str] | None:
77
+ proc_path = Path(f"/proc/{pid}/cmdline")
78
+ if proc_path.exists():
79
+ try:
80
+ raw = proc_path.read_bytes()
81
+ return [part for part in raw.decode().split("\0") if part]
82
+ except Exception:
83
+ pass
84
+
85
+ try:
86
+ out = subprocess.check_output(
87
+ ["ps", "-p", str(pid), "-o", "command="],
88
+ stderr=subprocess.DEVNULL,
89
+ )
90
+ cmd = out.decode().strip()
91
+ if cmd:
92
+ return cmd.split()
93
+ except Exception:
94
+ return None
95
+ return None
96
+
97
+
98
+ def _cmdline_matches(expected: list[str], actual: list[str]) -> bool:
99
+ if not expected or not actual:
100
+ return False
101
+ if len(actual) >= len(expected) and actual[-len(expected) :] == expected:
102
+ return True
103
+ expected_str = " ".join(expected)
104
+ actual_str = " ".join(actual)
105
+ return expected_str in actual_str
106
+
107
+
108
+ def _write_worker_metadata(path: Path, pid: int, cmd: list[str]) -> None:
109
+ data = {
110
+ "pid": pid,
111
+ "cmd": cmd,
112
+ "cwd": os.getcwd(),
113
+ }
114
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
115
+ # Also emit a plain PID file for quick inspection.
116
+ pid_path = path.with_suffix(".pid")
117
+ pid_path.write_text(str(pid), encoding="utf-8")
118
+
119
+
120
+ def clear_worker_metadata(artifacts_dir: Path) -> None:
121
+ for name in (
122
+ _WORKER_METADATA_FILENAME,
123
+ f"{Path(_WORKER_METADATA_FILENAME).stem}.pid",
124
+ ):
125
+ try:
126
+ (artifacts_dir / name).unlink()
127
+ except FileNotFoundError:
128
+ pass
129
+ except Exception:
130
+ pass
131
+
132
+
133
+ def check_worker_health(
134
+ repo_root: Path,
135
+ run_id: str,
136
+ *,
137
+ artifacts_root: Optional[Path] = None,
138
+ entrypoint: str = "codex_autorunner",
139
+ ) -> FlowWorkerHealth:
140
+ artifacts_dir = _worker_artifacts_dir(repo_root, run_id, artifacts_root)
141
+ metadata_path = _worker_metadata_path(artifacts_dir)
142
+
143
+ if not metadata_path.exists():
144
+ return FlowWorkerHealth(
145
+ status="absent",
146
+ pid=None,
147
+ cmdline=[],
148
+ artifact_path=metadata_path,
149
+ message="worker metadata missing",
150
+ )
151
+
152
+ try:
153
+ data = json.loads(metadata_path.read_text(encoding="utf-8"))
154
+ pid = int(data.get("pid")) if data.get("pid") is not None else None
155
+ cmd = data.get("cmd") or []
156
+ except Exception:
157
+ return FlowWorkerHealth(
158
+ status="invalid",
159
+ pid=None,
160
+ cmdline=[],
161
+ artifact_path=metadata_path,
162
+ message="worker metadata unreadable",
163
+ )
164
+
165
+ if not pid or pid <= 0:
166
+ return FlowWorkerHealth(
167
+ status="invalid",
168
+ pid=pid,
169
+ cmdline=cmd if isinstance(cmd, list) else [],
170
+ artifact_path=metadata_path,
171
+ message="missing or invalid PID",
172
+ )
173
+
174
+ if not _pid_is_running(pid):
175
+ return FlowWorkerHealth(
176
+ status="dead",
177
+ pid=pid,
178
+ cmdline=cmd if isinstance(cmd, list) else [],
179
+ artifact_path=metadata_path,
180
+ message="worker PID not running",
181
+ )
182
+
183
+ expected_cmd = _build_worker_cmd(entrypoint, run_id)
184
+ actual_cmd = _read_process_cmdline(pid)
185
+ if actual_cmd is None:
186
+ # Can't inspect cmdline; trust the PID check.
187
+ return FlowWorkerHealth(
188
+ status="alive",
189
+ pid=pid,
190
+ cmdline=cmd if isinstance(cmd, list) else [],
191
+ artifact_path=metadata_path,
192
+ message="worker running (cmdline unknown)",
193
+ )
194
+
195
+ if not _cmdline_matches(expected_cmd, actual_cmd):
196
+ return FlowWorkerHealth(
197
+ status="mismatch",
198
+ pid=pid,
199
+ cmdline=actual_cmd,
200
+ artifact_path=metadata_path,
201
+ message="worker PID command does not match expected",
202
+ )
203
+
204
+ return FlowWorkerHealth(
205
+ status="alive",
206
+ pid=pid,
207
+ cmdline=actual_cmd,
208
+ artifact_path=metadata_path,
209
+ message="worker running",
210
+ )
211
+
212
+
213
+ def spawn_flow_worker(
214
+ repo_root: Path,
215
+ run_id: str,
216
+ *,
217
+ artifacts_root: Optional[Path] = None,
218
+ entrypoint: str = "codex_autorunner",
219
+ ) -> Tuple[subprocess.Popen, IO[bytes], IO[bytes]]:
220
+ """Spawn a detached flow worker with consistent artifacts/log layout."""
221
+
222
+ normalized_run_id = _normalized_run_id(run_id)
223
+ repo_root = repo_root.resolve()
224
+ artifacts_dir = _worker_artifacts_dir(repo_root, normalized_run_id, artifacts_root)
225
+
226
+ stdout_path = artifacts_dir / "worker.out.log"
227
+ stderr_path = artifacts_dir / "worker.err.log"
228
+
229
+ stdout_handle = stdout_path.open("ab")
230
+ stderr_handle = stderr_path.open("ab")
231
+
232
+ cmd = _build_worker_cmd(entrypoint, normalized_run_id)
233
+
234
+ proc = subprocess.Popen(
235
+ cmd,
236
+ cwd=repo_root,
237
+ stdout=stdout_handle,
238
+ stderr=stderr_handle,
239
+ )
240
+
241
+ _write_worker_metadata(_worker_metadata_path(artifacts_dir), proc.pid, cmd)
242
+ return proc, stdout_handle, stderr_handle
@@ -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
+ }