shelldeck 0.0.1__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.
- shelldeck/__init__.py +0 -0
- shelldeck/__main__.py +4 -0
- shelldeck/addons/__init__.py +1 -0
- shelldeck/addons/fast_context.py +1346 -0
- shelldeck/auth.py +207 -0
- shelldeck/cli.py +587 -0
- shelldeck/db.py +794 -0
- shelldeck/gitgraph.py +150 -0
- shelldeck/integration/bash.sh +27 -0
- shelldeck/integration/shelldeck.fish +5 -0
- shelldeck/integration/shelldeck.ps1 +35 -0
- shelldeck/integration/zsh/.zshenv +6 -0
- shelldeck/integration/zsh/.zshrc +10 -0
- shelldeck/pty.py +352 -0
- shelldeck/runner.py +75 -0
- shelldeck/scheduler.py +283 -0
- shelldeck/server.py +1216 -0
- shelldeck/shells.py +174 -0
- shelldeck/static/app.css +2288 -0
- shelldeck/static/app.js +1863 -0
- shelldeck/static/gitgraph.js +142 -0
- shelldeck/static/history.js +172 -0
- shelldeck/static/icon-192.png +0 -0
- shelldeck/static/icon-32.png +0 -0
- shelldeck/static/icon-512.png +0 -0
- shelldeck/static/icon.svg +1 -0
- shelldeck/static/index.html +102 -0
- shelldeck/static/manifest.webmanifest +1 -0
- shelldeck/static/monitor.js +143 -0
- shelldeck/static/ui.js +316 -0
- shelldeck/static/vendor/LICENSE-xterm.txt +21 -0
- shelldeck/static/vendor/addon-fit.js +2 -0
- shelldeck/static/vendor/addon-search.js +2 -0
- shelldeck/static/vendor/addon-serialize.js +2 -0
- shelldeck/static/vendor/addon-web-links.js +2 -0
- shelldeck/static/vendor/addon-webgl.js +2 -0
- shelldeck/static/vendor/xterm.css +218 -0
- shelldeck/static/vendor/xterm.js +2 -0
- shelldeck/static/views.js +658 -0
- shelldeck/stats.py +128 -0
- shelldeck-0.0.1.dist-info/METADATA +254 -0
- shelldeck-0.0.1.dist-info/RECORD +45 -0
- shelldeck-0.0.1.dist-info/WHEEL +4 -0
- shelldeck-0.0.1.dist-info/entry_points.txt +3 -0
- shelldeck-0.0.1.dist-info/licenses/LICENSE +21 -0
shelldeck/db.py
ADDED
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import sqlite3
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def config_dir() -> Path:
|
|
13
|
+
p = Path(os.environ.get("SHELLDECK_HOME") or Path.home() / ".config" / "shelldeck")
|
|
14
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
return p
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def db_path() -> Path:
|
|
19
|
+
return config_dir() / "shelldeck.db"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _connect() -> sqlite3.Connection:
|
|
23
|
+
conn = sqlite3.connect(db_path(), timeout=10)
|
|
24
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
25
|
+
return conn
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _import_termy_db() -> None:
|
|
29
|
+
"""One-time copy of the old termy database, so projects/bookmarks/tasks carry over."""
|
|
30
|
+
old = Path.home() / ".config" / "termy" / "termy.db"
|
|
31
|
+
if "SHELLDECK_HOME" in os.environ or db_path().exists() or not old.exists():
|
|
32
|
+
return
|
|
33
|
+
shutil.copy2(old, db_path())
|
|
34
|
+
log.info("imported %s", old)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def short_id() -> str:
|
|
38
|
+
return uuid.uuid4().hex[:8]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _now() -> str:
|
|
42
|
+
return datetime.now(timezone.utc).isoformat()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def init_db() -> None:
|
|
46
|
+
_import_termy_db()
|
|
47
|
+
with _connect() as conn:
|
|
48
|
+
conn.execute("PRAGMA journal_mode = WAL")
|
|
49
|
+
conn.executescript(
|
|
50
|
+
"""
|
|
51
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
52
|
+
id TEXT PRIMARY KEY,
|
|
53
|
+
path TEXT UNIQUE NOT NULL,
|
|
54
|
+
name TEXT NOT NULL,
|
|
55
|
+
created_at TEXT NOT NULL
|
|
56
|
+
);
|
|
57
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
58
|
+
id TEXT PRIMARY KEY,
|
|
59
|
+
project_id TEXT NOT NULL,
|
|
60
|
+
cwd TEXT,
|
|
61
|
+
shell TEXT,
|
|
62
|
+
cols INTEGER,
|
|
63
|
+
rows INTEGER,
|
|
64
|
+
created_at TEXT NOT NULL,
|
|
65
|
+
name TEXT,
|
|
66
|
+
FOREIGN KEY (project_id) REFERENCES projects(id)
|
|
67
|
+
);
|
|
68
|
+
CREATE TABLE IF NOT EXISTS bookmarks (
|
|
69
|
+
id TEXT PRIMARY KEY,
|
|
70
|
+
name TEXT NOT NULL,
|
|
71
|
+
command TEXT NOT NULL,
|
|
72
|
+
project_id TEXT,
|
|
73
|
+
created_at TEXT NOT NULL,
|
|
74
|
+
updated_at TEXT NOT NULL,
|
|
75
|
+
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL
|
|
76
|
+
);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_bookmarks_project ON bookmarks(project_id);
|
|
78
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
79
|
+
key TEXT PRIMARY KEY,
|
|
80
|
+
value TEXT
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
84
|
+
id TEXT PRIMARY KEY,
|
|
85
|
+
title TEXT NOT NULL,
|
|
86
|
+
description TEXT,
|
|
87
|
+
board_column TEXT DEFAULT 'backlog',
|
|
88
|
+
priority TEXT DEFAULT 'medium',
|
|
89
|
+
due_at TEXT,
|
|
90
|
+
reminder_at TEXT,
|
|
91
|
+
reminder_acknowledged INTEGER DEFAULT 0,
|
|
92
|
+
tags TEXT,
|
|
93
|
+
created_at TEXT NOT NULL,
|
|
94
|
+
updated_at TEXT NOT NULL
|
|
95
|
+
);
|
|
96
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_column ON tasks(board_column);
|
|
97
|
+
|
|
98
|
+
CREATE TABLE IF NOT EXISTS scheduled_jobs (
|
|
99
|
+
id TEXT PRIMARY KEY,
|
|
100
|
+
name TEXT NOT NULL,
|
|
101
|
+
project_id TEXT NOT NULL,
|
|
102
|
+
command TEXT NOT NULL,
|
|
103
|
+
cron TEXT NOT NULL,
|
|
104
|
+
timezone TEXT DEFAULT 'UTC',
|
|
105
|
+
enabled INTEGER DEFAULT 1,
|
|
106
|
+
timeout_seconds INTEGER DEFAULT 60,
|
|
107
|
+
next_run_on TEXT,
|
|
108
|
+
last_run_on TEXT,
|
|
109
|
+
last_status TEXT DEFAULT 'never_run',
|
|
110
|
+
last_error TEXT,
|
|
111
|
+
last_duration_ms INTEGER,
|
|
112
|
+
total_runs INTEGER DEFAULT 0,
|
|
113
|
+
successful_runs INTEGER DEFAULT 0,
|
|
114
|
+
failed_runs INTEGER DEFAULT 0,
|
|
115
|
+
created_at TEXT NOT NULL,
|
|
116
|
+
updated_at TEXT NOT NULL
|
|
117
|
+
);
|
|
118
|
+
CREATE INDEX IF NOT EXISTS idx_scheduled_jobs_project ON scheduled_jobs(project_id);
|
|
119
|
+
|
|
120
|
+
CREATE TABLE IF NOT EXISTS job_runs (
|
|
121
|
+
id TEXT PRIMARY KEY,
|
|
122
|
+
job_id TEXT NOT NULL,
|
|
123
|
+
run_at TEXT NOT NULL,
|
|
124
|
+
status TEXT NOT NULL,
|
|
125
|
+
exit_code INTEGER,
|
|
126
|
+
output TEXT,
|
|
127
|
+
error TEXT,
|
|
128
|
+
duration_ms INTEGER,
|
|
129
|
+
created_at TEXT NOT NULL,
|
|
130
|
+
FOREIGN KEY (job_id) REFERENCES scheduled_jobs(id) ON DELETE CASCADE
|
|
131
|
+
);
|
|
132
|
+
CREATE INDEX IF NOT EXISTS idx_job_runs_job_id ON job_runs(job_id);
|
|
133
|
+
|
|
134
|
+
-- browser logins: sha256 of the cookie token, never the token itself
|
|
135
|
+
CREATE TABLE IF NOT EXISTS auth_sessions (
|
|
136
|
+
token_hash TEXT PRIMARY KEY,
|
|
137
|
+
created_at REAL NOT NULL,
|
|
138
|
+
last_seen REAL NOT NULL,
|
|
139
|
+
client TEXT
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
-- command history (sessions/projects may be deleted later; keep the text)
|
|
143
|
+
CREATE TABLE IF NOT EXISTS commands (
|
|
144
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
145
|
+
session_id TEXT,
|
|
146
|
+
project_id TEXT,
|
|
147
|
+
cwd TEXT,
|
|
148
|
+
command TEXT NOT NULL,
|
|
149
|
+
exit_code INTEGER,
|
|
150
|
+
duration_ms INTEGER,
|
|
151
|
+
started_at TEXT NOT NULL
|
|
152
|
+
);
|
|
153
|
+
CREATE INDEX IF NOT EXISTS idx_commands_started ON commands(started_at);
|
|
154
|
+
|
|
155
|
+
DROP TABLE IF EXISTS note_tags;
|
|
156
|
+
DROP TABLE IF EXISTS tags;
|
|
157
|
+
DROP TABLE IF EXISTS mindmap_jobs;
|
|
158
|
+
DROP TABLE IF EXISTS mindmap_index_jobs;
|
|
159
|
+
DROP TABLE IF EXISTS mindmap_nodes;
|
|
160
|
+
"""
|
|
161
|
+
)
|
|
162
|
+
cols = {r[1] for r in conn.execute("PRAGMA table_info(sessions)")}
|
|
163
|
+
if "name" not in cols:
|
|
164
|
+
conn.execute("ALTER TABLE sessions ADD COLUMN name TEXT")
|
|
165
|
+
cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)")}
|
|
166
|
+
if "color" not in cols:
|
|
167
|
+
conn.execute("ALTER TABLE projects ADD COLUMN color INTEGER")
|
|
168
|
+
missing = conn.execute("SELECT id FROM projects WHERE color IS NULL ORDER BY created_at").fetchall()
|
|
169
|
+
for (pid,) in missing:
|
|
170
|
+
conn.execute("UPDATE projects SET color = ? WHERE id = ?", (_next_color(conn), pid))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
PROJECT_COLORS = 8
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _next_color(conn: sqlite3.Connection) -> int:
|
|
177
|
+
"""Least-used palette slot, so each new project gets a distinct color."""
|
|
178
|
+
used = dict(conn.execute("SELECT color, COUNT(*) FROM projects WHERE color IS NOT NULL GROUP BY color").fetchall())
|
|
179
|
+
return min(range(PROJECT_COLORS), key=lambda c: (used.get(c, 0), c))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def ensure_project(path: str) -> dict:
|
|
183
|
+
path = str(Path(path).resolve())
|
|
184
|
+
name = Path(path).name or "root"
|
|
185
|
+
with _connect() as conn:
|
|
186
|
+
conn.row_factory = sqlite3.Row
|
|
187
|
+
row = conn.execute("SELECT * FROM projects WHERE path = ?", (path,)).fetchone()
|
|
188
|
+
if row:
|
|
189
|
+
return dict(row)
|
|
190
|
+
pid = short_id()
|
|
191
|
+
color = _next_color(conn)
|
|
192
|
+
conn.execute(
|
|
193
|
+
"INSERT INTO projects (id, path, name, created_at, color) VALUES (?, ?, ?, ?, ?)",
|
|
194
|
+
(pid, path, name, _now(), color),
|
|
195
|
+
)
|
|
196
|
+
conn.commit()
|
|
197
|
+
return {"id": pid, "path": path, "name": name, "created_at": _now(), "color": color}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def list_projects() -> list[dict]:
|
|
201
|
+
with _connect() as conn:
|
|
202
|
+
conn.row_factory = sqlite3.Row
|
|
203
|
+
rows = conn.execute(
|
|
204
|
+
"SELECT * FROM projects ORDER BY created_at"
|
|
205
|
+
).fetchall()
|
|
206
|
+
return [dict(r) for r in rows]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def get_project(project_id: str) -> dict | None:
|
|
210
|
+
with _connect() as conn:
|
|
211
|
+
conn.row_factory = sqlite3.Row
|
|
212
|
+
row = conn.execute(
|
|
213
|
+
"SELECT * FROM projects WHERE id = ?", (project_id,)
|
|
214
|
+
).fetchone()
|
|
215
|
+
return dict(row) if row else None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def rename_project(project_id: str, name: str) -> dict | None:
|
|
219
|
+
with _connect() as conn:
|
|
220
|
+
conn.execute("UPDATE projects SET name = ? WHERE id = ?", (name, project_id))
|
|
221
|
+
conn.commit()
|
|
222
|
+
return get_project(project_id)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def add_session(
|
|
226
|
+
project_id: str, cwd: str | None = None, shell: str | None = None, name: str | None = None
|
|
227
|
+
) -> dict:
|
|
228
|
+
sid = short_id()
|
|
229
|
+
cwd = cwd or "."
|
|
230
|
+
shell = shell or ""
|
|
231
|
+
cols, rows = 120, 24
|
|
232
|
+
with _connect() as conn:
|
|
233
|
+
conn.execute(
|
|
234
|
+
"INSERT INTO sessions (id, project_id, cwd, shell, cols, rows, created_at, name) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
235
|
+
(sid, project_id, cwd, shell, cols, rows, _now(), name),
|
|
236
|
+
)
|
|
237
|
+
conn.commit()
|
|
238
|
+
return {
|
|
239
|
+
"id": sid,
|
|
240
|
+
"project_id": project_id,
|
|
241
|
+
"cwd": cwd,
|
|
242
|
+
"shell": shell,
|
|
243
|
+
"name": name,
|
|
244
|
+
"cols": cols,
|
|
245
|
+
"rows": rows,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def list_sessions(project_id: str | None = None) -> list[dict]:
|
|
250
|
+
with _connect() as conn:
|
|
251
|
+
conn.row_factory = sqlite3.Row
|
|
252
|
+
if project_id:
|
|
253
|
+
rows = conn.execute(
|
|
254
|
+
"SELECT * FROM sessions WHERE project_id = ? ORDER BY created_at",
|
|
255
|
+
(project_id,),
|
|
256
|
+
).fetchall()
|
|
257
|
+
else:
|
|
258
|
+
rows = conn.execute(
|
|
259
|
+
"SELECT * FROM sessions ORDER BY created_at DESC"
|
|
260
|
+
).fetchall()
|
|
261
|
+
return [dict(r) for r in rows]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def get_session(session_id: str) -> dict | None:
|
|
265
|
+
with _connect() as conn:
|
|
266
|
+
conn.row_factory = sqlite3.Row
|
|
267
|
+
row = conn.execute(
|
|
268
|
+
"SELECT * FROM sessions WHERE id = ?", (session_id,)
|
|
269
|
+
).fetchone()
|
|
270
|
+
return dict(row) if row else None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def delete_session(session_id: str) -> None:
|
|
274
|
+
with _connect() as conn:
|
|
275
|
+
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
|
|
276
|
+
conn.commit()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def rename_session(session_id: str, name: str) -> dict | None:
|
|
280
|
+
with _connect() as conn:
|
|
281
|
+
conn.execute("UPDATE sessions SET name = ? WHERE id = ?", (name, session_id))
|
|
282
|
+
conn.commit()
|
|
283
|
+
return get_session(session_id)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def update_session_cwd(session_id: str, cwd: str) -> None:
|
|
287
|
+
with _connect() as conn:
|
|
288
|
+
conn.execute("UPDATE sessions SET cwd = ? WHERE id = ?", (cwd, session_id))
|
|
289
|
+
conn.commit()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def update_session_size(session_id: str, cols: int, rows: int) -> None:
|
|
293
|
+
with _connect() as conn:
|
|
294
|
+
conn.execute(
|
|
295
|
+
"UPDATE sessions SET cols = ?, rows = ? WHERE id = ?",
|
|
296
|
+
(cols, rows, session_id),
|
|
297
|
+
)
|
|
298
|
+
conn.commit()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def delete_project(project_id: str) -> None:
|
|
302
|
+
with _connect() as conn:
|
|
303
|
+
conn.execute("DELETE FROM sessions WHERE project_id = ?", (project_id,))
|
|
304
|
+
conn.execute("DELETE FROM scheduled_jobs WHERE project_id = ?", (project_id,))
|
|
305
|
+
conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
|
|
306
|
+
conn.commit()
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def list_sessions_with_project() -> list[dict]:
|
|
310
|
+
with _connect() as conn:
|
|
311
|
+
conn.row_factory = sqlite3.Row
|
|
312
|
+
rows = conn.execute(
|
|
313
|
+
"SELECT s.id, s.name, s.shell, s.cwd, s.created_at, p.id as project_id, p.name as project_name, p.path as project_path "
|
|
314
|
+
"FROM sessions s JOIN projects p ON s.project_id = p.id "
|
|
315
|
+
"ORDER BY p.name, s.created_at DESC"
|
|
316
|
+
).fetchall()
|
|
317
|
+
return [dict(r) for r in rows]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def add_auth_session(token_hash: str, now: float, client: str) -> None:
|
|
321
|
+
with _connect() as conn:
|
|
322
|
+
conn.execute(
|
|
323
|
+
"INSERT INTO auth_sessions (token_hash, created_at, last_seen, client) VALUES (?, ?, ?, ?)",
|
|
324
|
+
(token_hash, now, now, client[:200]),
|
|
325
|
+
)
|
|
326
|
+
conn.commit()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def get_auth_session(token_hash: str) -> dict | None:
|
|
330
|
+
with _connect() as conn:
|
|
331
|
+
conn.row_factory = sqlite3.Row
|
|
332
|
+
row = conn.execute("SELECT * FROM auth_sessions WHERE token_hash = ?", (token_hash,)).fetchone()
|
|
333
|
+
return dict(row) if row else None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def touch_auth_session(token_hash: str, now: float) -> None:
|
|
337
|
+
with _connect() as conn:
|
|
338
|
+
conn.execute("UPDATE auth_sessions SET last_seen = ? WHERE token_hash = ?", (now, token_hash))
|
|
339
|
+
conn.commit()
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def delete_auth_sessions(keep: str | None = None, only: str | None = None) -> None:
|
|
343
|
+
"""Delete one session (`only`), or all except `keep`."""
|
|
344
|
+
with _connect() as conn:
|
|
345
|
+
if only:
|
|
346
|
+
conn.execute("DELETE FROM auth_sessions WHERE token_hash = ?", (only,))
|
|
347
|
+
else:
|
|
348
|
+
conn.execute("DELETE FROM auth_sessions WHERE token_hash != ?", (keep or "",))
|
|
349
|
+
conn.commit()
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
HISTORY_LIMIT = 20000
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def add_command(session_id: str, project_id: str | None, cwd: str | None, command: str,
|
|
356
|
+
exit_code: int | None, duration_ms: int | None, started_at: str) -> None:
|
|
357
|
+
with _connect() as conn:
|
|
358
|
+
last = conn.execute(
|
|
359
|
+
"SELECT command, started_at FROM commands WHERE session_id = ? ORDER BY id DESC LIMIT 1", (session_id,)
|
|
360
|
+
).fetchone()
|
|
361
|
+
if last and last[0] == command and last[1] == started_at:
|
|
362
|
+
return # same command reported by a second window on this terminal
|
|
363
|
+
conn.execute(
|
|
364
|
+
"INSERT INTO commands (session_id, project_id, cwd, command, exit_code, duration_ms, started_at)"
|
|
365
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
366
|
+
(session_id, project_id, cwd, command, exit_code, duration_ms, started_at),
|
|
367
|
+
)
|
|
368
|
+
conn.execute("DELETE FROM commands WHERE id <= (SELECT MAX(id) FROM commands) - ?", (HISTORY_LIMIT,))
|
|
369
|
+
conn.commit()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def list_commands(q: str = "", project_id: str | None = None, failed: bool = False, limit: int = 300) -> list[dict]:
|
|
373
|
+
sql = "SELECT * FROM commands WHERE command LIKE ? ESCAPE '\\'"
|
|
374
|
+
like = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
|
375
|
+
args: list = [like]
|
|
376
|
+
if project_id:
|
|
377
|
+
sql += " AND project_id = ?"
|
|
378
|
+
args.append(project_id)
|
|
379
|
+
if failed:
|
|
380
|
+
sql += " AND exit_code IS NOT NULL AND exit_code != 0"
|
|
381
|
+
sql += " ORDER BY id DESC LIMIT ?"
|
|
382
|
+
args.append(limit)
|
|
383
|
+
with _connect() as conn:
|
|
384
|
+
conn.row_factory = sqlite3.Row
|
|
385
|
+
return [dict(r) for r in conn.execute(sql, args).fetchall()]
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def clear_commands() -> None:
|
|
389
|
+
with _connect() as conn:
|
|
390
|
+
conn.execute("DELETE FROM commands")
|
|
391
|
+
conn.commit()
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def list_bookmarks() -> list[dict]:
|
|
395
|
+
with _connect() as conn:
|
|
396
|
+
conn.row_factory = sqlite3.Row
|
|
397
|
+
rows = conn.execute(
|
|
398
|
+
"SELECT * FROM bookmarks ORDER BY updated_at DESC"
|
|
399
|
+
).fetchall()
|
|
400
|
+
return [dict(r) for r in rows]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def get_bookmark(bookmark_id: str) -> dict | None:
|
|
404
|
+
with _connect() as conn:
|
|
405
|
+
conn.row_factory = sqlite3.Row
|
|
406
|
+
row = conn.execute(
|
|
407
|
+
"SELECT * FROM bookmarks WHERE id = ?", (bookmark_id,)
|
|
408
|
+
).fetchone()
|
|
409
|
+
return dict(row) if row else None
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def add_bookmark(name: str, command: str, project_id: str | None = None) -> dict:
|
|
413
|
+
bid = short_id()
|
|
414
|
+
now = _now()
|
|
415
|
+
with _connect() as conn:
|
|
416
|
+
conn.execute(
|
|
417
|
+
"INSERT INTO bookmarks (id, name, command, project_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
418
|
+
(bid, name, command, project_id, now, now),
|
|
419
|
+
)
|
|
420
|
+
conn.commit()
|
|
421
|
+
return {
|
|
422
|
+
"id": bid,
|
|
423
|
+
"name": name,
|
|
424
|
+
"command": command,
|
|
425
|
+
"project_id": project_id,
|
|
426
|
+
"created_at": now,
|
|
427
|
+
"updated_at": now,
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def update_bookmark(
|
|
432
|
+
bookmark_id: str,
|
|
433
|
+
name: str,
|
|
434
|
+
command: str,
|
|
435
|
+
project_id: str | None = None,
|
|
436
|
+
) -> dict | None:
|
|
437
|
+
if not get_bookmark(bookmark_id):
|
|
438
|
+
return None
|
|
439
|
+
now = _now()
|
|
440
|
+
with _connect() as conn:
|
|
441
|
+
conn.execute(
|
|
442
|
+
"UPDATE bookmarks SET name = ?, command = ?, project_id = ?, updated_at = ? WHERE id = ?",
|
|
443
|
+
(name, command, project_id, now, bookmark_id),
|
|
444
|
+
)
|
|
445
|
+
conn.commit()
|
|
446
|
+
return get_bookmark(bookmark_id)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def delete_bookmark(bookmark_id: str) -> None:
|
|
450
|
+
with _connect() as conn:
|
|
451
|
+
conn.execute("DELETE FROM bookmarks WHERE id = ?", (bookmark_id,))
|
|
452
|
+
conn.commit()
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def get_setting(key: str, default: str | None = None) -> str | None:
|
|
456
|
+
with _connect() as conn:
|
|
457
|
+
row = conn.execute(
|
|
458
|
+
"SELECT value FROM settings WHERE key = ?", (key,)
|
|
459
|
+
).fetchone()
|
|
460
|
+
return row[0] if row else default
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def set_setting(key: str, value: str) -> None:
|
|
464
|
+
with _connect() as conn:
|
|
465
|
+
conn.execute(
|
|
466
|
+
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?",
|
|
467
|
+
(key, value, value),
|
|
468
|
+
)
|
|
469
|
+
conn.commit()
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def delete_setting(key: str) -> None:
|
|
473
|
+
with _connect() as conn:
|
|
474
|
+
conn.execute("DELETE FROM settings WHERE key = ?", (key,))
|
|
475
|
+
conn.commit()
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
# ---------------------------------------------------------------------------
|
|
479
|
+
# Tasks
|
|
480
|
+
# ---------------------------------------------------------------------------
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def add_task(
|
|
484
|
+
title: str,
|
|
485
|
+
description: str = "",
|
|
486
|
+
board_column: str = "backlog",
|
|
487
|
+
priority: str = "medium",
|
|
488
|
+
due_at: str | None = None,
|
|
489
|
+
reminder_at: str | None = None,
|
|
490
|
+
tags: str | None = None,
|
|
491
|
+
) -> dict:
|
|
492
|
+
tid = short_id()
|
|
493
|
+
now = _now()
|
|
494
|
+
with _connect() as conn:
|
|
495
|
+
conn.execute(
|
|
496
|
+
"INSERT INTO tasks (id, title, description, board_column, priority, due_at, reminder_at, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
497
|
+
(
|
|
498
|
+
tid,
|
|
499
|
+
title,
|
|
500
|
+
description,
|
|
501
|
+
board_column,
|
|
502
|
+
priority,
|
|
503
|
+
due_at,
|
|
504
|
+
reminder_at,
|
|
505
|
+
tags,
|
|
506
|
+
now,
|
|
507
|
+
now,
|
|
508
|
+
),
|
|
509
|
+
)
|
|
510
|
+
conn.commit()
|
|
511
|
+
return get_task(tid)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def get_task(task_id: str) -> dict | None:
|
|
515
|
+
with _connect() as conn:
|
|
516
|
+
conn.row_factory = sqlite3.Row
|
|
517
|
+
row = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
|
|
518
|
+
return dict(row) if row else None
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def list_tasks(board_column: str | None = None) -> list[dict]:
|
|
522
|
+
with _connect() as conn:
|
|
523
|
+
conn.row_factory = sqlite3.Row
|
|
524
|
+
if board_column:
|
|
525
|
+
rows = conn.execute(
|
|
526
|
+
"SELECT * FROM tasks WHERE board_column = ? ORDER BY updated_at DESC",
|
|
527
|
+
(board_column,),
|
|
528
|
+
).fetchall()
|
|
529
|
+
else:
|
|
530
|
+
rows = conn.execute(
|
|
531
|
+
"SELECT * FROM tasks ORDER BY updated_at DESC"
|
|
532
|
+
).fetchall()
|
|
533
|
+
return [dict(r) for r in rows]
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def update_task(
|
|
537
|
+
task_id: str,
|
|
538
|
+
title: str | None = None,
|
|
539
|
+
description: str | None = None,
|
|
540
|
+
board_column: str | None = None,
|
|
541
|
+
priority: str | None = None,
|
|
542
|
+
due_at: str | None = None,
|
|
543
|
+
reminder_at: str | None = None,
|
|
544
|
+
reminder_acknowledged: int | None = None,
|
|
545
|
+
tags: str | None = None,
|
|
546
|
+
) -> dict | None:
|
|
547
|
+
if not get_task(task_id):
|
|
548
|
+
return None
|
|
549
|
+
now = _now()
|
|
550
|
+
with _connect() as conn:
|
|
551
|
+
conn.execute(
|
|
552
|
+
"UPDATE tasks SET title = COALESCE(?, title), description = COALESCE(?, description), board_column = COALESCE(?, board_column), priority = COALESCE(?, priority), due_at = COALESCE(?, due_at), reminder_at = COALESCE(?, reminder_at), reminder_acknowledged = COALESCE(?, reminder_acknowledged), tags = COALESCE(?, tags), updated_at = ? WHERE id = ?",
|
|
553
|
+
(
|
|
554
|
+
title,
|
|
555
|
+
description,
|
|
556
|
+
board_column,
|
|
557
|
+
priority,
|
|
558
|
+
due_at,
|
|
559
|
+
reminder_at,
|
|
560
|
+
reminder_acknowledged,
|
|
561
|
+
tags,
|
|
562
|
+
now,
|
|
563
|
+
task_id,
|
|
564
|
+
),
|
|
565
|
+
)
|
|
566
|
+
conn.commit()
|
|
567
|
+
return get_task(task_id)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def move_task(task_id: str, board_column: str) -> dict | None:
|
|
571
|
+
return update_task(task_id, board_column=board_column)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def delete_task(task_id: str) -> None:
|
|
575
|
+
with _connect() as conn:
|
|
576
|
+
conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
|
|
577
|
+
conn.commit()
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def ack_reminder(task_id: str) -> dict | None:
|
|
581
|
+
return update_task(task_id, reminder_at=None, reminder_acknowledged=1)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def snooze_reminder(task_id: str, minutes: int = 15) -> dict | None:
|
|
585
|
+
t = get_task(task_id)
|
|
586
|
+
if not t:
|
|
587
|
+
return None
|
|
588
|
+
base = datetime.now(timezone.utc)
|
|
589
|
+
if t.get("reminder_at"):
|
|
590
|
+
try:
|
|
591
|
+
base = datetime.fromisoformat(t["reminder_at"]).astimezone(timezone.utc)
|
|
592
|
+
except ValueError:
|
|
593
|
+
pass
|
|
594
|
+
new_time = base + timedelta(minutes=minutes)
|
|
595
|
+
return update_task(
|
|
596
|
+
task_id, reminder_at=new_time.isoformat(), reminder_acknowledged=0
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def list_active_alarms(now_iso: str | None = None) -> list[dict]:
|
|
601
|
+
now = now_iso or _now()
|
|
602
|
+
with _connect() as conn:
|
|
603
|
+
conn.row_factory = sqlite3.Row
|
|
604
|
+
rows = conn.execute(
|
|
605
|
+
"SELECT * FROM tasks WHERE board_column != 'done' AND reminder_at <= ? AND (reminder_acknowledged = 0 OR reminder_acknowledged IS NULL) ORDER BY reminder_at ASC",
|
|
606
|
+
(now,),
|
|
607
|
+
).fetchall()
|
|
608
|
+
return [dict(r) for r in rows]
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
# ---------------------------------------------------------------------------
|
|
612
|
+
# Scheduled jobs
|
|
613
|
+
# ---------------------------------------------------------------------------
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def add_job(
|
|
617
|
+
name: str,
|
|
618
|
+
project_id: str,
|
|
619
|
+
command: str,
|
|
620
|
+
cron: str,
|
|
621
|
+
timezone: str = "UTC",
|
|
622
|
+
enabled: bool = True,
|
|
623
|
+
timeout_seconds: int = 60,
|
|
624
|
+
) -> dict:
|
|
625
|
+
jid = short_id()
|
|
626
|
+
now = _now()
|
|
627
|
+
with _connect() as conn:
|
|
628
|
+
conn.execute(
|
|
629
|
+
"INSERT INTO scheduled_jobs (id, name, project_id, command, cron, timezone, enabled, timeout_seconds, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
630
|
+
(
|
|
631
|
+
jid,
|
|
632
|
+
name,
|
|
633
|
+
project_id,
|
|
634
|
+
command,
|
|
635
|
+
cron,
|
|
636
|
+
timezone,
|
|
637
|
+
int(enabled),
|
|
638
|
+
timeout_seconds,
|
|
639
|
+
now,
|
|
640
|
+
now,
|
|
641
|
+
),
|
|
642
|
+
)
|
|
643
|
+
conn.commit()
|
|
644
|
+
return get_job(jid)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def get_job(job_id: str) -> dict | None:
|
|
648
|
+
with _connect() as conn:
|
|
649
|
+
conn.row_factory = sqlite3.Row
|
|
650
|
+
row = conn.execute(
|
|
651
|
+
"SELECT * FROM scheduled_jobs WHERE id = ?", (job_id,)
|
|
652
|
+
).fetchone()
|
|
653
|
+
return dict(row) if row else None
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def list_jobs() -> list[dict]:
|
|
657
|
+
with _connect() as conn:
|
|
658
|
+
conn.row_factory = sqlite3.Row
|
|
659
|
+
rows = conn.execute("SELECT * FROM scheduled_jobs ORDER BY name").fetchall()
|
|
660
|
+
return [dict(r) for r in rows]
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def update_job(
|
|
664
|
+
job_id: str,
|
|
665
|
+
name: str | None = None,
|
|
666
|
+
project_id: str | None = None,
|
|
667
|
+
command: str | None = None,
|
|
668
|
+
cron: str | None = None,
|
|
669
|
+
timezone: str | None = None,
|
|
670
|
+
enabled: bool | None = None,
|
|
671
|
+
timeout_seconds: int | None = None,
|
|
672
|
+
next_run_on: str | None = None,
|
|
673
|
+
last_run_on: str | None = None,
|
|
674
|
+
last_status: str | None = None,
|
|
675
|
+
last_error: str | None = None,
|
|
676
|
+
last_duration_ms: int | None = None,
|
|
677
|
+
) -> dict | None:
|
|
678
|
+
if not get_job(job_id):
|
|
679
|
+
return None
|
|
680
|
+
now = _now()
|
|
681
|
+
with _connect() as conn:
|
|
682
|
+
conn.execute(
|
|
683
|
+
"UPDATE scheduled_jobs SET name = COALESCE(?, name), project_id = COALESCE(?, project_id), command = COALESCE(?, command), cron = COALESCE(?, cron), timezone = COALESCE(?, timezone), enabled = COALESCE(?, enabled), timeout_seconds = COALESCE(?, timeout_seconds), next_run_on = COALESCE(?, next_run_on), last_run_on = COALESCE(?, last_run_on), last_status = COALESCE(?, last_status), last_error = COALESCE(?, last_error), last_duration_ms = COALESCE(?, last_duration_ms), updated_at = ? WHERE id = ?",
|
|
684
|
+
(
|
|
685
|
+
name,
|
|
686
|
+
project_id,
|
|
687
|
+
command,
|
|
688
|
+
cron,
|
|
689
|
+
timezone,
|
|
690
|
+
int(enabled) if enabled is not None else None,
|
|
691
|
+
timeout_seconds,
|
|
692
|
+
next_run_on,
|
|
693
|
+
last_run_on,
|
|
694
|
+
last_status,
|
|
695
|
+
last_error,
|
|
696
|
+
last_duration_ms,
|
|
697
|
+
now,
|
|
698
|
+
job_id,
|
|
699
|
+
),
|
|
700
|
+
)
|
|
701
|
+
conn.commit()
|
|
702
|
+
return get_job(job_id)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def delete_job(job_id: str) -> None:
|
|
706
|
+
with _connect() as conn:
|
|
707
|
+
conn.execute("DELETE FROM job_runs WHERE job_id = ?", (job_id,))
|
|
708
|
+
conn.execute("DELETE FROM scheduled_jobs WHERE id = ?", (job_id,))
|
|
709
|
+
conn.commit()
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def toggle_job(job_id: str) -> dict | None:
|
|
713
|
+
job = get_job(job_id)
|
|
714
|
+
if not job:
|
|
715
|
+
return None
|
|
716
|
+
return update_job(job_id, enabled=not bool(job.get("enabled")))
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def update_job_after_run(
|
|
720
|
+
job_id: str,
|
|
721
|
+
last_status: str,
|
|
722
|
+
last_run_on: str,
|
|
723
|
+
last_duration_ms: int,
|
|
724
|
+
last_error: str | None,
|
|
725
|
+
next_run_on: str | None,
|
|
726
|
+
successful: bool,
|
|
727
|
+
) -> dict | None:
|
|
728
|
+
if successful:
|
|
729
|
+
col = "successful_runs"
|
|
730
|
+
else:
|
|
731
|
+
col = "failed_runs"
|
|
732
|
+
now = _now()
|
|
733
|
+
with _connect() as conn:
|
|
734
|
+
conn.execute(
|
|
735
|
+
f"UPDATE scheduled_jobs SET last_status = ?, last_run_on = ?, last_duration_ms = ?, last_error = ?, next_run_on = ?, total_runs = total_runs + 1, {col} = {col} + 1, updated_at = ? WHERE id = ?",
|
|
736
|
+
(
|
|
737
|
+
last_status,
|
|
738
|
+
last_run_on,
|
|
739
|
+
last_duration_ms,
|
|
740
|
+
last_error,
|
|
741
|
+
next_run_on,
|
|
742
|
+
now,
|
|
743
|
+
job_id,
|
|
744
|
+
),
|
|
745
|
+
)
|
|
746
|
+
conn.commit()
|
|
747
|
+
return get_job(job_id)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def add_job_run(
|
|
751
|
+
job_id: str,
|
|
752
|
+
run_at: str,
|
|
753
|
+
status: str,
|
|
754
|
+
exit_code: int | None,
|
|
755
|
+
output: str,
|
|
756
|
+
error: str | None,
|
|
757
|
+
duration_ms: int,
|
|
758
|
+
) -> dict:
|
|
759
|
+
rid = short_id()
|
|
760
|
+
now = _now()
|
|
761
|
+
with _connect() as conn:
|
|
762
|
+
conn.execute(
|
|
763
|
+
"INSERT INTO job_runs (id, job_id, run_at, status, exit_code, output, error, duration_ms, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
764
|
+
(rid, job_id, run_at, status, exit_code, output, error, duration_ms, now),
|
|
765
|
+
)
|
|
766
|
+
conn.commit()
|
|
767
|
+
return {
|
|
768
|
+
"id": rid,
|
|
769
|
+
"job_id": job_id,
|
|
770
|
+
"run_at": run_at,
|
|
771
|
+
"status": status,
|
|
772
|
+
"exit_code": exit_code,
|
|
773
|
+
"output": output,
|
|
774
|
+
"error": error,
|
|
775
|
+
"duration_ms": duration_ms,
|
|
776
|
+
"created_at": now,
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def list_job_runs(job_id: str, limit: int = 50) -> list[dict]:
|
|
781
|
+
with _connect() as conn:
|
|
782
|
+
conn.row_factory = sqlite3.Row
|
|
783
|
+
rows = conn.execute(
|
|
784
|
+
"SELECT * FROM job_runs WHERE job_id = ? ORDER BY run_at DESC LIMIT ?",
|
|
785
|
+
(job_id, limit),
|
|
786
|
+
).fetchall()
|
|
787
|
+
return [dict(r) for r in rows]
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def get_run(run_id: str) -> dict | None:
|
|
791
|
+
with _connect() as conn:
|
|
792
|
+
conn.row_factory = sqlite3.Row
|
|
793
|
+
row = conn.execute("SELECT * FROM job_runs WHERE id = ?", (run_id,)).fetchone()
|
|
794
|
+
return dict(row) if row else None
|