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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- 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]
|