@hasna/loops 0.1.0

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.
@@ -0,0 +1,1059 @@
1
+ // @bun
2
+ // src/lib/store.ts
3
+ import { Database } from "bun:sqlite";
4
+ import { mkdirSync as mkdirSync2 } from "fs";
5
+ import { dirname } from "path";
6
+
7
+ // src/lib/ids.ts
8
+ import { randomBytes } from "crypto";
9
+ function genId(length = 12) {
10
+ return randomBytes(Math.ceil(length / 2)).toString("hex").slice(0, length);
11
+ }
12
+ function nowIso() {
13
+ return new Date().toISOString();
14
+ }
15
+
16
+ // src/lib/paths.ts
17
+ import { mkdirSync } from "fs";
18
+ import { homedir } from "os";
19
+ import { join } from "path";
20
+ function dataDir() {
21
+ return process.env.LOOPS_DATA_DIR || join(homedir(), ".hasna", "loops");
22
+ }
23
+ function ensureDataDir() {
24
+ const dir = dataDir();
25
+ mkdirSync(dir, { recursive: true, mode: 448 });
26
+ return dir;
27
+ }
28
+ function dbPath() {
29
+ return join(dataDir(), "loops.db");
30
+ }
31
+ function pidFilePath() {
32
+ return join(dataDir(), "daemon.pid");
33
+ }
34
+ function daemonLogPath() {
35
+ return join(dataDir(), "daemon.log");
36
+ }
37
+ function systemdServicePath() {
38
+ return join(homedir(), ".config", "systemd", "user", "loops-daemon.service");
39
+ }
40
+ function launchdPlistPath() {
41
+ return join(homedir(), "Library", "LaunchAgents", "com.hasna.loops.daemon.plist");
42
+ }
43
+
44
+ // src/lib/schedule.ts
45
+ function assertDate(value, label) {
46
+ const date = new Date(value);
47
+ if (Number.isNaN(date.getTime()))
48
+ throw new Error(`invalid ${label}: ${value}`);
49
+ return date;
50
+ }
51
+ function parseField(expr, min, max) {
52
+ const out = new Set;
53
+ for (const part of expr.split(",")) {
54
+ const [rangePart, stepPart] = part.split("/");
55
+ const step = stepPart ? Number(stepPart) : 1;
56
+ if (!Number.isInteger(step) || step <= 0)
57
+ throw new Error(`invalid cron step: ${part}`);
58
+ let lo = min;
59
+ let hi = max;
60
+ if (rangePart && rangePart !== "*") {
61
+ const bounds = rangePart.split("-");
62
+ if (bounds.length === 1) {
63
+ lo = hi = Number(bounds[0]);
64
+ } else if (bounds.length === 2) {
65
+ lo = Number(bounds[0]);
66
+ hi = Number(bounds[1]);
67
+ } else {
68
+ throw new Error(`invalid cron range: ${part}`);
69
+ }
70
+ if (!Number.isInteger(lo) || !Number.isInteger(hi))
71
+ throw new Error(`invalid cron field: ${part}`);
72
+ }
73
+ for (let v = lo;v <= hi; v += step) {
74
+ if (v < min || v > max)
75
+ throw new Error(`cron value out of range: ${v} in ${expr}`);
76
+ out.add(v);
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+ function parseCron(expr) {
82
+ const fields = expr.trim().split(/\s+/);
83
+ if (fields.length !== 5)
84
+ throw new Error(`cron must have 5 fields, got ${fields.length}: "${expr}"`);
85
+ const [minute, hour, dom, month, dow] = fields;
86
+ const dowSet = parseField(dow, 0, 7);
87
+ if (dowSet.has(7)) {
88
+ dowSet.delete(7);
89
+ dowSet.add(0);
90
+ }
91
+ return {
92
+ minute: parseField(minute, 0, 59),
93
+ hour: parseField(hour, 0, 23),
94
+ dom: parseField(dom, 1, 31),
95
+ month: parseField(month, 1, 12),
96
+ dow: dowSet,
97
+ domRestricted: dom !== "*",
98
+ dowRestricted: dow !== "*"
99
+ };
100
+ }
101
+ function cronMatches(cron, date) {
102
+ if (!cron.minute.has(date.getMinutes()))
103
+ return false;
104
+ if (!cron.hour.has(date.getHours()))
105
+ return false;
106
+ if (!cron.month.has(date.getMonth() + 1))
107
+ return false;
108
+ const domOk = cron.dom.has(date.getDate());
109
+ const dowOk = cron.dow.has(date.getDay());
110
+ if (cron.domRestricted && cron.dowRestricted)
111
+ return domOk || dowOk;
112
+ if (cron.domRestricted)
113
+ return domOk;
114
+ if (cron.dowRestricted)
115
+ return dowOk;
116
+ return true;
117
+ }
118
+ function nextCronRun(expr, from) {
119
+ const cron = parseCron(expr);
120
+ const date = new Date(from.getTime());
121
+ date.setSeconds(0, 0);
122
+ date.setMinutes(date.getMinutes() + 1);
123
+ const limit = from.getTime() + 366 * 24 * 60 * 60 * 1000;
124
+ while (date.getTime() <= limit) {
125
+ if (cronMatches(cron, date))
126
+ return date;
127
+ date.setMinutes(date.getMinutes() + 1);
128
+ }
129
+ throw new Error(`no cron match within one year for: ${expr}`);
130
+ }
131
+ function initialNextRun(schedule, from = new Date) {
132
+ switch (schedule.type) {
133
+ case "once":
134
+ return assertDate(schedule.at, "schedule.at").toISOString();
135
+ case "interval":
136
+ if (!Number.isFinite(schedule.everyMs) || schedule.everyMs <= 0)
137
+ throw new Error("interval everyMs must be > 0");
138
+ return new Date(from.getTime() + schedule.everyMs).toISOString();
139
+ case "cron":
140
+ return nextCronRun(schedule.expression, from).toISOString();
141
+ case "dynamic":
142
+ return new Date(from.getTime() + (schedule.minIntervalMs ?? 60000)).toISOString();
143
+ }
144
+ }
145
+ function computeNextAfter(schedule, scheduledFor, finishedAt) {
146
+ switch (schedule.type) {
147
+ case "once":
148
+ return;
149
+ case "interval": {
150
+ const anchor = schedule.anchor ?? "fixed_rate";
151
+ const base = anchor === "fixed_delay" ? finishedAt : scheduledFor;
152
+ let next = new Date(base.getTime() + schedule.everyMs);
153
+ while (next.getTime() <= finishedAt.getTime())
154
+ next = new Date(next.getTime() + schedule.everyMs);
155
+ return next.toISOString();
156
+ }
157
+ case "cron": {
158
+ let next = nextCronRun(schedule.expression, scheduledFor);
159
+ while (next.getTime() <= finishedAt.getTime())
160
+ next = nextCronRun(schedule.expression, next);
161
+ return next.toISOString();
162
+ }
163
+ case "dynamic":
164
+ return new Date(finishedAt.getTime() + (schedule.minIntervalMs ?? 60000)).toISOString();
165
+ }
166
+ }
167
+ function latestIntervalSlot(first, now, everyMs) {
168
+ if (first.getTime() > now.getTime())
169
+ return first;
170
+ const steps = Math.floor((now.getTime() - first.getTime()) / everyMs);
171
+ return new Date(first.getTime() + steps * everyMs);
172
+ }
173
+ function latestCronSlot(first, now, expression) {
174
+ let current = first;
175
+ while (true) {
176
+ const next = nextCronRun(expression, current);
177
+ if (next.getTime() > now.getTime())
178
+ return current;
179
+ current = next;
180
+ }
181
+ }
182
+ function dueSlots(loop, now) {
183
+ if (!loop.nextRunAt || loop.status !== "active")
184
+ return { slots: [] };
185
+ if (loop.expiresAt && new Date(loop.expiresAt).getTime() <= now.getTime())
186
+ return { slots: [] };
187
+ const next = assertDate(loop.nextRunAt, "loop.nextRunAt");
188
+ if (next.getTime() > now.getTime())
189
+ return { slots: [] };
190
+ if (loop.retryScheduledFor)
191
+ return { slots: [loop.retryScheduledFor] };
192
+ const catchUp = loop.catchUp;
193
+ switch (loop.schedule.type) {
194
+ case "once":
195
+ case "dynamic":
196
+ return { slots: [next.toISOString()] };
197
+ case "interval": {
198
+ if (catchUp === "all") {
199
+ const slots = [];
200
+ let cursor = next;
201
+ while (cursor.getTime() <= now.getTime() && slots.length < loop.catchUpLimit) {
202
+ slots.push(cursor.toISOString());
203
+ cursor = new Date(cursor.getTime() + loop.schedule.everyMs);
204
+ }
205
+ return { slots };
206
+ }
207
+ if (catchUp === "latest")
208
+ return { slots: [latestIntervalSlot(next, now, loop.schedule.everyMs).toISOString()] };
209
+ return { slots: [next.toISOString()] };
210
+ }
211
+ case "cron": {
212
+ if (catchUp === "all") {
213
+ const slots = [];
214
+ let cursor = next;
215
+ while (cursor.getTime() <= now.getTime() && slots.length < loop.catchUpLimit) {
216
+ slots.push(cursor.toISOString());
217
+ cursor = nextCronRun(loop.schedule.expression, cursor);
218
+ }
219
+ return { slots };
220
+ }
221
+ if (catchUp === "latest")
222
+ return { slots: [latestCronSlot(next, now, loop.schedule.expression).toISOString()] };
223
+ return { slots: [next.toISOString()] };
224
+ }
225
+ }
226
+ }
227
+ function parseDuration(input) {
228
+ const match = input.trim().match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/);
229
+ if (!match)
230
+ throw new Error(`invalid duration: ${input}`);
231
+ const value = Number(match[1]);
232
+ const unit = match[2] ?? "ms";
233
+ const multiplier = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60000 : unit === "h" ? 3600000 : 86400000;
234
+ return Math.round(value * multiplier);
235
+ }
236
+
237
+ // src/lib/store.ts
238
+ function rowToLoop(row) {
239
+ return {
240
+ id: row.id,
241
+ name: row.name,
242
+ description: row.description ?? undefined,
243
+ status: row.status,
244
+ schedule: JSON.parse(row.schedule_json),
245
+ target: JSON.parse(row.target_json),
246
+ nextRunAt: row.next_run_at ?? undefined,
247
+ retryScheduledFor: row.retry_scheduled_for ?? undefined,
248
+ catchUp: row.catch_up,
249
+ catchUpLimit: row.catch_up_limit,
250
+ overlap: row.overlap,
251
+ maxAttempts: row.max_attempts,
252
+ retryDelayMs: row.retry_delay_ms,
253
+ leaseMs: row.lease_ms,
254
+ expiresAt: row.expires_at ?? undefined,
255
+ createdAt: row.created_at,
256
+ updatedAt: row.updated_at
257
+ };
258
+ }
259
+ function rowToRun(row) {
260
+ return {
261
+ id: row.id,
262
+ loopId: row.loop_id,
263
+ loopName: row.loop_name,
264
+ scheduledFor: row.scheduled_for,
265
+ attempt: row.attempt,
266
+ status: row.status,
267
+ startedAt: row.started_at ?? undefined,
268
+ finishedAt: row.finished_at ?? undefined,
269
+ claimedBy: row.claimed_by ?? undefined,
270
+ leaseExpiresAt: row.lease_expires_at ?? undefined,
271
+ pid: row.pid ?? undefined,
272
+ exitCode: row.exit_code ?? undefined,
273
+ durationMs: row.duration_ms ?? undefined,
274
+ stdout: row.stdout ?? undefined,
275
+ stderr: row.stderr ?? undefined,
276
+ error: row.error ?? undefined,
277
+ createdAt: row.created_at,
278
+ updatedAt: row.updated_at
279
+ };
280
+ }
281
+ function rowToLease(row) {
282
+ return {
283
+ id: row.id,
284
+ pid: row.pid,
285
+ hostname: row.hostname,
286
+ heartbeatAt: row.heartbeat_at,
287
+ expiresAt: row.expires_at,
288
+ createdAt: row.created_at,
289
+ updatedAt: row.updated_at
290
+ };
291
+ }
292
+
293
+ class Store {
294
+ db;
295
+ constructor(path) {
296
+ const file = path ?? dbPath();
297
+ if (file !== ":memory:")
298
+ mkdirSync2(dirname(file), { recursive: true, mode: 448 });
299
+ this.db = new Database(file);
300
+ this.db.exec("PRAGMA busy_timeout = 5000;");
301
+ this.db.exec("PRAGMA journal_mode = WAL;");
302
+ this.migrate();
303
+ }
304
+ migrate() {
305
+ this.db.exec(`
306
+ CREATE TABLE IF NOT EXISTS loops (
307
+ id TEXT PRIMARY KEY,
308
+ name TEXT NOT NULL,
309
+ description TEXT,
310
+ status TEXT NOT NULL,
311
+ schedule_json TEXT NOT NULL,
312
+ target_json TEXT NOT NULL,
313
+ next_run_at TEXT,
314
+ retry_scheduled_for TEXT,
315
+ catch_up TEXT NOT NULL,
316
+ catch_up_limit INTEGER NOT NULL,
317
+ overlap TEXT NOT NULL,
318
+ max_attempts INTEGER NOT NULL,
319
+ retry_delay_ms INTEGER NOT NULL,
320
+ lease_ms INTEGER NOT NULL,
321
+ expires_at TEXT,
322
+ created_at TEXT NOT NULL,
323
+ updated_at TEXT NOT NULL
324
+ );
325
+ CREATE INDEX IF NOT EXISTS idx_loops_status_next ON loops(status, next_run_at);
326
+ CREATE INDEX IF NOT EXISTS idx_loops_name ON loops(name);
327
+
328
+ CREATE TABLE IF NOT EXISTS loop_runs (
329
+ id TEXT PRIMARY KEY,
330
+ loop_id TEXT NOT NULL,
331
+ loop_name TEXT NOT NULL,
332
+ scheduled_for TEXT NOT NULL,
333
+ attempt INTEGER NOT NULL,
334
+ status TEXT NOT NULL,
335
+ started_at TEXT,
336
+ finished_at TEXT,
337
+ claimed_by TEXT,
338
+ lease_expires_at TEXT,
339
+ pid INTEGER,
340
+ exit_code INTEGER,
341
+ duration_ms INTEGER,
342
+ stdout TEXT,
343
+ stderr TEXT,
344
+ error TEXT,
345
+ created_at TEXT NOT NULL,
346
+ updated_at TEXT NOT NULL,
347
+ UNIQUE(loop_id, scheduled_for)
348
+ );
349
+ CREATE INDEX IF NOT EXISTS idx_runs_loop ON loop_runs(loop_id, created_at);
350
+ CREATE INDEX IF NOT EXISTS idx_runs_status ON loop_runs(status);
351
+ CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
352
+
353
+ CREATE TABLE IF NOT EXISTS daemon_lease (
354
+ id TEXT PRIMARY KEY,
355
+ pid INTEGER NOT NULL,
356
+ hostname TEXT NOT NULL,
357
+ heartbeat_at TEXT NOT NULL,
358
+ expires_at TEXT NOT NULL,
359
+ created_at TEXT NOT NULL,
360
+ updated_at TEXT NOT NULL
361
+ );
362
+ `);
363
+ }
364
+ createLoop(input, from = new Date) {
365
+ const now = nowIso();
366
+ const loop = {
367
+ id: genId(),
368
+ name: input.name,
369
+ description: input.description,
370
+ status: "active",
371
+ schedule: input.schedule,
372
+ target: input.target,
373
+ nextRunAt: initialNextRun(input.schedule, from),
374
+ catchUp: input.catchUp ?? "latest",
375
+ catchUpLimit: input.catchUpLimit ?? 50,
376
+ overlap: input.overlap ?? "skip",
377
+ maxAttempts: input.maxAttempts ?? 1,
378
+ retryDelayMs: input.retryDelayMs ?? 60000,
379
+ leaseMs: input.leaseMs ?? 30 * 60000,
380
+ expiresAt: input.expiresAt,
381
+ createdAt: now,
382
+ updatedAt: now
383
+ };
384
+ this.db.query(`INSERT INTO loops (id, name, description, status, schedule_json, target_json, next_run_at, retry_scheduled_for,
385
+ catch_up, catch_up_limit, overlap, max_attempts, retry_delay_ms, lease_ms, expires_at, created_at, updated_at)
386
+ VALUES ($id, $name, $description, $status, $schedule, $target, $nextRun, NULL, $catchUp, $catchUpLimit,
387
+ $overlap, $maxAttempts, $retryDelay, $leaseMs, $expiresAt, $created, $updated)`).run({
388
+ $id: loop.id,
389
+ $name: loop.name,
390
+ $description: loop.description ?? null,
391
+ $status: loop.status,
392
+ $schedule: JSON.stringify(loop.schedule),
393
+ $target: JSON.stringify(loop.target),
394
+ $nextRun: loop.nextRunAt ?? null,
395
+ $catchUp: loop.catchUp,
396
+ $catchUpLimit: loop.catchUpLimit,
397
+ $overlap: loop.overlap,
398
+ $maxAttempts: loop.maxAttempts,
399
+ $retryDelay: loop.retryDelayMs,
400
+ $leaseMs: loop.leaseMs,
401
+ $expiresAt: loop.expiresAt ?? null,
402
+ $created: loop.createdAt,
403
+ $updated: loop.updatedAt
404
+ });
405
+ return loop;
406
+ }
407
+ getLoop(id) {
408
+ const row = this.db.query("SELECT * FROM loops WHERE id = ?").get(id);
409
+ return row ? rowToLoop(row) : undefined;
410
+ }
411
+ findLoopByName(name) {
412
+ const row = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 1").get(name);
413
+ return row ? rowToLoop(row) : undefined;
414
+ }
415
+ requireLoop(idOrName) {
416
+ return this.getLoop(idOrName) ?? this.findLoopByName(idOrName) ?? (() => {
417
+ throw new Error(`loop not found: ${idOrName}`);
418
+ })();
419
+ }
420
+ listLoops(opts = {}) {
421
+ const limit = opts.limit ?? 200;
422
+ const rows = opts.status ? this.db.query("SELECT * FROM loops WHERE status = ? ORDER BY next_run_at ASC LIMIT ?").all(opts.status, limit) : this.db.query("SELECT * FROM loops ORDER BY status ASC, next_run_at ASC LIMIT ?").all(limit);
423
+ return rows.map(rowToLoop);
424
+ }
425
+ dueLoops(now) {
426
+ const rows = this.db.query(`SELECT * FROM loops
427
+ WHERE status = 'active'
428
+ AND next_run_at IS NOT NULL
429
+ AND next_run_at <= ?
430
+ ORDER BY next_run_at ASC`).all(now.toISOString());
431
+ return rows.map(rowToLoop);
432
+ }
433
+ updateLoop(id, patch) {
434
+ const current = this.getLoop(id);
435
+ if (!current)
436
+ throw new Error(`loop not found: ${id}`);
437
+ const merged = { ...current, ...patch, updatedAt: nowIso() };
438
+ this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
439
+ expires_at=$expiresAt, updated_at=$updated WHERE id=$id`).run({
440
+ $id: id,
441
+ $status: merged.status,
442
+ $nextRun: merged.nextRunAt ?? null,
443
+ $retrySlot: merged.retryScheduledFor ?? null,
444
+ $expiresAt: merged.expiresAt ?? null,
445
+ $updated: merged.updatedAt
446
+ });
447
+ return merged;
448
+ }
449
+ deleteLoop(idOrName) {
450
+ const loop = this.requireLoop(idOrName);
451
+ const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
452
+ return res.changes > 0;
453
+ }
454
+ hasRunningRun(loopId) {
455
+ const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
456
+ return (row?.count ?? 0) > 0;
457
+ }
458
+ createSkippedRun(loop, scheduledFor, reason) {
459
+ const now = nowIso();
460
+ const run = {
461
+ id: genId(),
462
+ loopId: loop.id,
463
+ loopName: loop.name,
464
+ scheduledFor,
465
+ attempt: 1,
466
+ status: "skipped",
467
+ finishedAt: now,
468
+ error: reason,
469
+ createdAt: now,
470
+ updatedAt: now
471
+ };
472
+ this.db.query(`INSERT OR IGNORE INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
473
+ claimed_by, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
474
+ VALUES ($id, $loopId, $loopName, $scheduledFor, $attempt, $status, NULL, $finished, NULL, NULL, NULL, NULL, NULL,
475
+ NULL, NULL, $error, $created, $updated)`).run({
476
+ $id: run.id,
477
+ $loopId: run.loopId,
478
+ $loopName: run.loopName,
479
+ $scheduledFor: run.scheduledFor,
480
+ $attempt: run.attempt,
481
+ $status: run.status,
482
+ $finished: run.finishedAt ?? null,
483
+ $error: run.error ?? null,
484
+ $created: run.createdAt,
485
+ $updated: run.updatedAt
486
+ });
487
+ return this.getRunBySlot(loop.id, scheduledFor) ?? run;
488
+ }
489
+ getRun(id) {
490
+ const row = this.db.query("SELECT * FROM loop_runs WHERE id = ?").get(id);
491
+ return row ? rowToRun(row) : undefined;
492
+ }
493
+ getRunBySlot(loopId, scheduledFor) {
494
+ const row = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? AND scheduled_for = ?").get(loopId, scheduledFor);
495
+ return row ? rowToRun(row) : undefined;
496
+ }
497
+ claimRun(loop, scheduledFor, runnerId, now = new Date) {
498
+ const startedAt = now.toISOString();
499
+ const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
500
+ this.db.exec("BEGIN IMMEDIATE");
501
+ try {
502
+ const existing = this.getRunBySlot(loop.id, scheduledFor);
503
+ if (existing) {
504
+ if (existing.status === "running") {
505
+ const res3 = this.db.query(`UPDATE loop_runs SET status='running', started_at=$started, finished_at=NULL,
506
+ claimed_by=$claimedBy, lease_expires_at=$lease, pid=NULL, exit_code=NULL,
507
+ duration_ms=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
508
+ WHERE id=$id AND status='running' AND lease_expires_at <= $now`).run({
509
+ $id: existing.id,
510
+ $started: startedAt,
511
+ $claimedBy: runnerId,
512
+ $lease: leaseExpiresAt,
513
+ $updated: startedAt,
514
+ $now: startedAt
515
+ });
516
+ this.db.exec("COMMIT");
517
+ if (res3.changes !== 1)
518
+ return;
519
+ const run3 = this.getRun(existing.id);
520
+ return run3 ? { run: run3, loop } : undefined;
521
+ }
522
+ if (existing.status === "succeeded" || existing.status === "skipped") {
523
+ this.db.exec("COMMIT");
524
+ return;
525
+ }
526
+ const attempt = existing.attempt + 1;
527
+ const res2 = this.db.query(`UPDATE loop_runs SET attempt=$attempt, status='running', started_at=$started, finished_at=NULL,
528
+ claimed_by=$claimedBy, lease_expires_at=$lease, pid=NULL, exit_code=NULL,
529
+ duration_ms=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
530
+ WHERE id=$id
531
+ AND status IN ('failed', 'timed_out', 'abandoned')
532
+ AND attempt < $maxAttempts`).run({
533
+ $id: existing.id,
534
+ $attempt: attempt,
535
+ $started: startedAt,
536
+ $claimedBy: runnerId,
537
+ $lease: leaseExpiresAt,
538
+ $updated: startedAt,
539
+ $maxAttempts: loop.maxAttempts
540
+ });
541
+ this.db.exec("COMMIT");
542
+ if (res2.changes !== 1)
543
+ return;
544
+ const run2 = this.getRun(existing.id);
545
+ return run2 ? { run: run2, loop } : undefined;
546
+ }
547
+ const id = genId();
548
+ const res = this.db.query(`INSERT OR IGNORE INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
549
+ claimed_by, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
550
+ VALUES ($id, $loopId, $loopName, $scheduledFor, 1, 'running', $started, NULL, $claimedBy, $lease,
551
+ NULL, NULL, NULL, NULL, NULL, NULL, $created, $updated)`).run({
552
+ $id: id,
553
+ $loopId: loop.id,
554
+ $loopName: loop.name,
555
+ $scheduledFor: scheduledFor,
556
+ $started: startedAt,
557
+ $claimedBy: runnerId,
558
+ $lease: leaseExpiresAt,
559
+ $created: startedAt,
560
+ $updated: startedAt
561
+ });
562
+ this.db.exec("COMMIT");
563
+ if (res.changes !== 1)
564
+ return;
565
+ const run = this.getRun(id);
566
+ return run ? { run, loop } : undefined;
567
+ } catch (error) {
568
+ try {
569
+ this.db.exec("ROLLBACK");
570
+ } catch {}
571
+ throw error;
572
+ }
573
+ }
574
+ finalizeRun(id, patch) {
575
+ const finishedAt = patch.finishedAt ?? nowIso();
576
+ this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
577
+ duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run({
578
+ $id: id,
579
+ $status: patch.status,
580
+ $finished: finishedAt,
581
+ $pid: patch.pid ?? null,
582
+ $exitCode: patch.exitCode ?? null,
583
+ $durationMs: patch.durationMs ?? null,
584
+ $stdout: patch.stdout ?? null,
585
+ $stderr: patch.stderr ?? null,
586
+ $error: patch.error ?? null,
587
+ $updated: finishedAt
588
+ });
589
+ const run = this.getRun(id);
590
+ if (!run)
591
+ throw new Error(`run not found after finalize: ${id}`);
592
+ return run;
593
+ }
594
+ listRuns(opts = {}) {
595
+ const limit = opts.limit ?? 100;
596
+ let rows;
597
+ if (opts.loopId && opts.status) {
598
+ rows = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? AND status = ? ORDER BY created_at DESC LIMIT ?").all(opts.loopId, opts.status, limit);
599
+ } else if (opts.loopId) {
600
+ rows = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? ORDER BY created_at DESC LIMIT ?").all(opts.loopId, limit);
601
+ } else if (opts.status) {
602
+ rows = this.db.query("SELECT * FROM loop_runs WHERE status = ? ORDER BY created_at DESC LIMIT ?").all(opts.status, limit);
603
+ } else {
604
+ rows = this.db.query("SELECT * FROM loop_runs ORDER BY created_at DESC LIMIT ?").all(limit);
605
+ }
606
+ return rows.map(rowToRun);
607
+ }
608
+ recoverExpiredRunLeases(now = new Date) {
609
+ const rows = this.db.query("SELECT * FROM loop_runs WHERE status = 'running' AND lease_expires_at <= ?").all(now.toISOString());
610
+ const recovered = [];
611
+ for (const row of rows) {
612
+ this.db.query(`UPDATE loop_runs SET status='abandoned', finished_at=$finished, lease_expires_at=NULL,
613
+ error='run lease expired before completion', updated_at=$updated WHERE id=$id`).run({ $id: row.id, $finished: now.toISOString(), $updated: now.toISOString() });
614
+ const run = this.getRun(row.id);
615
+ if (run)
616
+ recovered.push(run);
617
+ }
618
+ return recovered;
619
+ }
620
+ expireLoops(now = new Date) {
621
+ const rows = this.db.query("SELECT * FROM loops WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at <= ?").all(now.toISOString());
622
+ const expired = [];
623
+ for (const row of rows)
624
+ expired.push(this.updateLoop(row.id, { status: "expired", nextRunAt: undefined }));
625
+ return expired;
626
+ }
627
+ countLoops(status) {
628
+ const row = status ? this.db.query("SELECT COUNT(*) AS count FROM loops WHERE status = ?").get(status) : this.db.query("SELECT COUNT(*) AS count FROM loops").get();
629
+ return row?.count ?? 0;
630
+ }
631
+ countRuns(status) {
632
+ const row = status ? this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE status = ?").get(status) : this.db.query("SELECT COUNT(*) AS count FROM loop_runs").get();
633
+ return row?.count ?? 0;
634
+ }
635
+ acquireDaemonLease(input) {
636
+ const now = input.now ?? new Date;
637
+ const expiresAt = new Date(now.getTime() + input.ttlMs).toISOString();
638
+ this.db.exec("BEGIN IMMEDIATE");
639
+ try {
640
+ const existing = this.db.query("SELECT * FROM daemon_lease LIMIT 1").get();
641
+ if (existing && existing.expires_at > now.toISOString() && existing.id !== input.id) {
642
+ this.db.exec("COMMIT");
643
+ return;
644
+ }
645
+ this.db.query("DELETE FROM daemon_lease").run();
646
+ this.db.query(`INSERT INTO daemon_lease (id, pid, hostname, heartbeat_at, expires_at, created_at, updated_at)
647
+ VALUES ($id, $pid, $hostname, $heartbeat, $expires, $created, $updated)`).run({
648
+ $id: input.id,
649
+ $pid: input.pid,
650
+ $hostname: input.hostname,
651
+ $heartbeat: now.toISOString(),
652
+ $expires: expiresAt,
653
+ $created: now.toISOString(),
654
+ $updated: now.toISOString()
655
+ });
656
+ this.db.exec("COMMIT");
657
+ return this.getDaemonLease();
658
+ } catch (error) {
659
+ try {
660
+ this.db.exec("ROLLBACK");
661
+ } catch {}
662
+ throw error;
663
+ }
664
+ }
665
+ heartbeatDaemonLease(id, ttlMs, now = new Date) {
666
+ const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
667
+ const res = this.db.query(`UPDATE daemon_lease SET heartbeat_at=$heartbeat, expires_at=$expires, updated_at=$updated WHERE id=$id`).run({ $id: id, $heartbeat: now.toISOString(), $expires: expiresAt, $updated: now.toISOString() });
668
+ if (res.changes !== 1)
669
+ return;
670
+ return this.getDaemonLease();
671
+ }
672
+ releaseDaemonLease(id) {
673
+ this.db.query("DELETE FROM daemon_lease WHERE id = ?").run(id);
674
+ }
675
+ getDaemonLease() {
676
+ const row = this.db.query("SELECT * FROM daemon_lease LIMIT 1").get();
677
+ return row ? rowToLease(row) : undefined;
678
+ }
679
+ close() {
680
+ this.db.close();
681
+ }
682
+ }
683
+
684
+ // src/lib/executor.ts
685
+ import { spawn } from "child_process";
686
+ import { once } from "events";
687
+ var DEFAULT_TIMEOUT_MS = 30 * 60000;
688
+ var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
689
+ function appendBounded(current, chunk, maxBytes) {
690
+ const next = current + chunk.toString("utf8");
691
+ if (Buffer.byteLength(next, "utf8") <= maxBytes)
692
+ return next;
693
+ const overflow = Buffer.byteLength(next, "utf8") - maxBytes;
694
+ return `[truncated ${overflow} bytes]
695
+ ${next.slice(-maxBytes)}`;
696
+ }
697
+ function killProcessGroup(pid) {
698
+ try {
699
+ process.kill(-pid, "SIGTERM");
700
+ } catch {
701
+ try {
702
+ process.kill(pid, "SIGTERM");
703
+ } catch {}
704
+ }
705
+ setTimeout(() => {
706
+ try {
707
+ process.kill(-pid, "SIGKILL");
708
+ } catch {
709
+ try {
710
+ process.kill(pid, "SIGKILL");
711
+ } catch {}
712
+ }
713
+ }, 2000).unref();
714
+ }
715
+ function providerCommand(provider) {
716
+ switch (provider) {
717
+ case "claude":
718
+ return "claude";
719
+ case "cursor":
720
+ return "cursor-agent";
721
+ case "codewith":
722
+ return "codewith";
723
+ case "aicopilot":
724
+ return "aicopilot";
725
+ case "opencode":
726
+ return "opencode";
727
+ }
728
+ }
729
+ function agentArgs(target) {
730
+ const isolation = target.configIsolation ?? "safe";
731
+ const args = [];
732
+ switch (target.provider) {
733
+ case "claude":
734
+ if (isolation === "safe")
735
+ args.push("--safe-mode", "--setting-sources", "local", "--no-session-persistence");
736
+ args.push("-p", "--output-format", "json");
737
+ if (target.model)
738
+ args.push("--model", target.model);
739
+ if (target.agent)
740
+ args.push("--agent", target.agent);
741
+ args.push(...target.extraArgs ?? [], target.prompt);
742
+ return args;
743
+ case "cursor":
744
+ args.push("-p");
745
+ if (target.model)
746
+ args.push("--model", target.model);
747
+ if (target.agent)
748
+ args.push("--agent", target.agent);
749
+ args.push(...target.extraArgs ?? [], target.prompt);
750
+ return args;
751
+ case "codewith":
752
+ args.push("exec", "--json", "--ephemeral", "--ask-for-approval", "never", "--sandbox", "workspace-write");
753
+ if (isolation === "safe")
754
+ args.push("--ignore-rules");
755
+ if (target.cwd)
756
+ args.push("--cd", target.cwd);
757
+ if (target.model)
758
+ args.push("--model", target.model);
759
+ if (target.agent)
760
+ args.push("--agent", target.agent);
761
+ args.push(...target.extraArgs ?? [], target.prompt);
762
+ return args;
763
+ case "aicopilot":
764
+ args.push("run", "--format", "json");
765
+ if (isolation === "safe")
766
+ args.push("--pure");
767
+ if (target.cwd)
768
+ args.push("--dir", target.cwd);
769
+ if (target.model)
770
+ args.push("--model", target.model);
771
+ if (target.agent)
772
+ args.push("--agent", target.agent);
773
+ args.push(...target.extraArgs ?? [], target.prompt);
774
+ return args;
775
+ case "opencode":
776
+ args.push("run", "--format", "json");
777
+ if (isolation === "safe")
778
+ args.push("--pure");
779
+ if (target.cwd)
780
+ args.push("--dir", target.cwd);
781
+ if (target.model)
782
+ args.push("--model", target.model);
783
+ if (target.agent)
784
+ args.push("--agent", target.agent);
785
+ args.push(...target.extraArgs ?? [], target.prompt);
786
+ return args;
787
+ }
788
+ }
789
+ function commandSpec(loop) {
790
+ const target = loop.target;
791
+ if (target.type === "command") {
792
+ const commandTarget = target;
793
+ return {
794
+ command: commandTarget.command,
795
+ args: commandTarget.args ?? [],
796
+ cwd: commandTarget.cwd,
797
+ shell: commandTarget.shell,
798
+ env: commandTarget.env,
799
+ timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS
800
+ };
801
+ }
802
+ const agentTarget = target;
803
+ return {
804
+ command: providerCommand(agentTarget.provider),
805
+ args: agentArgs(agentTarget),
806
+ cwd: agentTarget.cwd,
807
+ timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS
808
+ };
809
+ }
810
+ async function executeLoop(loop, run, opts = {}) {
811
+ const spec = commandSpec(loop);
812
+ const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
813
+ const startedAt = nowIso();
814
+ let stdout = "";
815
+ let stderr = "";
816
+ let timedOut = false;
817
+ let exitCode;
818
+ let error;
819
+ const env = {
820
+ ...opts.env ?? process.env,
821
+ ...spec.env ?? {},
822
+ LOOPS_LOOP_ID: loop.id,
823
+ LOOPS_LOOP_NAME: loop.name,
824
+ LOOPS_RUN_ID: run.id,
825
+ LOOPS_SCHEDULED_FOR: run.scheduledFor
826
+ };
827
+ const child = spawn(spec.command, spec.args, {
828
+ cwd: spec.cwd,
829
+ env,
830
+ shell: spec.shell ?? false,
831
+ detached: true,
832
+ stdio: ["ignore", "pipe", "pipe"]
833
+ });
834
+ child.stdout.on("data", (chunk) => {
835
+ stdout = appendBounded(stdout, chunk, maxOutputBytes);
836
+ });
837
+ child.stderr.on("data", (chunk) => {
838
+ stderr = appendBounded(stderr, chunk, maxOutputBytes);
839
+ });
840
+ const timer = setTimeout(() => {
841
+ timedOut = true;
842
+ if (child.pid)
843
+ killProcessGroup(child.pid);
844
+ }, spec.timeoutMs);
845
+ timer.unref();
846
+ try {
847
+ const [code, signal] = await once(child, "exit");
848
+ if (typeof code === "number")
849
+ exitCode = code;
850
+ if (signal)
851
+ error = `terminated by ${signal}`;
852
+ } catch (err) {
853
+ error = err instanceof Error ? err.message : String(err);
854
+ } finally {
855
+ clearTimeout(timer);
856
+ }
857
+ const finishedAt = nowIso();
858
+ const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
859
+ if (timedOut) {
860
+ return {
861
+ status: "timed_out",
862
+ exitCode,
863
+ stdout,
864
+ stderr,
865
+ error: `timed out after ${spec.timeoutMs}ms`,
866
+ pid: child.pid,
867
+ startedAt,
868
+ finishedAt,
869
+ durationMs
870
+ };
871
+ }
872
+ if (error || exitCode !== 0) {
873
+ return {
874
+ status: "failed",
875
+ exitCode,
876
+ stdout,
877
+ stderr,
878
+ error: error ?? `process exited with code ${exitCode ?? "unknown"}`,
879
+ pid: child.pid,
880
+ startedAt,
881
+ finishedAt,
882
+ durationMs
883
+ };
884
+ }
885
+ return {
886
+ status: "succeeded",
887
+ exitCode,
888
+ stdout,
889
+ stderr,
890
+ pid: child.pid,
891
+ startedAt,
892
+ finishedAt,
893
+ durationMs
894
+ };
895
+ }
896
+
897
+ // src/lib/scheduler.ts
898
+ function nextAfterRetry(loop, now) {
899
+ return new Date(now.getTime() + loop.retryDelayMs).toISOString();
900
+ }
901
+ function advanceLoop(store, loop, run, finishedAt, succeeded) {
902
+ const shouldRetry = !succeeded && run.attempt < loop.maxAttempts;
903
+ if (shouldRetry) {
904
+ store.updateLoop(loop.id, {
905
+ status: "active",
906
+ nextRunAt: nextAfterRetry(loop, finishedAt),
907
+ retryScheduledFor: run.scheduledFor
908
+ });
909
+ return;
910
+ }
911
+ const nextRunAt = computeNextAfter(loop.schedule, new Date(run.scheduledFor), finishedAt);
912
+ store.updateLoop(loop.id, {
913
+ status: nextRunAt ? "active" : "stopped",
914
+ nextRunAt,
915
+ retryScheduledFor: undefined
916
+ });
917
+ }
918
+ async function runSlot(deps, loop, scheduledFor) {
919
+ const now = deps.now?.() ?? new Date;
920
+ if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
921
+ const skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active");
922
+ advanceLoop(deps.store, loop, skipped, now, true);
923
+ deps.onRun?.(skipped);
924
+ return skipped;
925
+ }
926
+ const claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now);
927
+ if (!claim)
928
+ return;
929
+ deps.onRun?.(claim.run);
930
+ try {
931
+ const result = await (deps.execute ?? executeLoop)(claim.loop, claim.run);
932
+ const finalRun = deps.store.finalizeRun(claim.run.id, {
933
+ status: result.status,
934
+ finishedAt: result.finishedAt,
935
+ durationMs: result.durationMs,
936
+ stdout: result.stdout,
937
+ stderr: result.stderr,
938
+ exitCode: result.exitCode,
939
+ error: result.error,
940
+ pid: result.pid
941
+ });
942
+ advanceLoop(deps.store, claim.loop, finalRun, new Date(result.finishedAt), result.status === "succeeded");
943
+ deps.onRun?.(finalRun);
944
+ return finalRun;
945
+ } catch (err) {
946
+ deps.onError?.(claim.loop, err);
947
+ const finishedAt = new Date;
948
+ const finalRun = deps.store.finalizeRun(claim.run.id, {
949
+ status: "failed",
950
+ finishedAt: finishedAt.toISOString(),
951
+ durationMs: finishedAt.getTime() - new Date(claim.run.startedAt ?? claim.run.createdAt).getTime(),
952
+ stdout: "",
953
+ stderr: "",
954
+ error: err instanceof Error ? err.message : String(err)
955
+ });
956
+ advanceLoop(deps.store, claim.loop, finalRun, finishedAt, false);
957
+ deps.onRun?.(finalRun);
958
+ return finalRun;
959
+ }
960
+ }
961
+ async function tick(deps) {
962
+ const now = deps.now?.() ?? new Date;
963
+ const recovered = deps.store.recoverExpiredRunLeases(now);
964
+ for (const run of recovered) {
965
+ const loop = deps.store.getLoop(run.loopId);
966
+ if (loop)
967
+ advanceLoop(deps.store, loop, run, new Date(run.finishedAt ?? now), false);
968
+ }
969
+ const expired = deps.store.expireLoops(now);
970
+ const claimed = [];
971
+ const completed = [];
972
+ const skipped = [];
973
+ for (const loop of deps.store.dueLoops(now)) {
974
+ const plan = dueSlots(loop, now);
975
+ for (const slot of plan.slots) {
976
+ const run = await runSlot(deps, loop, slot);
977
+ if (!run)
978
+ continue;
979
+ if (run.status === "running")
980
+ claimed.push(run);
981
+ else if (run.status === "skipped")
982
+ skipped.push(run);
983
+ else
984
+ completed.push(run);
985
+ }
986
+ }
987
+ return { claimed, completed, skipped, recovered, expired };
988
+ }
989
+
990
+ // src/sdk/index.ts
991
+ class LoopsClient {
992
+ store;
993
+ ownStore;
994
+ runnerId;
995
+ constructor(opts = {}) {
996
+ this.store = opts.store ?? new Store;
997
+ this.ownStore = !opts.store;
998
+ this.runnerId = opts.runnerId ?? `sdk:${process.pid}`;
999
+ }
1000
+ create(input) {
1001
+ return this.store.createLoop(input);
1002
+ }
1003
+ list() {
1004
+ return this.store.listLoops();
1005
+ }
1006
+ get(idOrName) {
1007
+ return this.store.requireLoop(idOrName);
1008
+ }
1009
+ pause(idOrName) {
1010
+ const loop = this.get(idOrName);
1011
+ return this.store.updateLoop(loop.id, { status: "paused" });
1012
+ }
1013
+ resume(idOrName) {
1014
+ const loop = this.get(idOrName);
1015
+ return this.store.updateLoop(loop.id, { status: "active" });
1016
+ }
1017
+ stop(idOrName) {
1018
+ const loop = this.get(idOrName);
1019
+ return this.store.updateLoop(loop.id, { status: "stopped", nextRunAt: undefined });
1020
+ }
1021
+ delete(idOrName) {
1022
+ return this.store.deleteLoop(idOrName);
1023
+ }
1024
+ runs(loopId) {
1025
+ return this.store.listRuns({ loopId });
1026
+ }
1027
+ async tick() {
1028
+ return tick({ store: this.store, runnerId: this.runnerId });
1029
+ }
1030
+ async runNow(idOrName) {
1031
+ const loop = this.get(idOrName);
1032
+ const scheduledFor = new Date().toISOString();
1033
+ const claim = this.store.claimRun(loop, scheduledFor, this.runnerId);
1034
+ if (!claim)
1035
+ throw new Error(`could not claim manual run for ${idOrName}`);
1036
+ const result = await executeLoop(loop, claim.run);
1037
+ return this.store.finalizeRun(claim.run.id, {
1038
+ status: result.status,
1039
+ finishedAt: result.finishedAt,
1040
+ durationMs: result.durationMs,
1041
+ stdout: result.stdout,
1042
+ stderr: result.stderr,
1043
+ exitCode: result.exitCode,
1044
+ error: result.error,
1045
+ pid: result.pid
1046
+ });
1047
+ }
1048
+ close() {
1049
+ if (this.ownStore)
1050
+ this.store.close();
1051
+ }
1052
+ }
1053
+ function loops(opts = {}) {
1054
+ return new LoopsClient(opts);
1055
+ }
1056
+ export {
1057
+ loops,
1058
+ LoopsClient
1059
+ };