crabcode 0.1.6__py3-none-win_amd64.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 (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. crabcode_search/tool.py +268 -0
@@ -0,0 +1,558 @@
1
+ """SQLite schedule store — persistent storage for scheduled jobs and their runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sqlite3
7
+ from datetime import datetime, timedelta, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from crabcode_core.paths import get_config_home
12
+ from crabcode_core.schedule.models import JobRun, ScheduleJob
13
+
14
+
15
+ def _db_path() -> Path:
16
+ """Database path below the configured CrabCode home."""
17
+ return get_config_home() / "schedules.db"
18
+
19
+
20
+ _SCHEMA = """\
21
+ CREATE TABLE IF NOT EXISTS schedules (
22
+ id TEXT PRIMARY KEY,
23
+ name TEXT NOT NULL,
24
+ prompt TEXT NOT NULL,
25
+ schedule TEXT NOT NULL,
26
+ schedule_type TEXT NOT NULL,
27
+ cwd TEXT,
28
+ enabled INTEGER NOT NULL DEFAULT 1,
29
+ status TEXT NOT NULL DEFAULT 'active',
30
+ last_run TEXT,
31
+ next_run TEXT,
32
+ run_count INTEGER NOT NULL DEFAULT 0,
33
+ max_runs INTEGER,
34
+ created_at TEXT NOT NULL,
35
+ session_id TEXT,
36
+ description TEXT NOT NULL DEFAULT '',
37
+ tags TEXT NOT NULL DEFAULT '[]',
38
+ timeout INTEGER,
39
+ model_profile TEXT,
40
+ extra TEXT NOT NULL DEFAULT '{}',
41
+ claimed_by TEXT,
42
+ claimed_until TEXT
43
+ );
44
+
45
+ CREATE TABLE IF NOT EXISTS schedule_runs (
46
+ id TEXT PRIMARY KEY,
47
+ job_id TEXT NOT NULL,
48
+ status TEXT NOT NULL DEFAULT 'pending',
49
+ started_at TEXT,
50
+ finished_at TEXT,
51
+ duration_seconds REAL,
52
+ session_id TEXT,
53
+ exit_code INTEGER,
54
+ error_message TEXT,
55
+ result_summary TEXT NOT NULL DEFAULT '',
56
+ tokens_used INTEGER NOT NULL DEFAULT 0,
57
+ created_at TEXT NOT NULL,
58
+ FOREIGN KEY (job_id) REFERENCES schedules(id) ON DELETE CASCADE
59
+ );
60
+
61
+ CREATE INDEX IF NOT EXISTS idx_schedule_runs_job
62
+ ON schedule_runs(job_id, created_at DESC);
63
+
64
+ CREATE INDEX IF NOT EXISTS idx_schedules_pending
65
+ ON schedules(next_run)
66
+ WHERE status = 'active' AND enabled = 1;
67
+ """
68
+
69
+ _MIGRATIONS: list[str] = [
70
+ "ALTER TABLE schedules ADD COLUMN claimed_by TEXT",
71
+ "ALTER TABLE schedules ADD COLUMN claimed_until TEXT",
72
+ ]
73
+
74
+
75
+ def _get_conn(db_path: Path) -> sqlite3.Connection:
76
+ """Open (or create) the SQLite database and ensure the schema exists."""
77
+ db_path.parent.mkdir(parents=True, exist_ok=True)
78
+ conn = sqlite3.connect(str(db_path))
79
+ conn.execute("PRAGMA busy_timeout=5000")
80
+ conn.execute("PRAGMA journal_mode=WAL")
81
+ conn.execute("PRAGMA foreign_keys=ON")
82
+ conn.executescript(_SCHEMA)
83
+ for stmt in _MIGRATIONS:
84
+ try:
85
+ conn.execute(stmt)
86
+ conn.commit()
87
+ except sqlite3.OperationalError:
88
+ pass # column/table already exists
89
+ return conn
90
+
91
+
92
+ def _row_to_schedule(row: tuple[Any, ...], cols: list[str]) -> dict[str, Any]:
93
+ """Convert a raw DB row into a dict, deserialising JSON columns."""
94
+ d = dict(zip(cols, row))
95
+ # Deserialize JSON columns
96
+ for key in ("tags", "extra"):
97
+ val = d.get(key)
98
+ if isinstance(val, str):
99
+ try:
100
+ d[key] = json.loads(val)
101
+ except (json.JSONDecodeError, TypeError):
102
+ d[key] = [] if key == "tags" else {}
103
+ # Convert SQLite integer booleans
104
+ if "enabled" in d and isinstance(d["enabled"], int):
105
+ d["enabled"] = bool(d["enabled"])
106
+ return d
107
+
108
+
109
+ def _row_to_run(row: tuple[Any, ...], cols: list[str]) -> dict[str, Any]:
110
+ """Convert a raw schedule_runs row into a dict."""
111
+ d = dict(zip(cols, row))
112
+ return d
113
+
114
+
115
+ class ScheduleStore:
116
+ """SQLite-backed persistence for :class:`ScheduleJob` and :class:`JobRun`."""
117
+
118
+ def __init__(self, db_path: Path | None = None):
119
+ self._db_path = db_path or _db_path()
120
+ self._conn: sqlite3.Connection | None = None
121
+
122
+ # ------------------------------------------------------------------
123
+ # Connection lifecycle
124
+ # ------------------------------------------------------------------
125
+
126
+ def _conn_or_create(self) -> sqlite3.Connection:
127
+ if self._conn is None:
128
+ self._conn = _get_conn(self._db_path)
129
+ return self._conn
130
+
131
+ def close(self) -> None:
132
+ if self._conn:
133
+ self._conn.close()
134
+ self._conn = None
135
+
136
+ # ------------------------------------------------------------------
137
+ # Schedule CRUD
138
+ # ------------------------------------------------------------------
139
+
140
+ def upsert_schedule(self, job: ScheduleJob) -> None:
141
+ """Insert or update a :class:`ScheduleJob` row."""
142
+ conn = self._conn_or_create()
143
+ conn.execute(
144
+ """INSERT INTO schedules
145
+ (id, name, prompt, schedule, schedule_type, cwd, enabled, status,
146
+ last_run, next_run, run_count, max_runs, created_at, session_id,
147
+ description, tags, timeout, model_profile, extra)
148
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
149
+ ON CONFLICT(id) DO UPDATE SET
150
+ name = excluded.name,
151
+ prompt = excluded.prompt,
152
+ schedule = excluded.schedule,
153
+ schedule_type = excluded.schedule_type,
154
+ cwd = excluded.cwd,
155
+ enabled = excluded.enabled,
156
+ status = excluded.status,
157
+ last_run = excluded.last_run,
158
+ next_run = excluded.next_run,
159
+ run_count = excluded.run_count,
160
+ max_runs = excluded.max_runs,
161
+ created_at = excluded.created_at,
162
+ session_id = excluded.session_id,
163
+ description = excluded.description,
164
+ tags = excluded.tags,
165
+ timeout = excluded.timeout,
166
+ model_profile = excluded.model_profile,
167
+ extra = excluded.extra,
168
+ claimed_by = NULL,
169
+ claimed_until = NULL""",
170
+ (
171
+ job.id,
172
+ job.name,
173
+ job.prompt,
174
+ job.schedule,
175
+ job.schedule_type.value,
176
+ job.cwd,
177
+ 1 if job.enabled else 0,
178
+ job.status.value,
179
+ job.last_run,
180
+ job.next_run,
181
+ job.run_count,
182
+ job.max_runs,
183
+ job.created_at,
184
+ job.session_id,
185
+ job.description,
186
+ json.dumps(job.tags, ensure_ascii=False),
187
+ job.timeout,
188
+ job.model_profile,
189
+ json.dumps(job.extra, ensure_ascii=False),
190
+ ),
191
+ )
192
+ conn.commit()
193
+
194
+ def get_schedule(self, job_id: str) -> dict[str, Any] | None:
195
+ """Return a single schedule as a dict, or ``None`` if not found."""
196
+ conn = self._conn_or_create()
197
+ row = conn.execute(
198
+ "SELECT * FROM schedules WHERE id = ?", (job_id,)
199
+ ).fetchone()
200
+ if not row:
201
+ return None
202
+ cols = [d[0] for d in conn.execute("SELECT * FROM schedules LIMIT 0").description]
203
+ return _row_to_schedule(row, cols)
204
+
205
+ def resolve_schedule(self, selector: str) -> dict[str, Any] | None:
206
+ """Resolve an exact job ID or one unambiguous ID prefix."""
207
+ value = str(selector or "").strip()
208
+ if not value:
209
+ return None
210
+ exact = self.get_schedule(value)
211
+ if exact is not None:
212
+ return exact
213
+ conn = self._conn_or_create()
214
+ rows = conn.execute(
215
+ "SELECT * FROM schedules WHERE id LIKE ? ORDER BY created_at DESC LIMIT 2",
216
+ (f"{value}%",),
217
+ ).fetchall()
218
+ if len(rows) != 1:
219
+ return None
220
+ cols = [d[0] for d in conn.execute("SELECT * FROM schedules LIMIT 0").description]
221
+ return _row_to_schedule(rows[0], cols)
222
+
223
+ def list_schedules(
224
+ self,
225
+ *,
226
+ status: str | None = None,
227
+ schedule_type: str | None = None,
228
+ enabled: bool | None = None,
229
+ limit: int = 100,
230
+ ) -> list[dict[str, Any]]:
231
+ """List schedules with optional filtering, most recently created first."""
232
+ conn = self._conn_or_create()
233
+ clauses: list[str] = []
234
+ params: list[Any] = []
235
+ if status is not None:
236
+ clauses.append("status = ?")
237
+ params.append(status)
238
+ if schedule_type is not None:
239
+ clauses.append("schedule_type = ?")
240
+ params.append(schedule_type)
241
+ if enabled is not None:
242
+ clauses.append("enabled = ?")
243
+ params.append(1 if enabled else 0)
244
+
245
+ where = ""
246
+ if clauses:
247
+ where = "WHERE " + " AND ".join(clauses)
248
+
249
+ rows = conn.execute(
250
+ f"SELECT * FROM schedules {where} ORDER BY created_at DESC LIMIT ?",
251
+ (*params, limit),
252
+ ).fetchall()
253
+ cols = [d[0] for d in conn.execute("SELECT * FROM schedules LIMIT 0").description]
254
+ return [_row_to_schedule(r, cols) for r in rows]
255
+
256
+ def delete_schedule(self, job_id: str) -> None:
257
+ """Delete a schedule and all its associated runs (cascade)."""
258
+ conn = self._conn_or_create()
259
+ # Delete runs first (defensive — FK ON DELETE CASCADE should handle it)
260
+ conn.execute("DELETE FROM schedule_runs WHERE job_id = ?", (job_id,))
261
+ conn.execute("DELETE FROM schedules WHERE id = ?", (job_id,))
262
+ conn.commit()
263
+
264
+ def claim_due_schedules(
265
+ self,
266
+ owner: str,
267
+ *,
268
+ now_iso: str | None = None,
269
+ limit: int = 1,
270
+ lease_seconds: int = 660,
271
+ ) -> list[dict[str, Any]]:
272
+ """Atomically lease due jobs to one scheduler instance.
273
+
274
+ The lease is stored in SQLite so multiple CrabCode processes can share
275
+ the default schedule database without executing the same occurrence.
276
+ """
277
+ conn = self._conn_or_create()
278
+ now = now_iso or datetime.now(timezone.utc).isoformat()
279
+ lease_until = (
280
+ datetime.fromisoformat(now) + timedelta(seconds=max(1, lease_seconds))
281
+ ).isoformat()
282
+ safe_limit = max(1, int(limit))
283
+ try:
284
+ conn.execute("BEGIN IMMEDIATE")
285
+ rows = conn.execute(
286
+ """SELECT * FROM schedules
287
+ WHERE status = 'active'
288
+ AND enabled = 1
289
+ AND next_run IS NOT NULL
290
+ AND next_run <= ?
291
+ AND (max_runs IS NULL OR run_count < max_runs)
292
+ AND (claimed_until IS NULL OR claimed_until <= ?)
293
+ ORDER BY next_run ASC
294
+ LIMIT ?""",
295
+ (now, now, safe_limit),
296
+ ).fetchall()
297
+ cols = [
298
+ d[0]
299
+ for d in conn.execute("SELECT * FROM schedules LIMIT 0").description
300
+ ]
301
+ claimed: list[dict[str, Any]] = []
302
+ for row in rows:
303
+ data = _row_to_schedule(row, cols)
304
+ changed = conn.execute(
305
+ """UPDATE schedules
306
+ SET claimed_by = ?, claimed_until = ?
307
+ WHERE id = ?
308
+ AND status = 'active'
309
+ AND enabled = 1
310
+ AND next_run IS NOT NULL
311
+ AND next_run <= ?
312
+ AND (claimed_until IS NULL OR claimed_until <= ?)""",
313
+ (owner, lease_until, data["id"], now, now),
314
+ ).rowcount
315
+ if changed:
316
+ data["claimed_by"] = owner
317
+ data["claimed_until"] = lease_until
318
+ claimed.append(data)
319
+ conn.commit()
320
+ return claimed
321
+ except BaseException:
322
+ conn.rollback()
323
+ raise
324
+
325
+ def release_claim(self, job_id: str, owner: str) -> None:
326
+ """Release a lease only when it is still owned by *owner*."""
327
+ conn = self._conn_or_create()
328
+ conn.execute(
329
+ """UPDATE schedules
330
+ SET claimed_by = NULL, claimed_until = NULL
331
+ WHERE id = ? AND claimed_by = ?""",
332
+ (job_id, owner),
333
+ )
334
+ conn.commit()
335
+
336
+ def claim_schedule(
337
+ self,
338
+ job_id: str,
339
+ owner: str,
340
+ *,
341
+ lease_seconds: int,
342
+ include_terminal: bool = False,
343
+ ) -> bool:
344
+ """Lease a job for a manual run, optionally including terminal jobs."""
345
+ conn = self._conn_or_create()
346
+ now = datetime.now(timezone.utc)
347
+ status_clause = (
348
+ "status IN ('active', 'error', 'disabled')"
349
+ if include_terminal
350
+ else "status = 'active' AND enabled = 1"
351
+ )
352
+ changed = conn.execute(
353
+ f"""UPDATE schedules
354
+ SET claimed_by = ?, claimed_until = ?
355
+ WHERE id = ?
356
+ AND {status_clause}
357
+ AND (claimed_until IS NULL OR claimed_until <= ?)""",
358
+ (
359
+ owner,
360
+ (now + timedelta(seconds=max(1, lease_seconds))).isoformat(),
361
+ job_id,
362
+ now.isoformat(),
363
+ ),
364
+ ).rowcount
365
+ conn.commit()
366
+ return bool(changed)
367
+
368
+ def renew_claim(self, job_id: str, owner: str, lease_seconds: int) -> bool:
369
+ """Extend an existing lease for a bounded execution."""
370
+ conn = self._conn_or_create()
371
+ claimed_until = (
372
+ datetime.now(timezone.utc) + timedelta(seconds=max(1, lease_seconds))
373
+ ).isoformat()
374
+ changed = conn.execute(
375
+ """UPDATE schedules
376
+ SET claimed_until = ?
377
+ WHERE id = ? AND claimed_by = ?""",
378
+ (claimed_until, job_id, owner),
379
+ ).rowcount
380
+ conn.commit()
381
+ return bool(changed)
382
+
383
+ def release_all_claims(self, owner: str) -> None:
384
+ conn = self._conn_or_create()
385
+ conn.execute(
386
+ """UPDATE schedules
387
+ SET claimed_by = NULL, claimed_until = NULL
388
+ WHERE claimed_by = ?""",
389
+ (owner,),
390
+ )
391
+ conn.commit()
392
+
393
+ # ------------------------------------------------------------------
394
+ # JobRun operations
395
+ # ------------------------------------------------------------------
396
+
397
+ def record_run(self, run: JobRun) -> None:
398
+ """Persist a :class:`JobRun` record (insert only — runs are immutable)."""
399
+ conn = self._conn_or_create()
400
+ conn.execute(
401
+ """INSERT INTO schedule_runs
402
+ (id, job_id, status, started_at, finished_at, duration_seconds,
403
+ session_id, exit_code, error_message, result_summary,
404
+ tokens_used, created_at)
405
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
406
+ (
407
+ run.id,
408
+ run.job_id,
409
+ run.status.value,
410
+ run.started_at,
411
+ run.finished_at,
412
+ run.duration_seconds,
413
+ run.session_id,
414
+ run.exit_code,
415
+ run.error_message,
416
+ run.result_summary,
417
+ run.tokens_used,
418
+ run.created_at,
419
+ ),
420
+ )
421
+ conn.commit()
422
+
423
+ def update_run(self, run: JobRun) -> None:
424
+ """Update an existing :class:`JobRun` record (e.g. status transition)."""
425
+ conn = self._conn_or_create()
426
+ conn.execute(
427
+ """UPDATE schedule_runs
428
+ SET status = ?, started_at = ?, finished_at = ?,
429
+ duration_seconds = ?, session_id = ?, exit_code = ?,
430
+ error_message = ?, result_summary = ?, tokens_used = ?
431
+ WHERE id = ?""",
432
+ (
433
+ run.status.value,
434
+ run.started_at,
435
+ run.finished_at,
436
+ run.duration_seconds,
437
+ run.session_id,
438
+ run.exit_code,
439
+ run.error_message,
440
+ run.result_summary,
441
+ run.tokens_used,
442
+ run.id,
443
+ ),
444
+ )
445
+ conn.commit()
446
+
447
+ def list_runs(
448
+ self,
449
+ job_id: str,
450
+ *,
451
+ status: str | None = None,
452
+ limit: int = 50,
453
+ ) -> list[dict[str, Any]]:
454
+ """List runs for a given job, newest first."""
455
+ conn = self._conn_or_create()
456
+ if status is not None:
457
+ rows = conn.execute(
458
+ "SELECT * FROM schedule_runs WHERE job_id = ? AND status = ? "
459
+ "ORDER BY created_at DESC LIMIT ?",
460
+ (job_id, status, limit),
461
+ ).fetchall()
462
+ else:
463
+ rows = conn.execute(
464
+ "SELECT * FROM schedule_runs WHERE job_id = ? "
465
+ "ORDER BY created_at DESC LIMIT ?",
466
+ (job_id, limit),
467
+ ).fetchall()
468
+ cols = [d[0] for d in conn.execute("SELECT * FROM schedule_runs LIMIT 0").description]
469
+ return [_row_to_run(r, cols) for r in rows]
470
+
471
+ def get_run(self, run_id: str) -> dict[str, Any] | None:
472
+ """Return a single run as a dict, or ``None`` if not found."""
473
+ conn = self._conn_or_create()
474
+ row = conn.execute(
475
+ "SELECT * FROM schedule_runs WHERE id = ?", (run_id,)
476
+ ).fetchone()
477
+ if not row:
478
+ return None
479
+ cols = [d[0] for d in conn.execute("SELECT * FROM schedule_runs LIMIT 0").description]
480
+ return _row_to_run(row, cols)
481
+
482
+ # ------------------------------------------------------------------
483
+ # Restart recovery
484
+ # ------------------------------------------------------------------
485
+
486
+ def get_pending_schedules(self, now_iso: str | None = None) -> list[dict[str, Any]]:
487
+ """Return schedules that are due for execution (for restart recovery).
488
+
489
+ A schedule is *pending* when all of the following hold:
490
+ - ``status`` is ``active``
491
+ - ``enabled`` is true
492
+ - ``next_run`` is not ``NULL`` and ≤ *now*
493
+ - ``max_runs`` has not been reached (or is unlimited)
494
+
495
+ This is the primary method used by the scheduler engine on startup
496
+ to discover jobs that fired while the process was down.
497
+ """
498
+ conn = self._conn_or_create()
499
+ now = now_iso or datetime.now(timezone.utc).isoformat()
500
+
501
+ rows = conn.execute(
502
+ """SELECT * FROM schedules
503
+ WHERE status = 'active'
504
+ AND enabled = 1
505
+ AND next_run IS NOT NULL
506
+ AND next_run <= ?
507
+ AND (max_runs IS NULL OR run_count < max_runs)
508
+ ORDER BY next_run ASC""",
509
+ (now,),
510
+ ).fetchall()
511
+ cols = [d[0] for d in conn.execute("SELECT * FROM schedules LIMIT 0").description]
512
+ return [_row_to_schedule(r, cols) for r in rows]
513
+
514
+ def get_next_wakeup(self, now_iso: str | None = None) -> str | None:
515
+ """Return when a runnable job next needs polling.
516
+
517
+ A live lease delays the wakeup until it expires; otherwise ``next_run``
518
+ is used. This avoids both 30-second jitter for near-future jobs and a
519
+ busy loop around jobs currently owned by another process.
520
+ """
521
+ conn = self._conn_or_create()
522
+ now = now_iso or datetime.now(timezone.utc).isoformat()
523
+ rows = conn.execute(
524
+ """SELECT next_run, claimed_until FROM schedules
525
+ WHERE status = 'active'
526
+ AND enabled = 1
527
+ AND next_run IS NOT NULL
528
+ AND (max_runs IS NULL OR run_count < max_runs)"""
529
+ ).fetchall()
530
+ candidates = [
531
+ claimed_until
532
+ if claimed_until is not None and claimed_until > now
533
+ else next_run
534
+ for next_run, claimed_until in rows
535
+ ]
536
+ return min(candidates) if candidates else None
537
+
538
+ def get_stale_running_runs(self, now_iso: str | None = None) -> list[dict[str, Any]]:
539
+ """Return runs that are still marked as ``running`` (orphaned after a crash).
540
+
541
+ These should be transitioned to ``failed`` on startup.
542
+ """
543
+ conn = self._conn_or_create()
544
+ now = now_iso or datetime.now(timezone.utc).isoformat()
545
+ rows = conn.execute(
546
+ """SELECT schedule_runs.*
547
+ FROM schedule_runs
548
+ LEFT JOIN schedules ON schedules.id = schedule_runs.job_id
549
+ WHERE schedule_runs.status = 'running'
550
+ AND (
551
+ schedules.id IS NULL
552
+ OR schedules.claimed_until IS NULL
553
+ OR schedules.claimed_until <= ?
554
+ )""",
555
+ (now,),
556
+ ).fetchall()
557
+ cols = [d[0] for d in conn.execute("SELECT * FROM schedule_runs LIMIT 0").description]
558
+ return [_row_to_run(r, cols) for r in rows]