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/scheduler.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import traceback
|
|
3
|
+
from datetime import UTC, datetime, timedelta, timezone
|
|
4
|
+
from zoneinfo import ZoneInfo
|
|
5
|
+
|
|
6
|
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
7
|
+
from apscheduler.triggers.cron import CronTrigger
|
|
8
|
+
from apscheduler.triggers.date import DateTrigger
|
|
9
|
+
|
|
10
|
+
from . import db, runner
|
|
11
|
+
|
|
12
|
+
scheduler = AsyncIOScheduler()
|
|
13
|
+
_alarm_callback = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def set_alarm_callback(cb):
|
|
17
|
+
global _alarm_callback
|
|
18
|
+
_alarm_callback = cb
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _now() -> str:
|
|
22
|
+
return datetime.now(timezone.utc).isoformat()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _iso(dt: datetime | None) -> str | None:
|
|
26
|
+
return dt.isoformat() if dt else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def _broadcast_alarm(task: dict) -> None:
|
|
30
|
+
if _alarm_callback is None:
|
|
31
|
+
return
|
|
32
|
+
try:
|
|
33
|
+
await _alarm_callback(task)
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def execute_reminder(task_id: str) -> None:
|
|
39
|
+
task = await asyncio.to_thread(db.get_task, task_id)
|
|
40
|
+
if not task:
|
|
41
|
+
return
|
|
42
|
+
if task.get("board_column") == "done":
|
|
43
|
+
return
|
|
44
|
+
if not task.get("reminder_at"):
|
|
45
|
+
return
|
|
46
|
+
if task.get("reminder_acknowledged"):
|
|
47
|
+
return
|
|
48
|
+
try:
|
|
49
|
+
reminder_dt = datetime.fromisoformat(task["reminder_at"]).astimezone(
|
|
50
|
+
timezone.utc
|
|
51
|
+
)
|
|
52
|
+
except Exception:
|
|
53
|
+
return
|
|
54
|
+
# Allow a small tolerance for scheduler jitter
|
|
55
|
+
if reminder_dt > datetime.now(timezone.utc) + timedelta(seconds=5):
|
|
56
|
+
await ReminderService(scheduler).sync_task(task_id)
|
|
57
|
+
return
|
|
58
|
+
await _broadcast_alarm(task)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ReminderService:
|
|
62
|
+
def __init__(self, scheduler):
|
|
63
|
+
self.scheduler = scheduler
|
|
64
|
+
|
|
65
|
+
def _job_id(self, task_id: str) -> str:
|
|
66
|
+
return f"shelldeck_reminder_{task_id}"
|
|
67
|
+
|
|
68
|
+
def remove_task(self, task_id: str) -> None:
|
|
69
|
+
try:
|
|
70
|
+
self.scheduler.remove_job(self._job_id(task_id))
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
def sync_task(self, task_id: str) -> None:
|
|
75
|
+
self.remove_task(task_id)
|
|
76
|
+
task = db.get_task(task_id)
|
|
77
|
+
if not task or task.get("board_column") == "done":
|
|
78
|
+
return
|
|
79
|
+
reminder_at = task.get("reminder_at")
|
|
80
|
+
if not reminder_at:
|
|
81
|
+
return
|
|
82
|
+
if task.get("reminder_acknowledged"):
|
|
83
|
+
return
|
|
84
|
+
try:
|
|
85
|
+
run_date = datetime.fromisoformat(reminder_at).astimezone(timezone.utc)
|
|
86
|
+
except Exception:
|
|
87
|
+
return
|
|
88
|
+
self.scheduler.add_job(
|
|
89
|
+
execute_reminder,
|
|
90
|
+
trigger=DateTrigger(run_date=run_date, timezone=UTC),
|
|
91
|
+
id=self._job_id(task_id),
|
|
92
|
+
replace_existing=True,
|
|
93
|
+
misfire_grace_time=86400,
|
|
94
|
+
kwargs={"task_id": task_id},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
async def sync_all(self) -> None:
|
|
98
|
+
for job in list(self.scheduler.get_jobs()):
|
|
99
|
+
if job.id.startswith("shelldeck_reminder_"):
|
|
100
|
+
self.scheduler.remove_job(job.id)
|
|
101
|
+
tasks = await asyncio.to_thread(db.list_tasks)
|
|
102
|
+
for task in tasks:
|
|
103
|
+
self.sync_task(task["id"])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
reminder_service = ReminderService(scheduler)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def validate_cron(expression: str) -> CronTrigger:
|
|
110
|
+
parts = expression.split()
|
|
111
|
+
if len(parts) != 5:
|
|
112
|
+
raise ValueError("Cron expression must contain five fields.")
|
|
113
|
+
minute, hour, day, month, weekday = parts
|
|
114
|
+
return CronTrigger(
|
|
115
|
+
minute=minute,
|
|
116
|
+
hour=hour,
|
|
117
|
+
day=day,
|
|
118
|
+
month=month,
|
|
119
|
+
day_of_week=weekday,
|
|
120
|
+
timezone=UTC,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cron_trigger(cron: str, tz: str = "UTC") -> CronTrigger:
|
|
125
|
+
parts = cron.split()
|
|
126
|
+
if len(parts) != 5:
|
|
127
|
+
raise ValueError("Cron expression must contain five fields.")
|
|
128
|
+
try:
|
|
129
|
+
tzinfo = ZoneInfo(tz)
|
|
130
|
+
except Exception:
|
|
131
|
+
tzinfo = UTC
|
|
132
|
+
minute, hour, day, month, weekday = parts
|
|
133
|
+
return CronTrigger(
|
|
134
|
+
minute=minute,
|
|
135
|
+
hour=hour,
|
|
136
|
+
day=day,
|
|
137
|
+
month=month,
|
|
138
|
+
day_of_week=weekday,
|
|
139
|
+
timezone=tzinfo,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def execute_job(job_id: str):
|
|
144
|
+
job = await asyncio.to_thread(db.get_job, job_id)
|
|
145
|
+
if not job or not job.get("enabled"):
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
run_at = _now()
|
|
149
|
+
await asyncio.to_thread(
|
|
150
|
+
db.update_job,
|
|
151
|
+
job_id,
|
|
152
|
+
last_status="running",
|
|
153
|
+
last_run_on=run_at,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
result = await asyncio.to_thread(
|
|
158
|
+
runner.run_command,
|
|
159
|
+
job["project_id"],
|
|
160
|
+
job["command"],
|
|
161
|
+
None,
|
|
162
|
+
job.get("timeout_seconds", 60),
|
|
163
|
+
)
|
|
164
|
+
except Exception:
|
|
165
|
+
result = {
|
|
166
|
+
"status": "failed",
|
|
167
|
+
"exit_code": None,
|
|
168
|
+
"output": "",
|
|
169
|
+
"error": traceback.format_exc(),
|
|
170
|
+
"duration_ms": 0,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
status = result["status"]
|
|
174
|
+
duration_ms = result["duration_ms"]
|
|
175
|
+
exit_code = result["exit_code"]
|
|
176
|
+
output = result["output"]
|
|
177
|
+
error = result["error"]
|
|
178
|
+
successful = status == "success"
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
last_run_dt = datetime.fromisoformat(run_at)
|
|
182
|
+
trigger = cron_trigger(job["cron"], job.get("timezone", "UTC"))
|
|
183
|
+
next_run_dt = trigger.get_next_fire_time(last_run_dt, last_run_dt)
|
|
184
|
+
next_run = _iso(next_run_dt)
|
|
185
|
+
except Exception:
|
|
186
|
+
next_run = _iso(_next_run_time(job_id))
|
|
187
|
+
|
|
188
|
+
await asyncio.to_thread(
|
|
189
|
+
db.update_job_after_run,
|
|
190
|
+
job_id,
|
|
191
|
+
status,
|
|
192
|
+
run_at,
|
|
193
|
+
duration_ms,
|
|
194
|
+
error,
|
|
195
|
+
next_run,
|
|
196
|
+
successful,
|
|
197
|
+
)
|
|
198
|
+
await asyncio.to_thread(
|
|
199
|
+
db.add_job_run,
|
|
200
|
+
job_id,
|
|
201
|
+
run_at,
|
|
202
|
+
status,
|
|
203
|
+
exit_code,
|
|
204
|
+
output,
|
|
205
|
+
error,
|
|
206
|
+
duration_ms,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class SchedulerService:
|
|
211
|
+
def __init__(self):
|
|
212
|
+
self.scheduler = scheduler
|
|
213
|
+
|
|
214
|
+
def aps_job_id(self, db_job_id: str) -> str:
|
|
215
|
+
return f"shelldeck_job_{db_job_id}"
|
|
216
|
+
|
|
217
|
+
def remove(self, db_job_id: str) -> None:
|
|
218
|
+
job_id = self.aps_job_id(db_job_id)
|
|
219
|
+
if self.scheduler.get_job(job_id):
|
|
220
|
+
self.scheduler.remove_job(job_id)
|
|
221
|
+
|
|
222
|
+
def next_run(self, db_job_id: str) -> datetime | None:
|
|
223
|
+
job = self.scheduler.get_job(self.aps_job_id(db_job_id))
|
|
224
|
+
return job.next_run_time if job else None
|
|
225
|
+
|
|
226
|
+
async def sync_job(self, job: dict) -> None:
|
|
227
|
+
db_id = job["id"]
|
|
228
|
+
self.remove(db_id)
|
|
229
|
+
|
|
230
|
+
if not job.get("enabled"):
|
|
231
|
+
await asyncio.to_thread(
|
|
232
|
+
db.update_job,
|
|
233
|
+
db_id,
|
|
234
|
+
next_run_on=None,
|
|
235
|
+
)
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
trigger = cron_trigger(job["cron"], job.get("timezone", "UTC"))
|
|
239
|
+
|
|
240
|
+
self.scheduler.add_job(
|
|
241
|
+
execute_job,
|
|
242
|
+
trigger=trigger,
|
|
243
|
+
id=self.aps_job_id(db_id),
|
|
244
|
+
replace_existing=True,
|
|
245
|
+
kwargs={"job_id": db_id},
|
|
246
|
+
misfire_grace_time=60,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
now = datetime.now(timezone.utc)
|
|
250
|
+
next_run_dt = trigger.get_next_fire_time(None, now)
|
|
251
|
+
next_run = _iso(next_run_dt)
|
|
252
|
+
await asyncio.to_thread(
|
|
253
|
+
db.update_job,
|
|
254
|
+
db_id,
|
|
255
|
+
next_run_on=next_run,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
async def sync_all(self) -> None:
|
|
259
|
+
jobs = await asyncio.to_thread(db.list_jobs)
|
|
260
|
+
for job in jobs:
|
|
261
|
+
await self.sync_job(job)
|
|
262
|
+
|
|
263
|
+
def run_now(self, db_job_id: str) -> None:
|
|
264
|
+
self.scheduler.add_job(
|
|
265
|
+
execute_job,
|
|
266
|
+
kwargs={"job_id": db_job_id},
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
scheduler_service = SchedulerService()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _next_run_time(db_job_id: str) -> datetime | None:
|
|
274
|
+
return scheduler_service.next_run(db_job_id)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
async def start() -> None:
|
|
278
|
+
scheduler.start()
|
|
279
|
+
await scheduler_service.sync_all()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def shutdown() -> None:
|
|
283
|
+
scheduler.shutdown(wait=False)
|