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,568 @@
1
+ import json
2
+ import logging
3
+ import sqlite3
4
+ import threading
5
+ from contextlib import contextmanager
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Generator, List, Optional, cast
9
+
10
+ from ..sqlite_utils import SQLITE_PRAGMAS
11
+ from .models import (
12
+ FlowArtifact,
13
+ FlowEvent,
14
+ FlowEventType,
15
+ FlowRunRecord,
16
+ FlowRunStatus,
17
+ )
18
+
19
+ _logger = logging.getLogger(__name__)
20
+
21
+ SCHEMA_VERSION = 2
22
+ UNSET = object()
23
+
24
+
25
+ def now_iso() -> str:
26
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
27
+
28
+
29
+ class FlowStore:
30
+ def __init__(self, db_path: Path):
31
+ self.db_path = db_path
32
+ self._local: threading.local = threading.local()
33
+
34
+ def _get_conn(self) -> sqlite3.Connection:
35
+ if not hasattr(self._local, "conn"):
36
+ # Ensure parent directory exists so sqlite can create/open the file.
37
+ try:
38
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
39
+ except Exception:
40
+ # Let sqlite raise a clearer error below if directory creation failed.
41
+ pass
42
+ self._local.conn = sqlite3.connect(
43
+ self.db_path, check_same_thread=False, isolation_level=None
44
+ )
45
+ self._local.conn.row_factory = sqlite3.Row
46
+ for pragma in SQLITE_PRAGMAS:
47
+ self._local.conn.execute(pragma)
48
+ return cast(sqlite3.Connection, self._local.conn)
49
+
50
+ @contextmanager
51
+ def transaction(self) -> Generator[sqlite3.Connection, None, None]:
52
+ conn = self._get_conn()
53
+ try:
54
+ conn.execute("BEGIN IMMEDIATE")
55
+ yield conn
56
+ conn.commit()
57
+ except Exception:
58
+ conn.rollback()
59
+ raise
60
+
61
+ def initialize(self) -> None:
62
+ with self.transaction() as conn:
63
+ self._create_schema(conn)
64
+ self._ensure_schema_version(conn)
65
+
66
+ def _create_schema(self, conn: sqlite3.Connection) -> None:
67
+ conn.execute(
68
+ """
69
+ CREATE TABLE IF NOT EXISTS schema_info (
70
+ version INTEGER NOT NULL PRIMARY KEY
71
+ )
72
+ """
73
+ )
74
+
75
+ conn.execute(
76
+ """
77
+ CREATE TABLE IF NOT EXISTS flow_runs (
78
+ id TEXT PRIMARY KEY,
79
+ flow_type TEXT NOT NULL,
80
+ status TEXT NOT NULL,
81
+ input_data TEXT NOT NULL,
82
+ state TEXT NOT NULL,
83
+ current_step TEXT,
84
+ stop_requested INTEGER NOT NULL DEFAULT 0,
85
+ created_at TEXT NOT NULL,
86
+ started_at TEXT,
87
+ finished_at TEXT,
88
+ error_message TEXT,
89
+ metadata TEXT NOT NULL DEFAULT '{}'
90
+ )
91
+ """
92
+ )
93
+
94
+ conn.execute(
95
+ """
96
+ CREATE TABLE IF NOT EXISTS flow_events (
97
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
98
+ id TEXT NOT NULL UNIQUE,
99
+ run_id TEXT NOT NULL,
100
+ event_type TEXT NOT NULL,
101
+ timestamp TEXT NOT NULL,
102
+ data TEXT NOT NULL,
103
+ step_id TEXT,
104
+ FOREIGN KEY (run_id) REFERENCES flow_runs(id) ON DELETE CASCADE
105
+ )
106
+ """
107
+ )
108
+
109
+ conn.execute(
110
+ """
111
+ CREATE TABLE IF NOT EXISTS flow_artifacts (
112
+ id TEXT PRIMARY KEY,
113
+ run_id TEXT NOT NULL,
114
+ kind TEXT NOT NULL,
115
+ path TEXT NOT NULL,
116
+ created_at TEXT NOT NULL,
117
+ metadata TEXT NOT NULL DEFAULT '{}',
118
+ FOREIGN KEY (run_id) REFERENCES flow_runs(id) ON DELETE CASCADE
119
+ )
120
+ """
121
+ )
122
+
123
+ conn.execute(
124
+ "CREATE INDEX IF NOT EXISTS idx_flow_runs_status ON flow_runs(status)"
125
+ )
126
+ conn.execute(
127
+ "CREATE INDEX IF NOT EXISTS idx_flow_events_run_id ON flow_events(run_id, seq)"
128
+ )
129
+ conn.execute(
130
+ "CREATE INDEX IF NOT EXISTS idx_flow_artifacts_run_id ON flow_artifacts(run_id)"
131
+ )
132
+
133
+ def _ensure_schema_version(self, conn: sqlite3.Connection) -> None:
134
+ result = conn.execute("SELECT version FROM schema_info").fetchone()
135
+ if result is None:
136
+ conn.execute(
137
+ "INSERT INTO schema_info (version) VALUES (?)", (SCHEMA_VERSION,)
138
+ )
139
+ else:
140
+ current_version = result[0]
141
+ if current_version < SCHEMA_VERSION:
142
+ self._migrate_schema(conn, current_version, SCHEMA_VERSION)
143
+
144
+ def _migrate_schema(
145
+ self, conn: sqlite3.Connection, from_version: int, to_version: int
146
+ ) -> None:
147
+ _logger.info("Migrating schema from version %d to %d", from_version, to_version)
148
+ for version in range(from_version, to_version):
149
+ self._apply_migration(conn, version + 1)
150
+ conn.execute("UPDATE schema_info SET version = ?", (to_version,))
151
+
152
+ def _apply_migration(self, conn: sqlite3.Connection, version: int) -> None:
153
+ if version == 1:
154
+ pass
155
+ elif version == 2:
156
+ conn.execute("ALTER TABLE flow_events RENAME TO flow_events_old")
157
+ conn.execute(
158
+ """
159
+ CREATE TABLE flow_events (
160
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
161
+ id TEXT NOT NULL UNIQUE,
162
+ run_id TEXT NOT NULL,
163
+ event_type TEXT NOT NULL,
164
+ timestamp TEXT NOT NULL,
165
+ data TEXT NOT NULL,
166
+ step_id TEXT,
167
+ FOREIGN KEY (run_id) REFERENCES flow_runs(id) ON DELETE CASCADE
168
+ )
169
+ """
170
+ )
171
+ conn.execute(
172
+ """
173
+ INSERT INTO flow_events (id, run_id, event_type, timestamp, data, step_id)
174
+ SELECT id, run_id, event_type, timestamp, data, step_id
175
+ FROM flow_events_old
176
+ ORDER BY timestamp ASC
177
+ """
178
+ )
179
+ conn.execute("DROP TABLE flow_events_old")
180
+ conn.execute(
181
+ "CREATE INDEX IF NOT EXISTS idx_flow_events_run_id ON flow_events(run_id, seq)"
182
+ )
183
+
184
+ def create_flow_run(
185
+ self,
186
+ run_id: str,
187
+ flow_type: str,
188
+ input_data: Dict[str, Any],
189
+ metadata: Optional[Dict[str, Any]] = None,
190
+ state: Optional[Dict[str, Any]] = None,
191
+ current_step: Optional[str] = None,
192
+ ) -> FlowRunRecord:
193
+ now = now_iso()
194
+ record = FlowRunRecord(
195
+ id=run_id,
196
+ flow_type=flow_type,
197
+ status=FlowRunStatus.PENDING,
198
+ input_data=input_data,
199
+ state=state or {},
200
+ current_step=current_step,
201
+ stop_requested=False,
202
+ created_at=now,
203
+ metadata=metadata or {},
204
+ )
205
+
206
+ with self.transaction() as conn:
207
+ conn.execute(
208
+ """
209
+ INSERT INTO flow_runs (
210
+ id, flow_type, status, input_data, state, current_step,
211
+ stop_requested, created_at, metadata
212
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
213
+ """,
214
+ (
215
+ record.id,
216
+ record.flow_type,
217
+ record.status.value,
218
+ json.dumps(record.input_data),
219
+ json.dumps(record.state),
220
+ record.current_step,
221
+ 1 if record.stop_requested else 0,
222
+ record.created_at,
223
+ json.dumps(record.metadata),
224
+ ),
225
+ )
226
+
227
+ return record
228
+
229
+ def get_flow_run(self, run_id: str) -> Optional[FlowRunRecord]:
230
+ conn = self._get_conn()
231
+ row = conn.execute("SELECT * FROM flow_runs WHERE id = ?", (run_id,)).fetchone()
232
+ if row is None:
233
+ return None
234
+ return self._row_to_flow_run(row)
235
+
236
+ def update_flow_run_status(
237
+ self,
238
+ run_id: str,
239
+ status: FlowRunStatus,
240
+ current_step: Any = UNSET,
241
+ state: Any = UNSET,
242
+ started_at: Any = UNSET,
243
+ finished_at: Any = UNSET,
244
+ error_message: Any = UNSET,
245
+ ) -> Optional[FlowRunRecord]:
246
+ updates = ["status = ?"]
247
+ params: List[Any] = [status.value]
248
+
249
+ if current_step is not UNSET:
250
+ updates.append("current_step = ?")
251
+ params.append(current_step)
252
+
253
+ if state is not UNSET:
254
+ updates.append("state = ?")
255
+ params.append(json.dumps(state))
256
+
257
+ if started_at is not UNSET:
258
+ updates.append("started_at = ?")
259
+ params.append(started_at)
260
+
261
+ if finished_at is not UNSET:
262
+ updates.append("finished_at = ?")
263
+ params.append(finished_at)
264
+
265
+ if error_message is not UNSET:
266
+ updates.append("error_message = ?")
267
+ params.append(error_message)
268
+
269
+ params.append(run_id)
270
+
271
+ with self.transaction() as conn:
272
+ conn.execute(
273
+ f"UPDATE flow_runs SET {', '.join(updates)} WHERE id = ?",
274
+ params,
275
+ )
276
+ row = conn.execute(
277
+ "SELECT * FROM flow_runs WHERE id = ?", (run_id,)
278
+ ).fetchone()
279
+ if row is None:
280
+ return None
281
+ return self._row_to_flow_run(row)
282
+
283
+ def set_stop_requested(
284
+ self, run_id: str, stop_requested: bool
285
+ ) -> Optional[FlowRunRecord]:
286
+ with self.transaction() as conn:
287
+ conn.execute(
288
+ "UPDATE flow_runs SET stop_requested = ? WHERE id = ?",
289
+ (1 if stop_requested else 0, run_id),
290
+ )
291
+ row = conn.execute(
292
+ "SELECT * FROM flow_runs WHERE id = ?", (run_id,)
293
+ ).fetchone()
294
+ if row is None:
295
+ return None
296
+ return self._row_to_flow_run(row)
297
+
298
+ def update_current_step(
299
+ self, run_id: str, current_step: str
300
+ ) -> Optional[FlowRunRecord]:
301
+ with self.transaction() as conn:
302
+ conn.execute(
303
+ "UPDATE flow_runs SET current_step = ? WHERE id = ?",
304
+ (current_step, run_id),
305
+ )
306
+ row = conn.execute(
307
+ "SELECT * FROM flow_runs WHERE id = ?", (run_id,)
308
+ ).fetchone()
309
+ if row is None:
310
+ return None
311
+ return self._row_to_flow_run(row)
312
+
313
+ def list_flow_runs(
314
+ self, flow_type: Optional[str] = None, status: Optional[FlowRunStatus] = None
315
+ ) -> List[FlowRunRecord]:
316
+ conn = self._get_conn()
317
+ query = "SELECT * FROM flow_runs WHERE 1=1"
318
+ params: List[Any] = []
319
+
320
+ if flow_type is not None:
321
+ query += " AND flow_type = ?"
322
+ params.append(flow_type)
323
+
324
+ if status is not None:
325
+ query += " AND status = ?"
326
+ params.append(status.value)
327
+
328
+ query += " ORDER BY created_at DESC"
329
+
330
+ rows = conn.execute(query, params).fetchall()
331
+ return [self._row_to_flow_run(row) for row in rows]
332
+
333
+ def create_event(
334
+ self,
335
+ event_id: str,
336
+ run_id: str,
337
+ event_type: FlowEventType,
338
+ data: Optional[Dict[str, Any]] = None,
339
+ step_id: Optional[str] = None,
340
+ ) -> FlowEvent:
341
+ timestamp = now_iso()
342
+
343
+ with self.transaction() as conn:
344
+ conn.execute(
345
+ """
346
+ INSERT INTO flow_events (id, run_id, event_type, timestamp, data, step_id)
347
+ VALUES (?, ?, ?, ?, ?, ?)
348
+ """,
349
+ (
350
+ event_id,
351
+ run_id,
352
+ event_type.value,
353
+ timestamp,
354
+ json.dumps(data or {}),
355
+ step_id,
356
+ ),
357
+ )
358
+ row = conn.execute(
359
+ "SELECT * FROM flow_events WHERE id = ?", (event_id,)
360
+ ).fetchone()
361
+
362
+ if row is None:
363
+ raise RuntimeError("Failed to persist flow event")
364
+
365
+ return self._row_to_flow_event(row)
366
+
367
+ def get_events(
368
+ self,
369
+ run_id: str,
370
+ after_seq: Optional[int] = None,
371
+ limit: Optional[int] = None,
372
+ ) -> List[FlowEvent]:
373
+ conn = self._get_conn()
374
+ query = "SELECT * FROM flow_events WHERE run_id = ?"
375
+ params: List[Any] = [run_id]
376
+
377
+ if after_seq is not None:
378
+ query += " AND seq > ?"
379
+ params.append(after_seq)
380
+
381
+ query += " ORDER BY seq ASC"
382
+
383
+ if limit is not None:
384
+ query += " LIMIT ?"
385
+ params.append(limit)
386
+
387
+ rows = conn.execute(query, params).fetchall()
388
+ return [self._row_to_flow_event(row) for row in rows]
389
+
390
+ def get_last_event_meta(self, run_id: str) -> tuple[Optional[int], Optional[str]]:
391
+ conn = self._get_conn()
392
+ row = conn.execute(
393
+ "SELECT seq, timestamp FROM flow_events WHERE run_id = ? ORDER BY seq DESC LIMIT 1",
394
+ (run_id,),
395
+ ).fetchone()
396
+ if row is None:
397
+ return None, None
398
+ return row["seq"], row["timestamp"]
399
+
400
+ def get_last_event_seq_by_types(
401
+ self, run_id: str, event_types: list[FlowEventType]
402
+ ) -> Optional[int]:
403
+ if not event_types:
404
+ return None
405
+ conn = self._get_conn()
406
+ placeholders = ", ".join("?" for _ in event_types)
407
+ params = [run_id, *[t.value for t in event_types]]
408
+ row = conn.execute(
409
+ f"""
410
+ SELECT seq
411
+ FROM flow_events
412
+ WHERE run_id = ? AND event_type IN ({placeholders})
413
+ ORDER BY seq DESC
414
+ LIMIT 1
415
+ """,
416
+ params,
417
+ ).fetchone()
418
+ if row is None:
419
+ return None
420
+ return cast(int, row["seq"])
421
+
422
+ def get_last_event_by_type(
423
+ self, run_id: str, event_type: FlowEventType
424
+ ) -> Optional[FlowEvent]:
425
+ conn = self._get_conn()
426
+ row = conn.execute(
427
+ """
428
+ SELECT *
429
+ FROM flow_events
430
+ WHERE run_id = ? AND event_type = ?
431
+ ORDER BY seq DESC
432
+ LIMIT 1
433
+ """,
434
+ (run_id, event_type.value),
435
+ ).fetchone()
436
+ if row is None:
437
+ return None
438
+ return self._row_to_flow_event(row)
439
+
440
+ def get_latest_step_progress_current_ticket(
441
+ self, run_id: str, *, after_seq: Optional[int] = None, limit: int = 50
442
+ ) -> Optional[str]:
443
+ """Return the most recent step_progress.data.current_ticket for a run.
444
+
445
+ This is intentionally lightweight to support UI polling endpoints.
446
+ """
447
+ conn = self._get_conn()
448
+ query = """
449
+ SELECT seq, data
450
+ FROM flow_events
451
+ WHERE run_id = ? AND event_type = ?
452
+ """
453
+ params: List[Any] = [run_id, FlowEventType.STEP_PROGRESS.value]
454
+ if after_seq is not None:
455
+ query += " AND seq > ?"
456
+ params.append(after_seq)
457
+ query += " ORDER BY seq DESC LIMIT ?"
458
+ params.append(limit)
459
+ rows = conn.execute(query, params).fetchall()
460
+ for row in rows:
461
+ try:
462
+ data = json.loads(row["data"] or "{}")
463
+ except Exception:
464
+ data = {}
465
+ current_ticket = data.get("current_ticket")
466
+ if isinstance(current_ticket, str) and current_ticket.strip():
467
+ return current_ticket.strip()
468
+ return None
469
+
470
+ def create_artifact(
471
+ self,
472
+ artifact_id: str,
473
+ run_id: str,
474
+ kind: str,
475
+ path: str,
476
+ metadata: Optional[Dict[str, Any]] = None,
477
+ ) -> FlowArtifact:
478
+ artifact = FlowArtifact(
479
+ id=artifact_id,
480
+ run_id=run_id,
481
+ kind=kind,
482
+ path=path,
483
+ created_at=now_iso(),
484
+ metadata=metadata or {},
485
+ )
486
+
487
+ with self.transaction() as conn:
488
+ conn.execute(
489
+ """
490
+ INSERT INTO flow_artifacts (id, run_id, kind, path, created_at, metadata)
491
+ VALUES (?, ?, ?, ?, ?, ?)
492
+ """,
493
+ (
494
+ artifact.id,
495
+ artifact.run_id,
496
+ artifact.kind,
497
+ artifact.path,
498
+ artifact.created_at,
499
+ json.dumps(artifact.metadata),
500
+ ),
501
+ )
502
+
503
+ return artifact
504
+
505
+ def get_artifacts(self, run_id: str) -> List[FlowArtifact]:
506
+ conn = self._get_conn()
507
+ rows = conn.execute(
508
+ "SELECT * FROM flow_artifacts WHERE run_id = ? ORDER BY created_at ASC",
509
+ (run_id,),
510
+ ).fetchall()
511
+ return [self._row_to_flow_artifact(row) for row in rows]
512
+
513
+ def get_artifact(self, artifact_id: str) -> Optional[FlowArtifact]:
514
+ conn = self._get_conn()
515
+ row = conn.execute(
516
+ "SELECT * FROM flow_artifacts WHERE id = ?", (artifact_id,)
517
+ ).fetchone()
518
+ if row is None:
519
+ return None
520
+ return self._row_to_flow_artifact(row)
521
+
522
+ def delete_flow_run(self, run_id: str) -> bool:
523
+ """Delete a flow run and its events/artifacts (cascading)."""
524
+ with self.transaction() as conn:
525
+ cursor = conn.execute("DELETE FROM flow_runs WHERE id = ?", (run_id,))
526
+ return cursor.rowcount > 0
527
+
528
+ def _row_to_flow_run(self, row: sqlite3.Row) -> FlowRunRecord:
529
+ return FlowRunRecord(
530
+ id=row["id"],
531
+ flow_type=row["flow_type"],
532
+ status=FlowRunStatus(row["status"]),
533
+ input_data=json.loads(row["input_data"]),
534
+ state=json.loads(row["state"]),
535
+ current_step=row["current_step"],
536
+ stop_requested=bool(row["stop_requested"]),
537
+ created_at=row["created_at"],
538
+ started_at=row["started_at"],
539
+ finished_at=row["finished_at"],
540
+ error_message=row["error_message"],
541
+ metadata=json.loads(row["metadata"]),
542
+ )
543
+
544
+ def _row_to_flow_event(self, row: sqlite3.Row) -> FlowEvent:
545
+ return FlowEvent(
546
+ seq=row["seq"],
547
+ id=row["id"],
548
+ run_id=row["run_id"],
549
+ event_type=FlowEventType(row["event_type"]),
550
+ timestamp=row["timestamp"],
551
+ data=json.loads(row["data"]),
552
+ step_id=row["step_id"],
553
+ )
554
+
555
+ def _row_to_flow_artifact(self, row: sqlite3.Row) -> FlowArtifact:
556
+ return FlowArtifact(
557
+ id=row["id"],
558
+ run_id=row["run_id"],
559
+ kind=row["kind"],
560
+ path=row["path"],
561
+ created_at=row["created_at"],
562
+ metadata=json.loads(row["metadata"]),
563
+ )
564
+
565
+ def close(self) -> None:
566
+ if hasattr(self._local, "conn"):
567
+ self._local.conn.close()
568
+ del self._local.conn
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Optional
5
+
6
+ from codex_autorunner.core.flows.models import FlowRunRecord, FlowRunStatus
7
+ from codex_autorunner.core.flows.reasons import ensure_reason_summary
8
+ from codex_autorunner.core.flows.store import now_iso
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class TransitionDecision:
13
+ """Result of resolving a flow's next status.
14
+
15
+ Attributes
16
+ ----------
17
+ status: FlowRunStatus
18
+ The resolved outer status.
19
+ finished_at: Optional[str]
20
+ Completion timestamp when the flow reaches a terminal state.
21
+ state: dict[str, Any]
22
+ Updated state payload (ticket_engine etc.).
23
+ note: Optional[str]
24
+ Reason for the transition (useful for tests/logging).
25
+ """
26
+
27
+ status: FlowRunStatus
28
+ finished_at: Optional[str]
29
+ state: dict[str, Any]
30
+ note: Optional[str] = None
31
+
32
+
33
+ def resolve_flow_transition(
34
+ record: FlowRunRecord, health: Any, now: Optional[str] = None
35
+ ) -> TransitionDecision:
36
+ """Derive the flow status from worker liveness and inner ticket_engine state.
37
+
38
+ This is intentionally pure and side-effect free to keep recovery/test logic simple.
39
+ """
40
+
41
+ now = now or now_iso()
42
+ state: dict[str, Any] = record.state or {}
43
+ engine_raw = state.get("ticket_engine") if isinstance(state, dict) else {}
44
+ engine: dict[str, Any] = engine_raw if isinstance(engine_raw, dict) else {}
45
+ inner_status = engine.get("status")
46
+ reason_code = engine.get("reason_code")
47
+
48
+ # 1) Worker liveness overrides for active flows.
49
+ if (
50
+ record.status in (FlowRunStatus.RUNNING, FlowRunStatus.STOPPING)
51
+ and not health.is_alive
52
+ ):
53
+ new_status = (
54
+ FlowRunStatus.STOPPED
55
+ if record.status == FlowRunStatus.STOPPING
56
+ else FlowRunStatus.FAILED
57
+ )
58
+ state = ensure_reason_summary(state, status=new_status, default="Worker died")
59
+ return TransitionDecision(
60
+ status=new_status, finished_at=now, state=state, note="worker-dead"
61
+ )
62
+
63
+ # 2) Inner engine reconciliation (worker is alive or not required).
64
+ if record.status == FlowRunStatus.RUNNING:
65
+ if inner_status == "paused":
66
+ state = ensure_reason_summary(state, status=FlowRunStatus.PAUSED)
67
+ return TransitionDecision(
68
+ status=FlowRunStatus.PAUSED,
69
+ finished_at=None,
70
+ state=state,
71
+ note="engine-paused",
72
+ )
73
+
74
+ if inner_status == "completed":
75
+ return TransitionDecision(
76
+ status=FlowRunStatus.COMPLETED,
77
+ finished_at=now,
78
+ state=state,
79
+ note="engine-completed",
80
+ )
81
+
82
+ return TransitionDecision(
83
+ status=FlowRunStatus.RUNNING, finished_at=None, state=state, note="running"
84
+ )
85
+
86
+ if record.status == FlowRunStatus.PAUSED:
87
+ if inner_status == "completed":
88
+ return TransitionDecision(
89
+ status=FlowRunStatus.COMPLETED,
90
+ finished_at=now,
91
+ state=state,
92
+ note="paused-engine-completed",
93
+ )
94
+
95
+ if (
96
+ inner_status in (None, "running")
97
+ and reason_code != "user_pause"
98
+ and health.is_alive
99
+ ):
100
+ # Treat as stale pause; resume and clear pause metadata.
101
+ engine.pop("reason", None)
102
+ engine.pop("reason_details", None)
103
+ engine.pop("reason_code", None)
104
+ state.pop("reason_summary", None)
105
+ engine["status"] = "running"
106
+ state["ticket_engine"] = engine
107
+ return TransitionDecision(
108
+ status=FlowRunStatus.RUNNING,
109
+ finished_at=None,
110
+ state=state,
111
+ note="stale-pause-resumed",
112
+ )
113
+
114
+ if not health.is_alive:
115
+ return TransitionDecision(
116
+ status=FlowRunStatus.PAUSED,
117
+ finished_at=None,
118
+ state=state,
119
+ note="paused-worker-dead",
120
+ )
121
+
122
+ state = ensure_reason_summary(state, status=FlowRunStatus.PAUSED)
123
+ return TransitionDecision(
124
+ status=FlowRunStatus.PAUSED, finished_at=None, state=state, note="paused"
125
+ )
126
+
127
+ # STOPPING/STOPPED/COMPLETED/FAILED: leave unchanged.
128
+ if record.status.is_terminal() or record.status == FlowRunStatus.STOPPED:
129
+ return TransitionDecision(
130
+ status=record.status,
131
+ finished_at=record.finished_at,
132
+ state=state,
133
+ note="terminal",
134
+ )
135
+
136
+ return TransitionDecision(
137
+ status=record.status, finished_at=None, state=state, note="unchanged"
138
+ )