codex-autorunner 0.1.2__py3-none-any.whl → 1.0.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 (189) hide show
  1. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/opencode/client.py +68 -35
  3. codex_autorunner/agents/opencode/logging.py +21 -5
  4. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  5. codex_autorunner/agents/opencode/runtime.py +118 -30
  6. codex_autorunner/agents/opencode/supervisor.py +36 -48
  7. codex_autorunner/agents/registry.py +136 -8
  8. codex_autorunner/api.py +25 -0
  9. codex_autorunner/bootstrap.py +16 -35
  10. codex_autorunner/cli.py +157 -139
  11. codex_autorunner/core/about_car.py +44 -32
  12. codex_autorunner/core/adapter_utils.py +21 -0
  13. codex_autorunner/core/app_server_logging.py +7 -3
  14. codex_autorunner/core/app_server_prompts.py +27 -260
  15. codex_autorunner/core/app_server_threads.py +15 -26
  16. codex_autorunner/core/codex_runner.py +6 -0
  17. codex_autorunner/core/config.py +390 -100
  18. codex_autorunner/core/docs.py +10 -2
  19. codex_autorunner/core/drafts.py +82 -0
  20. codex_autorunner/core/engine.py +278 -262
  21. codex_autorunner/core/flows/__init__.py +25 -0
  22. codex_autorunner/core/flows/controller.py +178 -0
  23. codex_autorunner/core/flows/definition.py +82 -0
  24. codex_autorunner/core/flows/models.py +75 -0
  25. codex_autorunner/core/flows/runtime.py +351 -0
  26. codex_autorunner/core/flows/store.py +485 -0
  27. codex_autorunner/core/flows/transition.py +133 -0
  28. codex_autorunner/core/flows/worker_process.py +242 -0
  29. codex_autorunner/core/hub.py +15 -9
  30. codex_autorunner/core/locks.py +4 -0
  31. codex_autorunner/core/prompt.py +15 -7
  32. codex_autorunner/core/redaction.py +29 -0
  33. codex_autorunner/core/review_context.py +5 -8
  34. codex_autorunner/core/run_index.py +6 -0
  35. codex_autorunner/core/runner_process.py +5 -2
  36. codex_autorunner/core/state.py +0 -88
  37. codex_autorunner/core/static_assets.py +55 -0
  38. codex_autorunner/core/supervisor_utils.py +67 -0
  39. codex_autorunner/core/update.py +20 -11
  40. codex_autorunner/core/update_runner.py +2 -0
  41. codex_autorunner/core/utils.py +29 -2
  42. codex_autorunner/discovery.py +2 -4
  43. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  44. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  45. codex_autorunner/integrations/agents/__init__.py +27 -0
  46. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  47. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  48. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  49. codex_autorunner/integrations/agents/run_event.py +71 -0
  50. codex_autorunner/integrations/app_server/client.py +576 -92
  51. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  52. codex_autorunner/integrations/telegram/adapter.py +141 -167
  53. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  54. codex_autorunner/integrations/telegram/config.py +175 -0
  55. codex_autorunner/integrations/telegram/constants.py +16 -1
  56. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  57. codex_autorunner/integrations/telegram/doctor.py +47 -0
  58. codex_autorunner/integrations/telegram/handlers/callbacks.py +0 -4
  59. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  60. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  61. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  62. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  63. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  64. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  65. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  66. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +133 -475
  67. codex_autorunner/integrations/telegram/handlers/commands_spec.py +11 -4
  68. codex_autorunner/integrations/telegram/handlers/messages.py +120 -9
  69. codex_autorunner/integrations/telegram/helpers.py +88 -16
  70. codex_autorunner/integrations/telegram/outbox.py +208 -37
  71. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  72. codex_autorunner/integrations/telegram/service.py +214 -40
  73. codex_autorunner/integrations/telegram/state.py +100 -2
  74. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  75. codex_autorunner/integrations/telegram/transport.py +36 -3
  76. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  77. codex_autorunner/manifest.py +2 -0
  78. codex_autorunner/plugin_api.py +22 -0
  79. codex_autorunner/routes/__init__.py +23 -14
  80. codex_autorunner/routes/analytics.py +239 -0
  81. codex_autorunner/routes/base.py +81 -109
  82. codex_autorunner/routes/file_chat.py +836 -0
  83. codex_autorunner/routes/flows.py +980 -0
  84. codex_autorunner/routes/messages.py +459 -0
  85. codex_autorunner/routes/system.py +6 -1
  86. codex_autorunner/routes/usage.py +87 -0
  87. codex_autorunner/routes/workspace.py +271 -0
  88. codex_autorunner/server.py +2 -1
  89. codex_autorunner/static/agentControls.js +1 -0
  90. codex_autorunner/static/agentEvents.js +248 -0
  91. codex_autorunner/static/app.js +25 -22
  92. codex_autorunner/static/autoRefresh.js +29 -1
  93. codex_autorunner/static/bootstrap.js +1 -0
  94. codex_autorunner/static/bus.js +1 -0
  95. codex_autorunner/static/cache.js +1 -0
  96. codex_autorunner/static/constants.js +20 -4
  97. codex_autorunner/static/dashboard.js +162 -196
  98. codex_autorunner/static/diffRenderer.js +37 -0
  99. codex_autorunner/static/docChatCore.js +324 -0
  100. codex_autorunner/static/docChatStorage.js +65 -0
  101. codex_autorunner/static/docChatVoice.js +65 -0
  102. codex_autorunner/static/docEditor.js +133 -0
  103. codex_autorunner/static/env.js +1 -0
  104. codex_autorunner/static/eventSummarizer.js +166 -0
  105. codex_autorunner/static/fileChat.js +182 -0
  106. codex_autorunner/static/health.js +155 -0
  107. codex_autorunner/static/hub.js +41 -118
  108. codex_autorunner/static/index.html +787 -858
  109. codex_autorunner/static/liveUpdates.js +1 -0
  110. codex_autorunner/static/loader.js +1 -0
  111. codex_autorunner/static/messages.js +470 -0
  112. codex_autorunner/static/mobileCompact.js +2 -1
  113. codex_autorunner/static/settings.js +24 -211
  114. codex_autorunner/static/styles.css +7567 -3865
  115. codex_autorunner/static/tabs.js +28 -5
  116. codex_autorunner/static/terminal.js +14 -0
  117. codex_autorunner/static/terminalManager.js +34 -59
  118. codex_autorunner/static/ticketChatActions.js +333 -0
  119. codex_autorunner/static/ticketChatEvents.js +16 -0
  120. codex_autorunner/static/ticketChatStorage.js +16 -0
  121. codex_autorunner/static/ticketChatStream.js +264 -0
  122. codex_autorunner/static/ticketEditor.js +750 -0
  123. codex_autorunner/static/ticketVoice.js +9 -0
  124. codex_autorunner/static/tickets.js +1315 -0
  125. codex_autorunner/static/utils.js +32 -3
  126. codex_autorunner/static/voice.js +1 -0
  127. codex_autorunner/static/workspace.js +672 -0
  128. codex_autorunner/static/workspaceApi.js +53 -0
  129. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  130. codex_autorunner/tickets/__init__.py +20 -0
  131. codex_autorunner/tickets/agent_pool.py +377 -0
  132. codex_autorunner/tickets/files.py +85 -0
  133. codex_autorunner/tickets/frontmatter.py +55 -0
  134. codex_autorunner/tickets/lint.py +102 -0
  135. codex_autorunner/tickets/models.py +95 -0
  136. codex_autorunner/tickets/outbox.py +232 -0
  137. codex_autorunner/tickets/replies.py +179 -0
  138. codex_autorunner/tickets/runner.py +823 -0
  139. codex_autorunner/tickets/spec_ingest.py +77 -0
  140. codex_autorunner/web/app.py +269 -91
  141. codex_autorunner/web/middleware.py +3 -4
  142. codex_autorunner/web/schemas.py +89 -109
  143. codex_autorunner/web/static_assets.py +1 -44
  144. codex_autorunner/workspace/__init__.py +40 -0
  145. codex_autorunner/workspace/paths.py +319 -0
  146. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +18 -21
  147. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  148. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  149. codex_autorunner/agents/execution/policy.py +0 -292
  150. codex_autorunner/agents/factory.py +0 -52
  151. codex_autorunner/agents/orchestrator.py +0 -358
  152. codex_autorunner/core/doc_chat.py +0 -1446
  153. codex_autorunner/core/snapshot.py +0 -580
  154. codex_autorunner/integrations/github/chatops.py +0 -268
  155. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  156. codex_autorunner/routes/docs.py +0 -381
  157. codex_autorunner/routes/github.py +0 -327
  158. codex_autorunner/routes/runs.py +0 -250
  159. codex_autorunner/spec_ingest.py +0 -812
  160. codex_autorunner/static/docChatActions.js +0 -287
  161. codex_autorunner/static/docChatEvents.js +0 -300
  162. codex_autorunner/static/docChatRender.js +0 -205
  163. codex_autorunner/static/docChatStream.js +0 -361
  164. codex_autorunner/static/docs.js +0 -20
  165. codex_autorunner/static/docsClipboard.js +0 -69
  166. codex_autorunner/static/docsCrud.js +0 -257
  167. codex_autorunner/static/docsDocUpdates.js +0 -62
  168. codex_autorunner/static/docsDrafts.js +0 -16
  169. codex_autorunner/static/docsElements.js +0 -69
  170. codex_autorunner/static/docsInit.js +0 -285
  171. codex_autorunner/static/docsParse.js +0 -160
  172. codex_autorunner/static/docsSnapshot.js +0 -87
  173. codex_autorunner/static/docsSpecIngest.js +0 -263
  174. codex_autorunner/static/docsState.js +0 -127
  175. codex_autorunner/static/docsThreadRegistry.js +0 -44
  176. codex_autorunner/static/docsUi.js +0 -153
  177. codex_autorunner/static/docsVoice.js +0 -56
  178. codex_autorunner/static/github.js +0 -504
  179. codex_autorunner/static/logs.js +0 -678
  180. codex_autorunner/static/review.js +0 -157
  181. codex_autorunner/static/runs.js +0 -418
  182. codex_autorunner/static/snapshot.js +0 -124
  183. codex_autorunner/static/state.js +0 -94
  184. codex_autorunner/static/todoPreview.js +0 -27
  185. codex_autorunner/workspace.py +0 -16
  186. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  187. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  188. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  189. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,485 @@
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 .models import (
11
+ FlowArtifact,
12
+ FlowEvent,
13
+ FlowEventType,
14
+ FlowRunRecord,
15
+ FlowRunStatus,
16
+ )
17
+
18
+ _logger = logging.getLogger(__name__)
19
+
20
+ SCHEMA_VERSION = 2
21
+ UNSET = object()
22
+
23
+
24
+ def now_iso() -> str:
25
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
26
+
27
+
28
+ class FlowStore:
29
+ def __init__(self, db_path: Path):
30
+ self.db_path = db_path
31
+ self._local: threading.local = threading.local()
32
+
33
+ def _get_conn(self) -> sqlite3.Connection:
34
+ if not hasattr(self._local, "conn"):
35
+ # Ensure parent directory exists so sqlite can create/open the file.
36
+ try:
37
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
38
+ except Exception:
39
+ # Let sqlite raise a clearer error below if directory creation failed.
40
+ pass
41
+ self._local.conn = sqlite3.connect(
42
+ self.db_path, check_same_thread=False, isolation_level=None
43
+ )
44
+ self._local.conn.row_factory = sqlite3.Row
45
+ return cast(sqlite3.Connection, self._local.conn)
46
+
47
+ @contextmanager
48
+ def transaction(self) -> Generator[sqlite3.Connection, None, None]:
49
+ conn = self._get_conn()
50
+ try:
51
+ conn.execute("BEGIN IMMEDIATE")
52
+ yield conn
53
+ conn.commit()
54
+ except Exception:
55
+ conn.rollback()
56
+ raise
57
+
58
+ def initialize(self) -> None:
59
+ with self.transaction() as conn:
60
+ self._create_schema(conn)
61
+ self._ensure_schema_version(conn)
62
+
63
+ def _create_schema(self, conn: sqlite3.Connection) -> None:
64
+ conn.execute(
65
+ """
66
+ CREATE TABLE IF NOT EXISTS schema_info (
67
+ version INTEGER NOT NULL PRIMARY KEY
68
+ )
69
+ """
70
+ )
71
+
72
+ conn.execute(
73
+ """
74
+ CREATE TABLE IF NOT EXISTS flow_runs (
75
+ id TEXT PRIMARY KEY,
76
+ flow_type TEXT NOT NULL,
77
+ status TEXT NOT NULL,
78
+ input_data TEXT NOT NULL,
79
+ state TEXT NOT NULL,
80
+ current_step TEXT,
81
+ stop_requested INTEGER NOT NULL DEFAULT 0,
82
+ created_at TEXT NOT NULL,
83
+ started_at TEXT,
84
+ finished_at TEXT,
85
+ error_message TEXT,
86
+ metadata TEXT NOT NULL DEFAULT '{}'
87
+ )
88
+ """
89
+ )
90
+
91
+ conn.execute(
92
+ """
93
+ CREATE TABLE IF NOT EXISTS flow_events (
94
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
95
+ id TEXT NOT NULL UNIQUE,
96
+ run_id TEXT NOT NULL,
97
+ event_type TEXT NOT NULL,
98
+ timestamp TEXT NOT NULL,
99
+ data TEXT NOT NULL,
100
+ step_id TEXT,
101
+ FOREIGN KEY (run_id) REFERENCES flow_runs(id) ON DELETE CASCADE
102
+ )
103
+ """
104
+ )
105
+
106
+ conn.execute(
107
+ """
108
+ CREATE TABLE IF NOT EXISTS flow_artifacts (
109
+ id TEXT PRIMARY KEY,
110
+ run_id TEXT NOT NULL,
111
+ kind TEXT NOT NULL,
112
+ path TEXT NOT NULL,
113
+ created_at TEXT NOT NULL,
114
+ metadata TEXT NOT NULL DEFAULT '{}',
115
+ FOREIGN KEY (run_id) REFERENCES flow_runs(id) ON DELETE CASCADE
116
+ )
117
+ """
118
+ )
119
+
120
+ conn.execute(
121
+ "CREATE INDEX IF NOT EXISTS idx_flow_runs_status ON flow_runs(status)"
122
+ )
123
+ conn.execute(
124
+ "CREATE INDEX IF NOT EXISTS idx_flow_events_run_id ON flow_events(run_id, seq)"
125
+ )
126
+ conn.execute(
127
+ "CREATE INDEX IF NOT EXISTS idx_flow_artifacts_run_id ON flow_artifacts(run_id)"
128
+ )
129
+
130
+ def _ensure_schema_version(self, conn: sqlite3.Connection) -> None:
131
+ result = conn.execute("SELECT version FROM schema_info").fetchone()
132
+ if result is None:
133
+ conn.execute(
134
+ "INSERT INTO schema_info (version) VALUES (?)", (SCHEMA_VERSION,)
135
+ )
136
+ else:
137
+ current_version = result[0]
138
+ if current_version < SCHEMA_VERSION:
139
+ self._migrate_schema(conn, current_version, SCHEMA_VERSION)
140
+
141
+ def _migrate_schema(
142
+ self, conn: sqlite3.Connection, from_version: int, to_version: int
143
+ ) -> None:
144
+ _logger.info("Migrating schema from version %d to %d", from_version, to_version)
145
+ for version in range(from_version, to_version):
146
+ self._apply_migration(conn, version + 1)
147
+ conn.execute("UPDATE schema_info SET version = ?", (to_version,))
148
+
149
+ def _apply_migration(self, conn: sqlite3.Connection, version: int) -> None:
150
+ if version == 1:
151
+ pass
152
+ elif version == 2:
153
+ conn.execute("ALTER TABLE flow_events RENAME TO flow_events_old")
154
+ conn.execute(
155
+ """
156
+ CREATE TABLE flow_events (
157
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
158
+ id TEXT NOT NULL UNIQUE,
159
+ run_id TEXT NOT NULL,
160
+ event_type TEXT NOT NULL,
161
+ timestamp TEXT NOT NULL,
162
+ data TEXT NOT NULL,
163
+ step_id TEXT,
164
+ FOREIGN KEY (run_id) REFERENCES flow_runs(id) ON DELETE CASCADE
165
+ )
166
+ """
167
+ )
168
+ conn.execute(
169
+ """
170
+ INSERT INTO flow_events (id, run_id, event_type, timestamp, data, step_id)
171
+ SELECT id, run_id, event_type, timestamp, data, step_id
172
+ FROM flow_events_old
173
+ ORDER BY timestamp ASC
174
+ """
175
+ )
176
+ conn.execute("DROP TABLE flow_events_old")
177
+ conn.execute(
178
+ "CREATE INDEX IF NOT EXISTS idx_flow_events_run_id ON flow_events(run_id, seq)"
179
+ )
180
+
181
+ def create_flow_run(
182
+ self,
183
+ run_id: str,
184
+ flow_type: str,
185
+ input_data: Dict[str, Any],
186
+ metadata: Optional[Dict[str, Any]] = None,
187
+ state: Optional[Dict[str, Any]] = None,
188
+ current_step: Optional[str] = None,
189
+ ) -> FlowRunRecord:
190
+ now = now_iso()
191
+ record = FlowRunRecord(
192
+ id=run_id,
193
+ flow_type=flow_type,
194
+ status=FlowRunStatus.PENDING,
195
+ input_data=input_data,
196
+ state=state or {},
197
+ current_step=current_step,
198
+ stop_requested=False,
199
+ created_at=now,
200
+ metadata=metadata or {},
201
+ )
202
+
203
+ with self.transaction() as conn:
204
+ conn.execute(
205
+ """
206
+ INSERT INTO flow_runs (
207
+ id, flow_type, status, input_data, state, current_step,
208
+ stop_requested, created_at, metadata
209
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
210
+ """,
211
+ (
212
+ record.id,
213
+ record.flow_type,
214
+ record.status.value,
215
+ json.dumps(record.input_data),
216
+ json.dumps(record.state),
217
+ record.current_step,
218
+ 1 if record.stop_requested else 0,
219
+ record.created_at,
220
+ json.dumps(record.metadata),
221
+ ),
222
+ )
223
+
224
+ return record
225
+
226
+ def get_flow_run(self, run_id: str) -> Optional[FlowRunRecord]:
227
+ conn = self._get_conn()
228
+ row = conn.execute("SELECT * FROM flow_runs WHERE id = ?", (run_id,)).fetchone()
229
+ if row is None:
230
+ return None
231
+ return self._row_to_flow_run(row)
232
+
233
+ def update_flow_run_status(
234
+ self,
235
+ run_id: str,
236
+ status: FlowRunStatus,
237
+ current_step: Any = UNSET,
238
+ state: Any = UNSET,
239
+ started_at: Any = UNSET,
240
+ finished_at: Any = UNSET,
241
+ error_message: Any = UNSET,
242
+ ) -> Optional[FlowRunRecord]:
243
+ updates = ["status = ?"]
244
+ params: List[Any] = [status.value]
245
+
246
+ if current_step is not UNSET:
247
+ updates.append("current_step = ?")
248
+ params.append(current_step)
249
+
250
+ if state is not UNSET:
251
+ updates.append("state = ?")
252
+ params.append(json.dumps(state))
253
+
254
+ if started_at is not UNSET:
255
+ updates.append("started_at = ?")
256
+ params.append(started_at)
257
+
258
+ if finished_at is not UNSET:
259
+ updates.append("finished_at = ?")
260
+ params.append(finished_at)
261
+
262
+ if error_message is not UNSET:
263
+ updates.append("error_message = ?")
264
+ params.append(error_message)
265
+
266
+ params.append(run_id)
267
+
268
+ with self.transaction() as conn:
269
+ conn.execute(
270
+ f"UPDATE flow_runs SET {', '.join(updates)} WHERE id = ?",
271
+ params,
272
+ )
273
+ row = conn.execute(
274
+ "SELECT * FROM flow_runs WHERE id = ?", (run_id,)
275
+ ).fetchone()
276
+ if row is None:
277
+ return None
278
+ return self._row_to_flow_run(row)
279
+
280
+ def set_stop_requested(
281
+ self, run_id: str, stop_requested: bool
282
+ ) -> Optional[FlowRunRecord]:
283
+ with self.transaction() as conn:
284
+ conn.execute(
285
+ "UPDATE flow_runs SET stop_requested = ? WHERE id = ?",
286
+ (1 if stop_requested else 0, run_id),
287
+ )
288
+ row = conn.execute(
289
+ "SELECT * FROM flow_runs WHERE id = ?", (run_id,)
290
+ ).fetchone()
291
+ if row is None:
292
+ return None
293
+ return self._row_to_flow_run(row)
294
+
295
+ def update_current_step(
296
+ self, run_id: str, current_step: str
297
+ ) -> Optional[FlowRunRecord]:
298
+ with self.transaction() as conn:
299
+ conn.execute(
300
+ "UPDATE flow_runs SET current_step = ? WHERE id = ?",
301
+ (current_step, run_id),
302
+ )
303
+ row = conn.execute(
304
+ "SELECT * FROM flow_runs WHERE id = ?", (run_id,)
305
+ ).fetchone()
306
+ if row is None:
307
+ return None
308
+ return self._row_to_flow_run(row)
309
+
310
+ def list_flow_runs(
311
+ self, flow_type: Optional[str] = None, status: Optional[FlowRunStatus] = None
312
+ ) -> List[FlowRunRecord]:
313
+ conn = self._get_conn()
314
+ query = "SELECT * FROM flow_runs WHERE 1=1"
315
+ params: List[Any] = []
316
+
317
+ if flow_type is not None:
318
+ query += " AND flow_type = ?"
319
+ params.append(flow_type)
320
+
321
+ if status is not None:
322
+ query += " AND status = ?"
323
+ params.append(status.value)
324
+
325
+ query += " ORDER BY created_at DESC"
326
+
327
+ rows = conn.execute(query, params).fetchall()
328
+ return [self._row_to_flow_run(row) for row in rows]
329
+
330
+ def create_event(
331
+ self,
332
+ event_id: str,
333
+ run_id: str,
334
+ event_type: FlowEventType,
335
+ data: Optional[Dict[str, Any]] = None,
336
+ step_id: Optional[str] = None,
337
+ ) -> FlowEvent:
338
+ timestamp = now_iso()
339
+
340
+ with self.transaction() as conn:
341
+ conn.execute(
342
+ """
343
+ INSERT INTO flow_events (id, run_id, event_type, timestamp, data, step_id)
344
+ VALUES (?, ?, ?, ?, ?, ?)
345
+ """,
346
+ (
347
+ event_id,
348
+ run_id,
349
+ event_type.value,
350
+ timestamp,
351
+ json.dumps(data or {}),
352
+ step_id,
353
+ ),
354
+ )
355
+ row = conn.execute(
356
+ "SELECT * FROM flow_events WHERE id = ?", (event_id,)
357
+ ).fetchone()
358
+
359
+ if row is None:
360
+ raise RuntimeError("Failed to persist flow event")
361
+
362
+ return self._row_to_flow_event(row)
363
+
364
+ def get_events(
365
+ self,
366
+ run_id: str,
367
+ after_seq: Optional[int] = None,
368
+ limit: Optional[int] = None,
369
+ ) -> List[FlowEvent]:
370
+ conn = self._get_conn()
371
+ query = "SELECT * FROM flow_events WHERE run_id = ?"
372
+ params: List[Any] = [run_id]
373
+
374
+ if after_seq is not None:
375
+ query += " AND seq > ?"
376
+ params.append(after_seq)
377
+
378
+ query += " ORDER BY seq ASC"
379
+
380
+ if limit is not None:
381
+ query += " LIMIT ?"
382
+ params.append(limit)
383
+
384
+ rows = conn.execute(query, params).fetchall()
385
+ return [self._row_to_flow_event(row) for row in rows]
386
+
387
+ def create_artifact(
388
+ self,
389
+ artifact_id: str,
390
+ run_id: str,
391
+ kind: str,
392
+ path: str,
393
+ metadata: Optional[Dict[str, Any]] = None,
394
+ ) -> FlowArtifact:
395
+ artifact = FlowArtifact(
396
+ id=artifact_id,
397
+ run_id=run_id,
398
+ kind=kind,
399
+ path=path,
400
+ created_at=now_iso(),
401
+ metadata=metadata or {},
402
+ )
403
+
404
+ with self.transaction() as conn:
405
+ conn.execute(
406
+ """
407
+ INSERT INTO flow_artifacts (id, run_id, kind, path, created_at, metadata)
408
+ VALUES (?, ?, ?, ?, ?, ?)
409
+ """,
410
+ (
411
+ artifact.id,
412
+ artifact.run_id,
413
+ artifact.kind,
414
+ artifact.path,
415
+ artifact.created_at,
416
+ json.dumps(artifact.metadata),
417
+ ),
418
+ )
419
+
420
+ return artifact
421
+
422
+ def get_artifacts(self, run_id: str) -> List[FlowArtifact]:
423
+ conn = self._get_conn()
424
+ rows = conn.execute(
425
+ "SELECT * FROM flow_artifacts WHERE run_id = ? ORDER BY created_at ASC",
426
+ (run_id,),
427
+ ).fetchall()
428
+ return [self._row_to_flow_artifact(row) for row in rows]
429
+
430
+ def get_artifact(self, artifact_id: str) -> Optional[FlowArtifact]:
431
+ conn = self._get_conn()
432
+ row = conn.execute(
433
+ "SELECT * FROM flow_artifacts WHERE id = ?", (artifact_id,)
434
+ ).fetchone()
435
+ if row is None:
436
+ return None
437
+ return self._row_to_flow_artifact(row)
438
+
439
+ def delete_flow_run(self, run_id: str) -> bool:
440
+ """Delete a flow run and its events/artifacts (cascading)."""
441
+ with self.transaction() as conn:
442
+ cursor = conn.execute("DELETE FROM flow_runs WHERE id = ?", (run_id,))
443
+ return cursor.rowcount > 0
444
+
445
+ def _row_to_flow_run(self, row: sqlite3.Row) -> FlowRunRecord:
446
+ return FlowRunRecord(
447
+ id=row["id"],
448
+ flow_type=row["flow_type"],
449
+ status=FlowRunStatus(row["status"]),
450
+ input_data=json.loads(row["input_data"]),
451
+ state=json.loads(row["state"]),
452
+ current_step=row["current_step"],
453
+ stop_requested=bool(row["stop_requested"]),
454
+ created_at=row["created_at"],
455
+ started_at=row["started_at"],
456
+ finished_at=row["finished_at"],
457
+ error_message=row["error_message"],
458
+ metadata=json.loads(row["metadata"]),
459
+ )
460
+
461
+ def _row_to_flow_event(self, row: sqlite3.Row) -> FlowEvent:
462
+ return FlowEvent(
463
+ seq=row["seq"],
464
+ id=row["id"],
465
+ run_id=row["run_id"],
466
+ event_type=FlowEventType(row["event_type"]),
467
+ timestamp=row["timestamp"],
468
+ data=json.loads(row["data"]),
469
+ step_id=row["step_id"],
470
+ )
471
+
472
+ def _row_to_flow_artifact(self, row: sqlite3.Row) -> FlowArtifact:
473
+ return FlowArtifact(
474
+ id=row["id"],
475
+ run_id=row["run_id"],
476
+ kind=row["kind"],
477
+ path=row["path"],
478
+ created_at=row["created_at"],
479
+ metadata=json.loads(row["metadata"]),
480
+ )
481
+
482
+ def close(self) -> None:
483
+ if hasattr(self._local, "conn"):
484
+ self._local.conn.close()
485
+ del self._local.conn
@@ -0,0 +1,133 @@
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.store import now_iso
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class TransitionDecision:
12
+ """Result of resolving a flow's next status.
13
+
14
+ Attributes
15
+ ----------
16
+ status: FlowRunStatus
17
+ The resolved outer status.
18
+ finished_at: Optional[str]
19
+ Completion timestamp when the flow reaches a terminal state.
20
+ state: dict[str, Any]
21
+ Updated state payload (ticket_engine etc.).
22
+ note: Optional[str]
23
+ Reason for the transition (useful for tests/logging).
24
+ """
25
+
26
+ status: FlowRunStatus
27
+ finished_at: Optional[str]
28
+ state: dict[str, Any]
29
+ note: Optional[str] = None
30
+
31
+
32
+ def resolve_flow_transition(
33
+ record: FlowRunRecord, health: Any, now: Optional[str] = None
34
+ ) -> TransitionDecision:
35
+ """Derive the flow status from worker liveness and inner ticket_engine state.
36
+
37
+ This is intentionally pure and side-effect free to keep recovery/test logic simple.
38
+ """
39
+
40
+ now = now or now_iso()
41
+ state: dict[str, Any] = record.state or {}
42
+ engine_raw = state.get("ticket_engine") if isinstance(state, dict) else {}
43
+ engine: dict[str, Any] = engine_raw if isinstance(engine_raw, dict) else {}
44
+ inner_status = engine.get("status")
45
+ reason_code = engine.get("reason_code")
46
+
47
+ # 1) Worker liveness overrides for active flows.
48
+ if (
49
+ record.status in (FlowRunStatus.RUNNING, FlowRunStatus.STOPPING)
50
+ and not health.is_alive
51
+ ):
52
+ new_status = (
53
+ FlowRunStatus.STOPPED
54
+ if record.status == FlowRunStatus.STOPPING
55
+ else FlowRunStatus.FAILED
56
+ )
57
+ return TransitionDecision(
58
+ status=new_status, finished_at=now, state=state, note="worker-dead"
59
+ )
60
+
61
+ # 2) Inner engine reconciliation (worker is alive or not required).
62
+ if record.status == FlowRunStatus.RUNNING:
63
+ if inner_status == "paused":
64
+ return TransitionDecision(
65
+ status=FlowRunStatus.PAUSED,
66
+ finished_at=None,
67
+ state=state,
68
+ note="engine-paused",
69
+ )
70
+
71
+ if inner_status == "completed":
72
+ return TransitionDecision(
73
+ status=FlowRunStatus.COMPLETED,
74
+ finished_at=now,
75
+ state=state,
76
+ note="engine-completed",
77
+ )
78
+
79
+ return TransitionDecision(
80
+ status=FlowRunStatus.RUNNING, finished_at=None, state=state, note="running"
81
+ )
82
+
83
+ if record.status == FlowRunStatus.PAUSED:
84
+ if inner_status == "completed":
85
+ return TransitionDecision(
86
+ status=FlowRunStatus.COMPLETED,
87
+ finished_at=now,
88
+ state=state,
89
+ note="paused-engine-completed",
90
+ )
91
+
92
+ if (
93
+ inner_status in (None, "running")
94
+ and reason_code != "user_pause"
95
+ and health.is_alive
96
+ ):
97
+ # Treat as stale pause; resume and clear pause metadata.
98
+ engine.pop("reason", None)
99
+ engine.pop("reason_details", None)
100
+ engine.pop("reason_code", None)
101
+ engine["status"] = "running"
102
+ state["ticket_engine"] = engine
103
+ return TransitionDecision(
104
+ status=FlowRunStatus.RUNNING,
105
+ finished_at=None,
106
+ state=state,
107
+ note="stale-pause-resumed",
108
+ )
109
+
110
+ if not health.is_alive:
111
+ return TransitionDecision(
112
+ status=FlowRunStatus.PAUSED,
113
+ finished_at=None,
114
+ state=state,
115
+ note="paused-worker-dead",
116
+ )
117
+
118
+ return TransitionDecision(
119
+ status=FlowRunStatus.PAUSED, finished_at=None, state=state, note="paused"
120
+ )
121
+
122
+ # STOPPING/STOPPED/COMPLETED/FAILED: leave unchanged.
123
+ if record.status.is_terminal() or record.status == FlowRunStatus.STOPPED:
124
+ return TransitionDecision(
125
+ status=record.status,
126
+ finished_at=record.finished_at,
127
+ state=state,
128
+ note="terminal",
129
+ )
130
+
131
+ return TransitionDecision(
132
+ status=record.status, finished_at=None, state=state, note="unchanged"
133
+ )