@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,1586 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/lib/store.ts
5
+ import { Database } from "bun:sqlite";
6
+ import { mkdirSync as mkdirSync2 } from "fs";
7
+ import { dirname } from "path";
8
+
9
+ // src/lib/ids.ts
10
+ import { randomBytes } from "crypto";
11
+ function genId(length = 12) {
12
+ return randomBytes(Math.ceil(length / 2)).toString("hex").slice(0, length);
13
+ }
14
+ function nowIso() {
15
+ return new Date().toISOString();
16
+ }
17
+
18
+ // src/lib/paths.ts
19
+ import { mkdirSync } from "fs";
20
+ import { homedir } from "os";
21
+ import { join } from "path";
22
+ function dataDir() {
23
+ return process.env.LOOPS_DATA_DIR || join(homedir(), ".hasna", "loops");
24
+ }
25
+ function ensureDataDir() {
26
+ const dir = dataDir();
27
+ mkdirSync(dir, { recursive: true, mode: 448 });
28
+ return dir;
29
+ }
30
+ function dbPath() {
31
+ return join(dataDir(), "loops.db");
32
+ }
33
+ function pidFilePath() {
34
+ return join(dataDir(), "daemon.pid");
35
+ }
36
+ function daemonLogPath() {
37
+ return join(dataDir(), "daemon.log");
38
+ }
39
+ function systemdServicePath() {
40
+ return join(homedir(), ".config", "systemd", "user", "loops-daemon.service");
41
+ }
42
+ function launchdPlistPath() {
43
+ return join(homedir(), "Library", "LaunchAgents", "com.hasna.loops.daemon.plist");
44
+ }
45
+
46
+ // src/lib/schedule.ts
47
+ function assertDate(value, label) {
48
+ const date = new Date(value);
49
+ if (Number.isNaN(date.getTime()))
50
+ throw new Error(`invalid ${label}: ${value}`);
51
+ return date;
52
+ }
53
+ function parseField(expr, min, max) {
54
+ const out = new Set;
55
+ for (const part of expr.split(",")) {
56
+ const [rangePart, stepPart] = part.split("/");
57
+ const step = stepPart ? Number(stepPart) : 1;
58
+ if (!Number.isInteger(step) || step <= 0)
59
+ throw new Error(`invalid cron step: ${part}`);
60
+ let lo = min;
61
+ let hi = max;
62
+ if (rangePart && rangePart !== "*") {
63
+ const bounds = rangePart.split("-");
64
+ if (bounds.length === 1) {
65
+ lo = hi = Number(bounds[0]);
66
+ } else if (bounds.length === 2) {
67
+ lo = Number(bounds[0]);
68
+ hi = Number(bounds[1]);
69
+ } else {
70
+ throw new Error(`invalid cron range: ${part}`);
71
+ }
72
+ if (!Number.isInteger(lo) || !Number.isInteger(hi))
73
+ throw new Error(`invalid cron field: ${part}`);
74
+ }
75
+ for (let v = lo;v <= hi; v += step) {
76
+ if (v < min || v > max)
77
+ throw new Error(`cron value out of range: ${v} in ${expr}`);
78
+ out.add(v);
79
+ }
80
+ }
81
+ return out;
82
+ }
83
+ function parseCron(expr) {
84
+ const fields = expr.trim().split(/\s+/);
85
+ if (fields.length !== 5)
86
+ throw new Error(`cron must have 5 fields, got ${fields.length}: "${expr}"`);
87
+ const [minute, hour, dom, month, dow] = fields;
88
+ const dowSet = parseField(dow, 0, 7);
89
+ if (dowSet.has(7)) {
90
+ dowSet.delete(7);
91
+ dowSet.add(0);
92
+ }
93
+ return {
94
+ minute: parseField(minute, 0, 59),
95
+ hour: parseField(hour, 0, 23),
96
+ dom: parseField(dom, 1, 31),
97
+ month: parseField(month, 1, 12),
98
+ dow: dowSet,
99
+ domRestricted: dom !== "*",
100
+ dowRestricted: dow !== "*"
101
+ };
102
+ }
103
+ function cronMatches(cron, date) {
104
+ if (!cron.minute.has(date.getMinutes()))
105
+ return false;
106
+ if (!cron.hour.has(date.getHours()))
107
+ return false;
108
+ if (!cron.month.has(date.getMonth() + 1))
109
+ return false;
110
+ const domOk = cron.dom.has(date.getDate());
111
+ const dowOk = cron.dow.has(date.getDay());
112
+ if (cron.domRestricted && cron.dowRestricted)
113
+ return domOk || dowOk;
114
+ if (cron.domRestricted)
115
+ return domOk;
116
+ if (cron.dowRestricted)
117
+ return dowOk;
118
+ return true;
119
+ }
120
+ function nextCronRun(expr, from) {
121
+ const cron = parseCron(expr);
122
+ const date = new Date(from.getTime());
123
+ date.setSeconds(0, 0);
124
+ date.setMinutes(date.getMinutes() + 1);
125
+ const limit = from.getTime() + 366 * 24 * 60 * 60 * 1000;
126
+ while (date.getTime() <= limit) {
127
+ if (cronMatches(cron, date))
128
+ return date;
129
+ date.setMinutes(date.getMinutes() + 1);
130
+ }
131
+ throw new Error(`no cron match within one year for: ${expr}`);
132
+ }
133
+ function initialNextRun(schedule, from = new Date) {
134
+ switch (schedule.type) {
135
+ case "once":
136
+ return assertDate(schedule.at, "schedule.at").toISOString();
137
+ case "interval":
138
+ if (!Number.isFinite(schedule.everyMs) || schedule.everyMs <= 0)
139
+ throw new Error("interval everyMs must be > 0");
140
+ return new Date(from.getTime() + schedule.everyMs).toISOString();
141
+ case "cron":
142
+ return nextCronRun(schedule.expression, from).toISOString();
143
+ case "dynamic":
144
+ return new Date(from.getTime() + (schedule.minIntervalMs ?? 60000)).toISOString();
145
+ }
146
+ }
147
+ function computeNextAfter(schedule, scheduledFor, finishedAt) {
148
+ switch (schedule.type) {
149
+ case "once":
150
+ return;
151
+ case "interval": {
152
+ const anchor = schedule.anchor ?? "fixed_rate";
153
+ const base = anchor === "fixed_delay" ? finishedAt : scheduledFor;
154
+ let next = new Date(base.getTime() + schedule.everyMs);
155
+ while (next.getTime() <= finishedAt.getTime())
156
+ next = new Date(next.getTime() + schedule.everyMs);
157
+ return next.toISOString();
158
+ }
159
+ case "cron": {
160
+ let next = nextCronRun(schedule.expression, scheduledFor);
161
+ while (next.getTime() <= finishedAt.getTime())
162
+ next = nextCronRun(schedule.expression, next);
163
+ return next.toISOString();
164
+ }
165
+ case "dynamic":
166
+ return new Date(finishedAt.getTime() + (schedule.minIntervalMs ?? 60000)).toISOString();
167
+ }
168
+ }
169
+ function latestIntervalSlot(first, now, everyMs) {
170
+ if (first.getTime() > now.getTime())
171
+ return first;
172
+ const steps = Math.floor((now.getTime() - first.getTime()) / everyMs);
173
+ return new Date(first.getTime() + steps * everyMs);
174
+ }
175
+ function latestCronSlot(first, now, expression) {
176
+ let current = first;
177
+ while (true) {
178
+ const next = nextCronRun(expression, current);
179
+ if (next.getTime() > now.getTime())
180
+ return current;
181
+ current = next;
182
+ }
183
+ }
184
+ function dueSlots(loop, now) {
185
+ if (!loop.nextRunAt || loop.status !== "active")
186
+ return { slots: [] };
187
+ if (loop.expiresAt && new Date(loop.expiresAt).getTime() <= now.getTime())
188
+ return { slots: [] };
189
+ const next = assertDate(loop.nextRunAt, "loop.nextRunAt");
190
+ if (next.getTime() > now.getTime())
191
+ return { slots: [] };
192
+ if (loop.retryScheduledFor)
193
+ return { slots: [loop.retryScheduledFor] };
194
+ const catchUp = loop.catchUp;
195
+ switch (loop.schedule.type) {
196
+ case "once":
197
+ case "dynamic":
198
+ return { slots: [next.toISOString()] };
199
+ case "interval": {
200
+ if (catchUp === "all") {
201
+ const slots = [];
202
+ let cursor = next;
203
+ while (cursor.getTime() <= now.getTime() && slots.length < loop.catchUpLimit) {
204
+ slots.push(cursor.toISOString());
205
+ cursor = new Date(cursor.getTime() + loop.schedule.everyMs);
206
+ }
207
+ return { slots };
208
+ }
209
+ if (catchUp === "latest")
210
+ return { slots: [latestIntervalSlot(next, now, loop.schedule.everyMs).toISOString()] };
211
+ return { slots: [next.toISOString()] };
212
+ }
213
+ case "cron": {
214
+ if (catchUp === "all") {
215
+ const slots = [];
216
+ let cursor = next;
217
+ while (cursor.getTime() <= now.getTime() && slots.length < loop.catchUpLimit) {
218
+ slots.push(cursor.toISOString());
219
+ cursor = nextCronRun(loop.schedule.expression, cursor);
220
+ }
221
+ return { slots };
222
+ }
223
+ if (catchUp === "latest")
224
+ return { slots: [latestCronSlot(next, now, loop.schedule.expression).toISOString()] };
225
+ return { slots: [next.toISOString()] };
226
+ }
227
+ }
228
+ }
229
+ function parseDuration(input) {
230
+ const match = input.trim().match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/);
231
+ if (!match)
232
+ throw new Error(`invalid duration: ${input}`);
233
+ const value = Number(match[1]);
234
+ const unit = match[2] ?? "ms";
235
+ const multiplier = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60000 : unit === "h" ? 3600000 : 86400000;
236
+ return Math.round(value * multiplier);
237
+ }
238
+
239
+ // src/lib/store.ts
240
+ function rowToLoop(row) {
241
+ return {
242
+ id: row.id,
243
+ name: row.name,
244
+ description: row.description ?? undefined,
245
+ status: row.status,
246
+ schedule: JSON.parse(row.schedule_json),
247
+ target: JSON.parse(row.target_json),
248
+ nextRunAt: row.next_run_at ?? undefined,
249
+ retryScheduledFor: row.retry_scheduled_for ?? undefined,
250
+ catchUp: row.catch_up,
251
+ catchUpLimit: row.catch_up_limit,
252
+ overlap: row.overlap,
253
+ maxAttempts: row.max_attempts,
254
+ retryDelayMs: row.retry_delay_ms,
255
+ leaseMs: row.lease_ms,
256
+ expiresAt: row.expires_at ?? undefined,
257
+ createdAt: row.created_at,
258
+ updatedAt: row.updated_at
259
+ };
260
+ }
261
+ function rowToRun(row) {
262
+ return {
263
+ id: row.id,
264
+ loopId: row.loop_id,
265
+ loopName: row.loop_name,
266
+ scheduledFor: row.scheduled_for,
267
+ attempt: row.attempt,
268
+ status: row.status,
269
+ startedAt: row.started_at ?? undefined,
270
+ finishedAt: row.finished_at ?? undefined,
271
+ claimedBy: row.claimed_by ?? undefined,
272
+ leaseExpiresAt: row.lease_expires_at ?? undefined,
273
+ pid: row.pid ?? undefined,
274
+ exitCode: row.exit_code ?? undefined,
275
+ durationMs: row.duration_ms ?? undefined,
276
+ stdout: row.stdout ?? undefined,
277
+ stderr: row.stderr ?? undefined,
278
+ error: row.error ?? undefined,
279
+ createdAt: row.created_at,
280
+ updatedAt: row.updated_at
281
+ };
282
+ }
283
+ function rowToLease(row) {
284
+ return {
285
+ id: row.id,
286
+ pid: row.pid,
287
+ hostname: row.hostname,
288
+ heartbeatAt: row.heartbeat_at,
289
+ expiresAt: row.expires_at,
290
+ createdAt: row.created_at,
291
+ updatedAt: row.updated_at
292
+ };
293
+ }
294
+
295
+ class Store {
296
+ db;
297
+ constructor(path) {
298
+ const file = path ?? dbPath();
299
+ if (file !== ":memory:")
300
+ mkdirSync2(dirname(file), { recursive: true, mode: 448 });
301
+ this.db = new Database(file);
302
+ this.db.exec("PRAGMA busy_timeout = 5000;");
303
+ this.db.exec("PRAGMA journal_mode = WAL;");
304
+ this.migrate();
305
+ }
306
+ migrate() {
307
+ this.db.exec(`
308
+ CREATE TABLE IF NOT EXISTS loops (
309
+ id TEXT PRIMARY KEY,
310
+ name TEXT NOT NULL,
311
+ description TEXT,
312
+ status TEXT NOT NULL,
313
+ schedule_json TEXT NOT NULL,
314
+ target_json TEXT NOT NULL,
315
+ next_run_at TEXT,
316
+ retry_scheduled_for TEXT,
317
+ catch_up TEXT NOT NULL,
318
+ catch_up_limit INTEGER NOT NULL,
319
+ overlap TEXT NOT NULL,
320
+ max_attempts INTEGER NOT NULL,
321
+ retry_delay_ms INTEGER NOT NULL,
322
+ lease_ms INTEGER NOT NULL,
323
+ expires_at TEXT,
324
+ created_at TEXT NOT NULL,
325
+ updated_at TEXT NOT NULL
326
+ );
327
+ CREATE INDEX IF NOT EXISTS idx_loops_status_next ON loops(status, next_run_at);
328
+ CREATE INDEX IF NOT EXISTS idx_loops_name ON loops(name);
329
+
330
+ CREATE TABLE IF NOT EXISTS loop_runs (
331
+ id TEXT PRIMARY KEY,
332
+ loop_id TEXT NOT NULL,
333
+ loop_name TEXT NOT NULL,
334
+ scheduled_for TEXT NOT NULL,
335
+ attempt INTEGER NOT NULL,
336
+ status TEXT NOT NULL,
337
+ started_at TEXT,
338
+ finished_at TEXT,
339
+ claimed_by TEXT,
340
+ lease_expires_at TEXT,
341
+ pid INTEGER,
342
+ exit_code INTEGER,
343
+ duration_ms INTEGER,
344
+ stdout TEXT,
345
+ stderr TEXT,
346
+ error TEXT,
347
+ created_at TEXT NOT NULL,
348
+ updated_at TEXT NOT NULL,
349
+ UNIQUE(loop_id, scheduled_for)
350
+ );
351
+ CREATE INDEX IF NOT EXISTS idx_runs_loop ON loop_runs(loop_id, created_at);
352
+ CREATE INDEX IF NOT EXISTS idx_runs_status ON loop_runs(status);
353
+ CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
354
+
355
+ CREATE TABLE IF NOT EXISTS daemon_lease (
356
+ id TEXT PRIMARY KEY,
357
+ pid INTEGER NOT NULL,
358
+ hostname TEXT NOT NULL,
359
+ heartbeat_at TEXT NOT NULL,
360
+ expires_at TEXT NOT NULL,
361
+ created_at TEXT NOT NULL,
362
+ updated_at TEXT NOT NULL
363
+ );
364
+ `);
365
+ }
366
+ createLoop(input, from = new Date) {
367
+ const now = nowIso();
368
+ const loop = {
369
+ id: genId(),
370
+ name: input.name,
371
+ description: input.description,
372
+ status: "active",
373
+ schedule: input.schedule,
374
+ target: input.target,
375
+ nextRunAt: initialNextRun(input.schedule, from),
376
+ catchUp: input.catchUp ?? "latest",
377
+ catchUpLimit: input.catchUpLimit ?? 50,
378
+ overlap: input.overlap ?? "skip",
379
+ maxAttempts: input.maxAttempts ?? 1,
380
+ retryDelayMs: input.retryDelayMs ?? 60000,
381
+ leaseMs: input.leaseMs ?? 30 * 60000,
382
+ expiresAt: input.expiresAt,
383
+ createdAt: now,
384
+ updatedAt: now
385
+ };
386
+ this.db.query(`INSERT INTO loops (id, name, description, status, schedule_json, target_json, next_run_at, retry_scheduled_for,
387
+ catch_up, catch_up_limit, overlap, max_attempts, retry_delay_ms, lease_ms, expires_at, created_at, updated_at)
388
+ VALUES ($id, $name, $description, $status, $schedule, $target, $nextRun, NULL, $catchUp, $catchUpLimit,
389
+ $overlap, $maxAttempts, $retryDelay, $leaseMs, $expiresAt, $created, $updated)`).run({
390
+ $id: loop.id,
391
+ $name: loop.name,
392
+ $description: loop.description ?? null,
393
+ $status: loop.status,
394
+ $schedule: JSON.stringify(loop.schedule),
395
+ $target: JSON.stringify(loop.target),
396
+ $nextRun: loop.nextRunAt ?? null,
397
+ $catchUp: loop.catchUp,
398
+ $catchUpLimit: loop.catchUpLimit,
399
+ $overlap: loop.overlap,
400
+ $maxAttempts: loop.maxAttempts,
401
+ $retryDelay: loop.retryDelayMs,
402
+ $leaseMs: loop.leaseMs,
403
+ $expiresAt: loop.expiresAt ?? null,
404
+ $created: loop.createdAt,
405
+ $updated: loop.updatedAt
406
+ });
407
+ return loop;
408
+ }
409
+ getLoop(id) {
410
+ const row = this.db.query("SELECT * FROM loops WHERE id = ?").get(id);
411
+ return row ? rowToLoop(row) : undefined;
412
+ }
413
+ findLoopByName(name) {
414
+ const row = this.db.query("SELECT * FROM loops WHERE name = ? ORDER BY created_at DESC LIMIT 1").get(name);
415
+ return row ? rowToLoop(row) : undefined;
416
+ }
417
+ requireLoop(idOrName) {
418
+ return this.getLoop(idOrName) ?? this.findLoopByName(idOrName) ?? (() => {
419
+ throw new Error(`loop not found: ${idOrName}`);
420
+ })();
421
+ }
422
+ listLoops(opts = {}) {
423
+ const limit = opts.limit ?? 200;
424
+ 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);
425
+ return rows.map(rowToLoop);
426
+ }
427
+ dueLoops(now) {
428
+ const rows = this.db.query(`SELECT * FROM loops
429
+ WHERE status = 'active'
430
+ AND next_run_at IS NOT NULL
431
+ AND next_run_at <= ?
432
+ ORDER BY next_run_at ASC`).all(now.toISOString());
433
+ return rows.map(rowToLoop);
434
+ }
435
+ updateLoop(id, patch) {
436
+ const current = this.getLoop(id);
437
+ if (!current)
438
+ throw new Error(`loop not found: ${id}`);
439
+ const merged = { ...current, ...patch, updatedAt: nowIso() };
440
+ this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
441
+ expires_at=$expiresAt, updated_at=$updated WHERE id=$id`).run({
442
+ $id: id,
443
+ $status: merged.status,
444
+ $nextRun: merged.nextRunAt ?? null,
445
+ $retrySlot: merged.retryScheduledFor ?? null,
446
+ $expiresAt: merged.expiresAt ?? null,
447
+ $updated: merged.updatedAt
448
+ });
449
+ return merged;
450
+ }
451
+ deleteLoop(idOrName) {
452
+ const loop = this.requireLoop(idOrName);
453
+ const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
454
+ return res.changes > 0;
455
+ }
456
+ hasRunningRun(loopId) {
457
+ const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
458
+ return (row?.count ?? 0) > 0;
459
+ }
460
+ createSkippedRun(loop, scheduledFor, reason) {
461
+ const now = nowIso();
462
+ const run = {
463
+ id: genId(),
464
+ loopId: loop.id,
465
+ loopName: loop.name,
466
+ scheduledFor,
467
+ attempt: 1,
468
+ status: "skipped",
469
+ finishedAt: now,
470
+ error: reason,
471
+ createdAt: now,
472
+ updatedAt: now
473
+ };
474
+ this.db.query(`INSERT OR IGNORE INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
475
+ claimed_by, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
476
+ VALUES ($id, $loopId, $loopName, $scheduledFor, $attempt, $status, NULL, $finished, NULL, NULL, NULL, NULL, NULL,
477
+ NULL, NULL, $error, $created, $updated)`).run({
478
+ $id: run.id,
479
+ $loopId: run.loopId,
480
+ $loopName: run.loopName,
481
+ $scheduledFor: run.scheduledFor,
482
+ $attempt: run.attempt,
483
+ $status: run.status,
484
+ $finished: run.finishedAt ?? null,
485
+ $error: run.error ?? null,
486
+ $created: run.createdAt,
487
+ $updated: run.updatedAt
488
+ });
489
+ return this.getRunBySlot(loop.id, scheduledFor) ?? run;
490
+ }
491
+ getRun(id) {
492
+ const row = this.db.query("SELECT * FROM loop_runs WHERE id = ?").get(id);
493
+ return row ? rowToRun(row) : undefined;
494
+ }
495
+ getRunBySlot(loopId, scheduledFor) {
496
+ const row = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? AND scheduled_for = ?").get(loopId, scheduledFor);
497
+ return row ? rowToRun(row) : undefined;
498
+ }
499
+ claimRun(loop, scheduledFor, runnerId, now = new Date) {
500
+ const startedAt = now.toISOString();
501
+ const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
502
+ this.db.exec("BEGIN IMMEDIATE");
503
+ try {
504
+ const existing = this.getRunBySlot(loop.id, scheduledFor);
505
+ if (existing) {
506
+ if (existing.status === "running") {
507
+ const res3 = this.db.query(`UPDATE loop_runs SET status='running', started_at=$started, finished_at=NULL,
508
+ claimed_by=$claimedBy, lease_expires_at=$lease, pid=NULL, exit_code=NULL,
509
+ duration_ms=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
510
+ WHERE id=$id AND status='running' AND lease_expires_at <= $now`).run({
511
+ $id: existing.id,
512
+ $started: startedAt,
513
+ $claimedBy: runnerId,
514
+ $lease: leaseExpiresAt,
515
+ $updated: startedAt,
516
+ $now: startedAt
517
+ });
518
+ this.db.exec("COMMIT");
519
+ if (res3.changes !== 1)
520
+ return;
521
+ const run3 = this.getRun(existing.id);
522
+ return run3 ? { run: run3, loop } : undefined;
523
+ }
524
+ if (existing.status === "succeeded" || existing.status === "skipped") {
525
+ this.db.exec("COMMIT");
526
+ return;
527
+ }
528
+ const attempt = existing.attempt + 1;
529
+ const res2 = this.db.query(`UPDATE loop_runs SET attempt=$attempt, status='running', started_at=$started, finished_at=NULL,
530
+ claimed_by=$claimedBy, lease_expires_at=$lease, pid=NULL, exit_code=NULL,
531
+ duration_ms=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
532
+ WHERE id=$id
533
+ AND status IN ('failed', 'timed_out', 'abandoned')
534
+ AND attempt < $maxAttempts`).run({
535
+ $id: existing.id,
536
+ $attempt: attempt,
537
+ $started: startedAt,
538
+ $claimedBy: runnerId,
539
+ $lease: leaseExpiresAt,
540
+ $updated: startedAt,
541
+ $maxAttempts: loop.maxAttempts
542
+ });
543
+ this.db.exec("COMMIT");
544
+ if (res2.changes !== 1)
545
+ return;
546
+ const run2 = this.getRun(existing.id);
547
+ return run2 ? { run: run2, loop } : undefined;
548
+ }
549
+ const id = genId();
550
+ const res = this.db.query(`INSERT OR IGNORE INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
551
+ claimed_by, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
552
+ VALUES ($id, $loopId, $loopName, $scheduledFor, 1, 'running', $started, NULL, $claimedBy, $lease,
553
+ NULL, NULL, NULL, NULL, NULL, NULL, $created, $updated)`).run({
554
+ $id: id,
555
+ $loopId: loop.id,
556
+ $loopName: loop.name,
557
+ $scheduledFor: scheduledFor,
558
+ $started: startedAt,
559
+ $claimedBy: runnerId,
560
+ $lease: leaseExpiresAt,
561
+ $created: startedAt,
562
+ $updated: startedAt
563
+ });
564
+ this.db.exec("COMMIT");
565
+ if (res.changes !== 1)
566
+ return;
567
+ const run = this.getRun(id);
568
+ return run ? { run, loop } : undefined;
569
+ } catch (error) {
570
+ try {
571
+ this.db.exec("ROLLBACK");
572
+ } catch {}
573
+ throw error;
574
+ }
575
+ }
576
+ finalizeRun(id, patch) {
577
+ const finishedAt = patch.finishedAt ?? nowIso();
578
+ this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
579
+ duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run({
580
+ $id: id,
581
+ $status: patch.status,
582
+ $finished: finishedAt,
583
+ $pid: patch.pid ?? null,
584
+ $exitCode: patch.exitCode ?? null,
585
+ $durationMs: patch.durationMs ?? null,
586
+ $stdout: patch.stdout ?? null,
587
+ $stderr: patch.stderr ?? null,
588
+ $error: patch.error ?? null,
589
+ $updated: finishedAt
590
+ });
591
+ const run = this.getRun(id);
592
+ if (!run)
593
+ throw new Error(`run not found after finalize: ${id}`);
594
+ return run;
595
+ }
596
+ listRuns(opts = {}) {
597
+ const limit = opts.limit ?? 100;
598
+ let rows;
599
+ if (opts.loopId && opts.status) {
600
+ 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);
601
+ } else if (opts.loopId) {
602
+ rows = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? ORDER BY created_at DESC LIMIT ?").all(opts.loopId, limit);
603
+ } else if (opts.status) {
604
+ rows = this.db.query("SELECT * FROM loop_runs WHERE status = ? ORDER BY created_at DESC LIMIT ?").all(opts.status, limit);
605
+ } else {
606
+ rows = this.db.query("SELECT * FROM loop_runs ORDER BY created_at DESC LIMIT ?").all(limit);
607
+ }
608
+ return rows.map(rowToRun);
609
+ }
610
+ recoverExpiredRunLeases(now = new Date) {
611
+ const rows = this.db.query("SELECT * FROM loop_runs WHERE status = 'running' AND lease_expires_at <= ?").all(now.toISOString());
612
+ const recovered = [];
613
+ for (const row of rows) {
614
+ this.db.query(`UPDATE loop_runs SET status='abandoned', finished_at=$finished, lease_expires_at=NULL,
615
+ error='run lease expired before completion', updated_at=$updated WHERE id=$id`).run({ $id: row.id, $finished: now.toISOString(), $updated: now.toISOString() });
616
+ const run = this.getRun(row.id);
617
+ if (run)
618
+ recovered.push(run);
619
+ }
620
+ return recovered;
621
+ }
622
+ expireLoops(now = new Date) {
623
+ const rows = this.db.query("SELECT * FROM loops WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at <= ?").all(now.toISOString());
624
+ const expired = [];
625
+ for (const row of rows)
626
+ expired.push(this.updateLoop(row.id, { status: "expired", nextRunAt: undefined }));
627
+ return expired;
628
+ }
629
+ countLoops(status) {
630
+ 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();
631
+ return row?.count ?? 0;
632
+ }
633
+ countRuns(status) {
634
+ 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();
635
+ return row?.count ?? 0;
636
+ }
637
+ acquireDaemonLease(input) {
638
+ const now = input.now ?? new Date;
639
+ const expiresAt = new Date(now.getTime() + input.ttlMs).toISOString();
640
+ this.db.exec("BEGIN IMMEDIATE");
641
+ try {
642
+ const existing = this.db.query("SELECT * FROM daemon_lease LIMIT 1").get();
643
+ if (existing && existing.expires_at > now.toISOString() && existing.id !== input.id) {
644
+ this.db.exec("COMMIT");
645
+ return;
646
+ }
647
+ this.db.query("DELETE FROM daemon_lease").run();
648
+ this.db.query(`INSERT INTO daemon_lease (id, pid, hostname, heartbeat_at, expires_at, created_at, updated_at)
649
+ VALUES ($id, $pid, $hostname, $heartbeat, $expires, $created, $updated)`).run({
650
+ $id: input.id,
651
+ $pid: input.pid,
652
+ $hostname: input.hostname,
653
+ $heartbeat: now.toISOString(),
654
+ $expires: expiresAt,
655
+ $created: now.toISOString(),
656
+ $updated: now.toISOString()
657
+ });
658
+ this.db.exec("COMMIT");
659
+ return this.getDaemonLease();
660
+ } catch (error) {
661
+ try {
662
+ this.db.exec("ROLLBACK");
663
+ } catch {}
664
+ throw error;
665
+ }
666
+ }
667
+ heartbeatDaemonLease(id, ttlMs, now = new Date) {
668
+ const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
669
+ 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() });
670
+ if (res.changes !== 1)
671
+ return;
672
+ return this.getDaemonLease();
673
+ }
674
+ releaseDaemonLease(id) {
675
+ this.db.query("DELETE FROM daemon_lease WHERE id = ?").run(id);
676
+ }
677
+ getDaemonLease() {
678
+ const row = this.db.query("SELECT * FROM daemon_lease LIMIT 1").get();
679
+ return row ? rowToLease(row) : undefined;
680
+ }
681
+ close() {
682
+ this.db.close();
683
+ }
684
+ }
685
+
686
+ // src/cli/index.ts
687
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
688
+ import { Command } from "commander";
689
+
690
+ // src/lib/format.ts
691
+ function redact(value, visible = 80) {
692
+ if (!value)
693
+ return value;
694
+ if (value.length <= visible)
695
+ return value;
696
+ return `${value.slice(0, visible)}... [redacted ${value.length - visible} chars]`;
697
+ }
698
+ function publicLoop(loop) {
699
+ const target = loop.target.type === "command" ? { ...loop.target, env: loop.target.env ? "[redacted]" : undefined } : { ...loop.target, prompt: redact(loop.target.prompt) };
700
+ return {
701
+ ...loop,
702
+ target
703
+ };
704
+ }
705
+ function publicRun(run, showOutput = false) {
706
+ return {
707
+ ...run,
708
+ stdout: showOutput ? run.stdout : run.stdout ? `[redacted ${run.stdout.length} chars]` : undefined,
709
+ stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined
710
+ };
711
+ }
712
+
713
+ // src/lib/executor.ts
714
+ import { spawn } from "child_process";
715
+ import { once } from "events";
716
+ var DEFAULT_TIMEOUT_MS = 30 * 60000;
717
+ var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
718
+ function appendBounded(current, chunk, maxBytes) {
719
+ const next = current + chunk.toString("utf8");
720
+ if (Buffer.byteLength(next, "utf8") <= maxBytes)
721
+ return next;
722
+ const overflow = Buffer.byteLength(next, "utf8") - maxBytes;
723
+ return `[truncated ${overflow} bytes]
724
+ ${next.slice(-maxBytes)}`;
725
+ }
726
+ function killProcessGroup(pid) {
727
+ try {
728
+ process.kill(-pid, "SIGTERM");
729
+ } catch {
730
+ try {
731
+ process.kill(pid, "SIGTERM");
732
+ } catch {}
733
+ }
734
+ setTimeout(() => {
735
+ try {
736
+ process.kill(-pid, "SIGKILL");
737
+ } catch {
738
+ try {
739
+ process.kill(pid, "SIGKILL");
740
+ } catch {}
741
+ }
742
+ }, 2000).unref();
743
+ }
744
+ function providerCommand(provider) {
745
+ switch (provider) {
746
+ case "claude":
747
+ return "claude";
748
+ case "cursor":
749
+ return "cursor-agent";
750
+ case "codewith":
751
+ return "codewith";
752
+ case "aicopilot":
753
+ return "aicopilot";
754
+ case "opencode":
755
+ return "opencode";
756
+ }
757
+ }
758
+ function agentArgs(target) {
759
+ const isolation = target.configIsolation ?? "safe";
760
+ const args = [];
761
+ switch (target.provider) {
762
+ case "claude":
763
+ if (isolation === "safe")
764
+ args.push("--safe-mode", "--setting-sources", "local", "--no-session-persistence");
765
+ args.push("-p", "--output-format", "json");
766
+ if (target.model)
767
+ args.push("--model", target.model);
768
+ if (target.agent)
769
+ args.push("--agent", target.agent);
770
+ args.push(...target.extraArgs ?? [], target.prompt);
771
+ return args;
772
+ case "cursor":
773
+ args.push("-p");
774
+ if (target.model)
775
+ args.push("--model", target.model);
776
+ if (target.agent)
777
+ args.push("--agent", target.agent);
778
+ args.push(...target.extraArgs ?? [], target.prompt);
779
+ return args;
780
+ case "codewith":
781
+ args.push("exec", "--json", "--ephemeral", "--ask-for-approval", "never", "--sandbox", "workspace-write");
782
+ if (isolation === "safe")
783
+ args.push("--ignore-rules");
784
+ if (target.cwd)
785
+ args.push("--cd", target.cwd);
786
+ if (target.model)
787
+ args.push("--model", target.model);
788
+ if (target.agent)
789
+ args.push("--agent", target.agent);
790
+ args.push(...target.extraArgs ?? [], target.prompt);
791
+ return args;
792
+ case "aicopilot":
793
+ args.push("run", "--format", "json");
794
+ if (isolation === "safe")
795
+ args.push("--pure");
796
+ if (target.cwd)
797
+ args.push("--dir", target.cwd);
798
+ if (target.model)
799
+ args.push("--model", target.model);
800
+ if (target.agent)
801
+ args.push("--agent", target.agent);
802
+ args.push(...target.extraArgs ?? [], target.prompt);
803
+ return args;
804
+ case "opencode":
805
+ args.push("run", "--format", "json");
806
+ if (isolation === "safe")
807
+ args.push("--pure");
808
+ if (target.cwd)
809
+ args.push("--dir", target.cwd);
810
+ if (target.model)
811
+ args.push("--model", target.model);
812
+ if (target.agent)
813
+ args.push("--agent", target.agent);
814
+ args.push(...target.extraArgs ?? [], target.prompt);
815
+ return args;
816
+ }
817
+ }
818
+ function commandSpec(loop) {
819
+ const target = loop.target;
820
+ if (target.type === "command") {
821
+ const commandTarget = target;
822
+ return {
823
+ command: commandTarget.command,
824
+ args: commandTarget.args ?? [],
825
+ cwd: commandTarget.cwd,
826
+ shell: commandTarget.shell,
827
+ env: commandTarget.env,
828
+ timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS
829
+ };
830
+ }
831
+ const agentTarget = target;
832
+ return {
833
+ command: providerCommand(agentTarget.provider),
834
+ args: agentArgs(agentTarget),
835
+ cwd: agentTarget.cwd,
836
+ timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS
837
+ };
838
+ }
839
+ async function executeLoop(loop, run, opts = {}) {
840
+ const spec = commandSpec(loop);
841
+ const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
842
+ const startedAt = nowIso();
843
+ let stdout = "";
844
+ let stderr = "";
845
+ let timedOut = false;
846
+ let exitCode;
847
+ let error;
848
+ const env = {
849
+ ...opts.env ?? process.env,
850
+ ...spec.env ?? {},
851
+ LOOPS_LOOP_ID: loop.id,
852
+ LOOPS_LOOP_NAME: loop.name,
853
+ LOOPS_RUN_ID: run.id,
854
+ LOOPS_SCHEDULED_FOR: run.scheduledFor
855
+ };
856
+ const child = spawn(spec.command, spec.args, {
857
+ cwd: spec.cwd,
858
+ env,
859
+ shell: spec.shell ?? false,
860
+ detached: true,
861
+ stdio: ["ignore", "pipe", "pipe"]
862
+ });
863
+ child.stdout.on("data", (chunk) => {
864
+ stdout = appendBounded(stdout, chunk, maxOutputBytes);
865
+ });
866
+ child.stderr.on("data", (chunk) => {
867
+ stderr = appendBounded(stderr, chunk, maxOutputBytes);
868
+ });
869
+ const timer = setTimeout(() => {
870
+ timedOut = true;
871
+ if (child.pid)
872
+ killProcessGroup(child.pid);
873
+ }, spec.timeoutMs);
874
+ timer.unref();
875
+ try {
876
+ const [code, signal] = await once(child, "exit");
877
+ if (typeof code === "number")
878
+ exitCode = code;
879
+ if (signal)
880
+ error = `terminated by ${signal}`;
881
+ } catch (err) {
882
+ error = err instanceof Error ? err.message : String(err);
883
+ } finally {
884
+ clearTimeout(timer);
885
+ }
886
+ const finishedAt = nowIso();
887
+ const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
888
+ if (timedOut) {
889
+ return {
890
+ status: "timed_out",
891
+ exitCode,
892
+ stdout,
893
+ stderr,
894
+ error: `timed out after ${spec.timeoutMs}ms`,
895
+ pid: child.pid,
896
+ startedAt,
897
+ finishedAt,
898
+ durationMs
899
+ };
900
+ }
901
+ if (error || exitCode !== 0) {
902
+ return {
903
+ status: "failed",
904
+ exitCode,
905
+ stdout,
906
+ stderr,
907
+ error: error ?? `process exited with code ${exitCode ?? "unknown"}`,
908
+ pid: child.pid,
909
+ startedAt,
910
+ finishedAt,
911
+ durationMs
912
+ };
913
+ }
914
+ return {
915
+ status: "succeeded",
916
+ exitCode,
917
+ stdout,
918
+ stderr,
919
+ pid: child.pid,
920
+ startedAt,
921
+ finishedAt,
922
+ durationMs
923
+ };
924
+ }
925
+
926
+ // src/lib/scheduler.ts
927
+ function nextAfterRetry(loop, now) {
928
+ return new Date(now.getTime() + loop.retryDelayMs).toISOString();
929
+ }
930
+ function advanceLoop(store, loop, run, finishedAt, succeeded) {
931
+ const shouldRetry = !succeeded && run.attempt < loop.maxAttempts;
932
+ if (shouldRetry) {
933
+ store.updateLoop(loop.id, {
934
+ status: "active",
935
+ nextRunAt: nextAfterRetry(loop, finishedAt),
936
+ retryScheduledFor: run.scheduledFor
937
+ });
938
+ return;
939
+ }
940
+ const nextRunAt = computeNextAfter(loop.schedule, new Date(run.scheduledFor), finishedAt);
941
+ store.updateLoop(loop.id, {
942
+ status: nextRunAt ? "active" : "stopped",
943
+ nextRunAt,
944
+ retryScheduledFor: undefined
945
+ });
946
+ }
947
+ async function runSlot(deps, loop, scheduledFor) {
948
+ const now = deps.now?.() ?? new Date;
949
+ if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
950
+ const skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active");
951
+ advanceLoop(deps.store, loop, skipped, now, true);
952
+ deps.onRun?.(skipped);
953
+ return skipped;
954
+ }
955
+ const claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now);
956
+ if (!claim)
957
+ return;
958
+ deps.onRun?.(claim.run);
959
+ try {
960
+ const result = await (deps.execute ?? executeLoop)(claim.loop, claim.run);
961
+ const finalRun = deps.store.finalizeRun(claim.run.id, {
962
+ status: result.status,
963
+ finishedAt: result.finishedAt,
964
+ durationMs: result.durationMs,
965
+ stdout: result.stdout,
966
+ stderr: result.stderr,
967
+ exitCode: result.exitCode,
968
+ error: result.error,
969
+ pid: result.pid
970
+ });
971
+ advanceLoop(deps.store, claim.loop, finalRun, new Date(result.finishedAt), result.status === "succeeded");
972
+ deps.onRun?.(finalRun);
973
+ return finalRun;
974
+ } catch (err) {
975
+ deps.onError?.(claim.loop, err);
976
+ const finishedAt = new Date;
977
+ const finalRun = deps.store.finalizeRun(claim.run.id, {
978
+ status: "failed",
979
+ finishedAt: finishedAt.toISOString(),
980
+ durationMs: finishedAt.getTime() - new Date(claim.run.startedAt ?? claim.run.createdAt).getTime(),
981
+ stdout: "",
982
+ stderr: "",
983
+ error: err instanceof Error ? err.message : String(err)
984
+ });
985
+ advanceLoop(deps.store, claim.loop, finalRun, finishedAt, false);
986
+ deps.onRun?.(finalRun);
987
+ return finalRun;
988
+ }
989
+ }
990
+ async function tick(deps) {
991
+ const now = deps.now?.() ?? new Date;
992
+ const recovered = deps.store.recoverExpiredRunLeases(now);
993
+ for (const run of recovered) {
994
+ const loop = deps.store.getLoop(run.loopId);
995
+ if (loop)
996
+ advanceLoop(deps.store, loop, run, new Date(run.finishedAt ?? now), false);
997
+ }
998
+ const expired = deps.store.expireLoops(now);
999
+ const claimed = [];
1000
+ const completed = [];
1001
+ const skipped = [];
1002
+ for (const loop of deps.store.dueLoops(now)) {
1003
+ const plan = dueSlots(loop, now);
1004
+ for (const slot of plan.slots) {
1005
+ const run = await runSlot(deps, loop, slot);
1006
+ if (!run)
1007
+ continue;
1008
+ if (run.status === "running")
1009
+ claimed.push(run);
1010
+ else if (run.status === "skipped")
1011
+ skipped.push(run);
1012
+ else
1013
+ completed.push(run);
1014
+ }
1015
+ }
1016
+ return { claimed, completed, skipped, recovered, expired };
1017
+ }
1018
+
1019
+ // src/daemon/control.ts
1020
+ import { existsSync, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
1021
+ import { hostname } from "os";
1022
+ import { dirname as dirname2 } from "path";
1023
+
1024
+ // src/daemon/loop.ts
1025
+ function realSleep(ms) {
1026
+ return new Promise((resolve) => setTimeout(resolve, ms));
1027
+ }
1028
+ async function runLoop(opts) {
1029
+ const sleep = opts.sleep ?? realSleep;
1030
+ const sliceMs = opts.sliceMs ?? 200;
1031
+ while (!opts.shouldStop()) {
1032
+ try {
1033
+ await opts.tickFn();
1034
+ } catch (err) {
1035
+ opts.onTickError?.(err);
1036
+ }
1037
+ let waited = 0;
1038
+ while (waited < opts.intervalMs && !opts.shouldStop()) {
1039
+ const chunk = Math.min(sliceMs, opts.intervalMs - waited);
1040
+ await sleep(chunk);
1041
+ waited += chunk;
1042
+ }
1043
+ }
1044
+ }
1045
+
1046
+ // src/daemon/control.ts
1047
+ function readPid(path = pidFilePath()) {
1048
+ if (!existsSync(path))
1049
+ return;
1050
+ try {
1051
+ const pid = Number(readFileSync(path, "utf8").trim());
1052
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
1053
+ } catch {
1054
+ return;
1055
+ }
1056
+ }
1057
+ function writePid(pid = process.pid, path = pidFilePath()) {
1058
+ mkdirSync3(dirname2(path), { recursive: true, mode: 448 });
1059
+ writeFileSync(path, String(pid));
1060
+ }
1061
+ function removePid(path = pidFilePath()) {
1062
+ rmSync(path, { force: true });
1063
+ }
1064
+ function isAlive(pid) {
1065
+ try {
1066
+ process.kill(pid, 0);
1067
+ return true;
1068
+ } catch (err) {
1069
+ return err.code === "EPERM";
1070
+ }
1071
+ }
1072
+ function isDaemonRunning(path = pidFilePath()) {
1073
+ const pid = readPid(path);
1074
+ if (!pid)
1075
+ return { running: false, stale: false };
1076
+ if (isAlive(pid))
1077
+ return { running: true, stale: false, pid };
1078
+ return { running: false, stale: true, pid };
1079
+ }
1080
+ function daemonStatus(store, path = pidFilePath()) {
1081
+ return {
1082
+ ...isDaemonRunning(path),
1083
+ lease: store.getDaemonLease(),
1084
+ host: hostname(),
1085
+ loops: {
1086
+ total: store.countLoops(),
1087
+ active: store.countLoops("active"),
1088
+ paused: store.countLoops("paused"),
1089
+ stopped: store.countLoops("stopped"),
1090
+ expired: store.countLoops("expired")
1091
+ },
1092
+ runs: {
1093
+ total: store.countRuns(),
1094
+ running: store.countRuns("running"),
1095
+ failed: store.countRuns("failed"),
1096
+ succeeded: store.countRuns("succeeded"),
1097
+ abandoned: store.countRuns("abandoned")
1098
+ },
1099
+ logPath: daemonLogPath()
1100
+ };
1101
+ }
1102
+ async function stopDaemon(opts = {}) {
1103
+ const path = opts.path ?? pidFilePath();
1104
+ const state = isDaemonRunning(path);
1105
+ if (state.stale) {
1106
+ removePid(path);
1107
+ return { wasRunning: false, stopped: false, forced: false, pid: state.pid };
1108
+ }
1109
+ if (!state.running || !state.pid)
1110
+ return { wasRunning: false, stopped: false, forced: false };
1111
+ const store = new Store;
1112
+ try {
1113
+ const lease = store.getDaemonLease();
1114
+ if (!lease || lease.pid !== state.pid || new Date(lease.expiresAt).getTime() <= Date.now()) {
1115
+ removePid(path);
1116
+ return { wasRunning: false, stopped: false, forced: false, pid: state.pid };
1117
+ }
1118
+ } finally {
1119
+ store.close();
1120
+ }
1121
+ const sleep = opts.sleep ?? realSleep;
1122
+ try {
1123
+ process.kill(state.pid, "SIGTERM");
1124
+ } catch {
1125
+ removePid(path);
1126
+ return { wasRunning: true, stopped: true, forced: false, pid: state.pid };
1127
+ }
1128
+ const steps = Math.max(1, Math.ceil((opts.timeoutMs ?? 6000) / 100));
1129
+ for (let i = 0;i < steps; i++) {
1130
+ await sleep(100);
1131
+ if (!isAlive(state.pid)) {
1132
+ removePid(path);
1133
+ return { wasRunning: true, stopped: true, forced: false, pid: state.pid };
1134
+ }
1135
+ }
1136
+ try {
1137
+ process.kill(state.pid, "SIGKILL");
1138
+ } catch {}
1139
+ await sleep(150);
1140
+ removePid(path);
1141
+ return { wasRunning: true, stopped: !isAlive(state.pid), forced: true, pid: state.pid };
1142
+ }
1143
+
1144
+ // src/daemon/daemon.ts
1145
+ import { openSync } from "fs";
1146
+ import { hostname as hostname2 } from "os";
1147
+ import { spawn as spawn2 } from "child_process";
1148
+ function intervalFromEnv() {
1149
+ const raw = process.env.LOOPS_DAEMON_INTERVAL_MS;
1150
+ if (!raw)
1151
+ return;
1152
+ const value = Number(raw);
1153
+ return Number.isFinite(value) && value > 0 ? value : undefined;
1154
+ }
1155
+ async function runDaemon(opts = {}) {
1156
+ ensureDataDir();
1157
+ const pidPath = opts.pidPath ?? pidFilePath();
1158
+ const state = isDaemonRunning(pidPath);
1159
+ if (state.running)
1160
+ throw new Error(`daemon already running (pid ${state.pid})`);
1161
+ if (state.stale)
1162
+ removePid(pidPath);
1163
+ const ownStore = !opts.store;
1164
+ const store = opts.store ?? new Store;
1165
+ const leaseId = genId();
1166
+ const intervalMs = opts.intervalMs ?? intervalFromEnv() ?? 1000;
1167
+ const leaseTtlMs = opts.leaseTtlMs ?? Math.max(60000, intervalMs * 10);
1168
+ const log = opts.log ?? ((message) => console.error(`[loops-daemon] ${message}`));
1169
+ const lease = store.acquireDaemonLease({
1170
+ id: leaseId,
1171
+ pid: process.pid,
1172
+ hostname: hostname2(),
1173
+ ttlMs: leaseTtlMs
1174
+ });
1175
+ if (!lease)
1176
+ throw new Error("another loops daemon holds the database lease");
1177
+ writePid(process.pid, pidPath);
1178
+ log(`started pid=${process.pid} interval=${intervalMs}ms lease=${leaseId}`);
1179
+ let stopFlag = false;
1180
+ let leaseLost = false;
1181
+ const ensureLease = () => {
1182
+ const current = store.heartbeatDaemonLease(leaseId, leaseTtlMs);
1183
+ if (!current || current.id !== leaseId) {
1184
+ leaseLost = true;
1185
+ stopFlag = true;
1186
+ throw new Error("daemon lease lost");
1187
+ }
1188
+ };
1189
+ const onSignal = () => {
1190
+ stopFlag = true;
1191
+ log("stop signal received");
1192
+ };
1193
+ process.on("SIGINT", onSignal);
1194
+ process.on("SIGTERM", onSignal);
1195
+ try {
1196
+ await runLoop({
1197
+ intervalMs,
1198
+ sleep: opts.sleep ?? realSleep,
1199
+ shouldStop: opts.shouldStop ?? (() => stopFlag),
1200
+ onTickError: (err) => log(`tick error: ${err instanceof Error ? err.message : String(err)}`),
1201
+ tickFn: async () => {
1202
+ ensureLease();
1203
+ const result = await tick({
1204
+ store,
1205
+ runnerId: `${hostname2()}:${process.pid}:${leaseId}`,
1206
+ execute: async (loop, run) => {
1207
+ const heartbeatMs = Math.max(1000, Math.floor(leaseTtlMs / 3));
1208
+ const timer = setInterval(() => {
1209
+ try {
1210
+ ensureLease();
1211
+ } catch (err) {
1212
+ log(err instanceof Error ? err.message : String(err));
1213
+ }
1214
+ }, heartbeatMs);
1215
+ timer.unref();
1216
+ try {
1217
+ const result2 = await executeLoop(loop, run);
1218
+ if (leaseLost)
1219
+ throw new Error("daemon lease lost during run");
1220
+ return result2;
1221
+ } finally {
1222
+ clearInterval(timer);
1223
+ }
1224
+ },
1225
+ onError: (loop, err) => log(`loop ${loop.id} failed: ${err instanceof Error ? err.message : String(err)}`)
1226
+ });
1227
+ const changed = result.completed.length + result.skipped.length + result.recovered.length + result.expired.length;
1228
+ if (changed > 0) {
1229
+ log(`tick completed=${result.completed.length} skipped=${result.skipped.length} recovered=${result.recovered.length} expired=${result.expired.length}`);
1230
+ }
1231
+ }
1232
+ });
1233
+ } finally {
1234
+ process.off("SIGINT", onSignal);
1235
+ process.off("SIGTERM", onSignal);
1236
+ store.releaseDaemonLease(leaseId);
1237
+ removePid(pidPath);
1238
+ if (ownStore)
1239
+ store.close();
1240
+ log("stopped");
1241
+ }
1242
+ }
1243
+ async function startDaemon(opts) {
1244
+ ensureDataDir();
1245
+ const state = isDaemonRunning();
1246
+ if (state.running)
1247
+ return { started: false, alreadyRunning: true, pid: state.pid };
1248
+ if (state.stale)
1249
+ removePid();
1250
+ const out = openSync(daemonLogPath(), "a");
1251
+ const child = spawn2(opts.execPath ?? process.execPath, [opts.cliEntry, ...opts.args ?? ["daemon", "run"]], {
1252
+ detached: true,
1253
+ stdio: ["ignore", out, out]
1254
+ });
1255
+ child.unref();
1256
+ const sleep = opts.sleep ?? realSleep;
1257
+ const deadline = Math.max(1, Math.ceil((opts.waitMs ?? 4000) / 100));
1258
+ for (let i = 0;i < deadline; i++) {
1259
+ await sleep(100);
1260
+ const current = isDaemonRunning();
1261
+ if (current.running)
1262
+ return { started: true, alreadyRunning: false, pid: current.pid };
1263
+ }
1264
+ return { started: false, alreadyRunning: false };
1265
+ }
1266
+
1267
+ // src/daemon/install.ts
1268
+ import { chmodSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync2 } from "fs";
1269
+ import { dirname as dirname3 } from "path";
1270
+ function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
1271
+ const command = [execPath, cliEntry, ...args].join(" ");
1272
+ if (process.platform === "linux") {
1273
+ const path = systemdServicePath();
1274
+ mkdirSync4(dirname3(path), { recursive: true, mode: 448 });
1275
+ writeFileSync2(path, `[Unit]
1276
+ Description=Hasna OpenLoops daemon
1277
+ After=default.target
1278
+
1279
+ [Service]
1280
+ Type=simple
1281
+ ExecStart=${command}
1282
+ Restart=always
1283
+ RestartSec=5
1284
+ Environment=PATH=${process.env.PATH ?? ""}
1285
+
1286
+ [Install]
1287
+ WantedBy=default.target
1288
+ `);
1289
+ return {
1290
+ platform: process.platform,
1291
+ path,
1292
+ instructions: [
1293
+ "systemctl --user daemon-reload",
1294
+ "systemctl --user enable --now loops-daemon.service",
1295
+ "loginctl enable-linger $USER"
1296
+ ]
1297
+ };
1298
+ }
1299
+ if (process.platform === "darwin") {
1300
+ const path = launchdPlistPath();
1301
+ mkdirSync4(dirname3(path), { recursive: true, mode: 448 });
1302
+ writeFileSync2(path, `<?xml version="1.0" encoding="UTF-8"?>
1303
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1304
+ <plist version="1.0">
1305
+ <dict>
1306
+ <key>Label</key><string>com.hasna.loops.daemon</string>
1307
+ <key>ProgramArguments</key>
1308
+ <array>
1309
+ <string>${execPath}</string>
1310
+ <string>${cliEntry}</string>
1311
+ ${args.map((arg) => ` <string>${arg}</string>`).join(`
1312
+ `)}
1313
+ </array>
1314
+ <key>RunAtLoad</key><true/>
1315
+ <key>KeepAlive</key><true/>
1316
+ <key>StandardOutPath</key><string>${daemonLogPath()}</string>
1317
+ <key>StandardErrorPath</key><string>${daemonLogPath()}</string>
1318
+ </dict>
1319
+ </plist>
1320
+ `);
1321
+ chmodSync(path, 384);
1322
+ return {
1323
+ platform: process.platform,
1324
+ path,
1325
+ instructions: [`launchctl load -w ${path}`]
1326
+ };
1327
+ }
1328
+ throw new Error(`startup install is not implemented for ${process.platform}`);
1329
+ }
1330
+
1331
+ // src/cli/index.ts
1332
+ var program = new Command;
1333
+ program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.1.0");
1334
+ program.option("-j, --json", "print JSON");
1335
+ function isJson() {
1336
+ return Boolean(program.opts().json);
1337
+ }
1338
+ function print(value, human) {
1339
+ if (isJson() || !human)
1340
+ console.log(JSON.stringify(value, null, 2));
1341
+ else
1342
+ console.log(human);
1343
+ }
1344
+ function parseSchedule(opts) {
1345
+ const count = [opts.at, opts.every, opts.cron, opts.dynamic ? "dynamic" : undefined].filter(Boolean).length;
1346
+ if (count !== 1)
1347
+ throw new Error("choose exactly one schedule: --at, --every, --cron, or --dynamic");
1348
+ if (opts.at)
1349
+ return { type: "once", at: new Date(opts.at).toISOString() };
1350
+ if (opts.every)
1351
+ return { type: "interval", everyMs: parseDuration(opts.every), anchor: "fixed_rate" };
1352
+ if (opts.cron)
1353
+ return { type: "cron", expression: opts.cron };
1354
+ return { type: "dynamic", minIntervalMs: 60000 };
1355
+ }
1356
+ function positiveInteger(raw, label) {
1357
+ if (raw === undefined)
1358
+ return;
1359
+ const value = Number(raw);
1360
+ if (!Number.isInteger(value) || value <= 0)
1361
+ throw new Error(`${label} must be a positive integer`);
1362
+ return value;
1363
+ }
1364
+ function positiveDuration(raw, label) {
1365
+ if (raw === undefined)
1366
+ return;
1367
+ const value = parseDuration(raw);
1368
+ if (!Number.isFinite(value) || value <= 0)
1369
+ throw new Error(`${label} must be greater than zero`);
1370
+ return value;
1371
+ }
1372
+ function parsePolicy(opts) {
1373
+ const catchUp = opts.catchUp ?? "latest";
1374
+ if (!["none", "latest", "all"].includes(catchUp))
1375
+ throw new Error("--catch-up must be none, latest, or all");
1376
+ const overlap = opts.overlap ?? "skip";
1377
+ if (!["skip", "allow"].includes(overlap))
1378
+ throw new Error("--overlap must be skip or allow");
1379
+ return {
1380
+ catchUp,
1381
+ catchUpLimit: positiveInteger(opts.catchUpLimit, "--catch-up-limit"),
1382
+ overlap,
1383
+ maxAttempts: positiveInteger(opts.attempts, "--attempts"),
1384
+ retryDelayMs: positiveDuration(opts.retryDelay, "--retry-delay"),
1385
+ leaseMs: positiveDuration(opts.lease, "--lease")
1386
+ };
1387
+ }
1388
+ function baseCreateInput(name, opts, target) {
1389
+ const schedule = parseSchedule({
1390
+ at: typeof opts.at === "string" ? opts.at : undefined,
1391
+ every: typeof opts.every === "string" ? opts.every : undefined,
1392
+ cron: typeof opts.cron === "string" ? opts.cron : undefined,
1393
+ dynamic: Boolean(opts.dynamic)
1394
+ });
1395
+ const policy = parsePolicy({
1396
+ catchUp: typeof opts.catchUp === "string" ? opts.catchUp : undefined,
1397
+ catchUpLimit: typeof opts.catchUpLimit === "string" ? opts.catchUpLimit : undefined,
1398
+ overlap: typeof opts.overlap === "string" ? opts.overlap : undefined,
1399
+ attempts: typeof opts.attempts === "string" ? opts.attempts : undefined,
1400
+ retryDelay: typeof opts.retryDelay === "string" ? opts.retryDelay : undefined,
1401
+ lease: typeof opts.lease === "string" ? opts.lease : undefined
1402
+ });
1403
+ return {
1404
+ name,
1405
+ description: typeof opts.description === "string" ? opts.description : undefined,
1406
+ schedule,
1407
+ target,
1408
+ ...policy,
1409
+ expiresAt: typeof opts.expiresAt === "string" ? new Date(opts.expiresAt).toISOString() : undefined
1410
+ };
1411
+ }
1412
+ function addScheduleOptions(command) {
1413
+ return command.option("--at <time>", "run once at an absolute time").option("--every <duration>", "run at a fixed interval, e.g. 15m, 1h, 30s").option("--cron <expr>", "run on a 5-field cron expression").option("--dynamic", "run on the default dynamic one-minute cadence").option("--catch-up <policy>", "none, latest, or all", "latest").option("--catch-up-limit <n>", "maximum missed slots to run when --catch-up all").option("--overlap <policy>", "skip or allow", "skip").option("--attempts <n>", "max attempts per scheduled slot").option("--retry-delay <duration>", "delay between retries", "1m").option("--lease <duration>", "running lease timeout", "30m").option("--expires-at <time>", "stop scheduling after this time").option("-d, --description <text>", "description");
1414
+ }
1415
+ var create = program.command("create").description("create loops");
1416
+ addScheduleOptions(create.command("command <name>").description("create a deterministic shell command loop").requiredOption("--cmd <command>", "command string to execute").option("--cwd <dir>", "working directory").option("--timeout <duration>", "run timeout").option("--no-shell", "execute without a shell")).action((name, opts) => {
1417
+ const store = new Store;
1418
+ try {
1419
+ const target = {
1420
+ type: "command",
1421
+ command: opts.cmd,
1422
+ cwd: opts.cwd,
1423
+ shell: opts.shell,
1424
+ timeoutMs: opts.timeout ? parseDuration(opts.timeout) : undefined
1425
+ };
1426
+ const loop = store.createLoop(baseCreateInput(name, opts, target));
1427
+ print(publicLoop(loop), `created loop ${loop.id} (${loop.name}) next=${loop.nextRunAt}`);
1428
+ } finally {
1429
+ store.close();
1430
+ }
1431
+ });
1432
+ addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, or opencode").requiredOption("--prompt <prompt>", "agent prompt").option("--cwd <dir>", "working directory").option("--model <model>", "model").option("--agent <agent>", "provider-specific agent").option("--timeout <duration>", "run timeout").option("--config-isolation <mode>", "safe or none", "safe")).action((name, opts) => {
1433
+ const provider = opts.provider;
1434
+ if (!["claude", "cursor", "codewith", "aicopilot", "opencode"].includes(provider)) {
1435
+ throw new Error("unsupported provider");
1436
+ }
1437
+ if (!["safe", "none"].includes(opts.configIsolation)) {
1438
+ throw new Error("--config-isolation must be safe or none");
1439
+ }
1440
+ const store = new Store;
1441
+ try {
1442
+ const target = {
1443
+ type: "agent",
1444
+ provider,
1445
+ prompt: opts.prompt,
1446
+ cwd: opts.cwd,
1447
+ model: opts.model,
1448
+ agent: opts.agent,
1449
+ timeoutMs: opts.timeout ? parseDuration(opts.timeout) : undefined,
1450
+ configIsolation: opts.configIsolation
1451
+ };
1452
+ const loop = store.createLoop(baseCreateInput(name, opts, target));
1453
+ print(publicLoop(loop), `created loop ${loop.id} (${loop.name}) next=${loop.nextRunAt}`);
1454
+ } finally {
1455
+ store.close();
1456
+ }
1457
+ });
1458
+ program.command("list").alias("ls").option("--status <status>", "filter by status").action((opts) => {
1459
+ const store = new Store;
1460
+ try {
1461
+ const loops = store.listLoops({ status: opts.status });
1462
+ if (isJson())
1463
+ print(loops.map(publicLoop));
1464
+ else {
1465
+ for (const loop of loops) {
1466
+ console.log(`${loop.id} ${loop.status.padEnd(7)} next=${loop.nextRunAt ?? "-"} ${loop.name}`);
1467
+ }
1468
+ }
1469
+ } finally {
1470
+ store.close();
1471
+ }
1472
+ });
1473
+ program.command("show <idOrName>").action((idOrName) => {
1474
+ const store = new Store;
1475
+ try {
1476
+ print(publicLoop(store.requireLoop(idOrName)));
1477
+ } finally {
1478
+ store.close();
1479
+ }
1480
+ });
1481
+ program.command("runs [idOrName]").option("--limit <n>", "limit", "50").option("--show-output", "show stdout/stderr").action((idOrName, opts) => {
1482
+ const store = new Store;
1483
+ try {
1484
+ const loop = idOrName ? store.requireLoop(idOrName) : undefined;
1485
+ const runs = store.listRuns({ loopId: loop?.id, limit: Number(opts.limit) });
1486
+ if (isJson())
1487
+ print(runs.map((run) => publicRun(run, opts.showOutput)));
1488
+ else {
1489
+ for (const run of runs) {
1490
+ console.log(`${run.id} ${run.status.padEnd(10)} attempt=${run.attempt} slot=${run.scheduledFor} ${run.loopName}`);
1491
+ }
1492
+ }
1493
+ } finally {
1494
+ store.close();
1495
+ }
1496
+ });
1497
+ program.command("pause <idOrName>").action((idOrName) => updateStatus(idOrName, "paused"));
1498
+ program.command("resume <idOrName>").action((idOrName) => updateStatus(idOrName, "active"));
1499
+ program.command("stop <idOrName>").action((idOrName) => updateStatus(idOrName, "stopped"));
1500
+ function updateStatus(idOrName, status) {
1501
+ const store = new Store;
1502
+ try {
1503
+ const loop = store.requireLoop(idOrName);
1504
+ const updated = store.updateLoop(loop.id, { status, nextRunAt: status === "stopped" ? undefined : loop.nextRunAt });
1505
+ print(publicLoop(updated), `${updated.id} ${updated.status}`);
1506
+ } finally {
1507
+ store.close();
1508
+ }
1509
+ }
1510
+ program.command("remove <idOrName>").alias("rm").action((idOrName) => {
1511
+ const store = new Store;
1512
+ try {
1513
+ const removed = store.deleteLoop(idOrName);
1514
+ print({ removed }, removed ? "removed" : "not removed");
1515
+ } finally {
1516
+ store.close();
1517
+ }
1518
+ });
1519
+ program.command("run-now <idOrName>").option("--show-output", "show stdout/stderr").action(async (idOrName, opts) => {
1520
+ const store = new Store;
1521
+ try {
1522
+ const loop = store.requireLoop(idOrName);
1523
+ const claim = store.claimRun(loop, new Date().toISOString(), `manual:${process.pid}`);
1524
+ if (!claim)
1525
+ throw new Error("could not claim manual run");
1526
+ const result = await executeLoop(loop, claim.run);
1527
+ const run = store.finalizeRun(claim.run.id, {
1528
+ status: result.status,
1529
+ finishedAt: result.finishedAt,
1530
+ durationMs: result.durationMs,
1531
+ stdout: result.stdout,
1532
+ stderr: result.stderr,
1533
+ exitCode: result.exitCode,
1534
+ error: result.error,
1535
+ pid: result.pid
1536
+ });
1537
+ print(publicRun(run, opts.showOutput), `${run.id} ${run.status}`);
1538
+ } finally {
1539
+ store.close();
1540
+ }
1541
+ });
1542
+ program.command("tick").description("run one scheduler tick").action(async () => {
1543
+ const store = new Store;
1544
+ try {
1545
+ const result = await tick({ store, runnerId: `manual-tick:${process.pid}` });
1546
+ print(result, `completed=${result.completed.length} skipped=${result.skipped.length} recovered=${result.recovered.length}`);
1547
+ } finally {
1548
+ store.close();
1549
+ }
1550
+ });
1551
+ var daemon = program.command("daemon").description("manage the local daemon");
1552
+ daemon.command("run").option("--interval-ms <ms>", "tick interval", (value) => Number(value)).action(async (opts) => runDaemon({ intervalMs: opts.intervalMs }));
1553
+ daemon.command("start").action(async () => {
1554
+ const result = await startDaemon({ cliEntry: process.argv[1] ?? "loops" });
1555
+ print(result, result.alreadyRunning ? `already running pid=${result.pid}` : result.started ? `started pid=${result.pid}` : "failed to start");
1556
+ });
1557
+ daemon.command("stop").action(async () => {
1558
+ const result = await stopDaemon();
1559
+ print(result, result.stopped ? `stopped pid=${result.pid}` : "not running");
1560
+ });
1561
+ daemon.command("status").action(() => {
1562
+ const store = new Store;
1563
+ try {
1564
+ print(daemonStatus(store));
1565
+ } finally {
1566
+ store.close();
1567
+ }
1568
+ });
1569
+ daemon.command("install").description("write a systemd user service or launchd plist").action(() => {
1570
+ const result = installStartup(process.argv[1] ?? "loops");
1571
+ print(result, `wrote ${result.path}
1572
+ ${result.instructions.join(`
1573
+ `)}`);
1574
+ });
1575
+ daemon.command("logs").option("-n, --lines <n>", "lines", "80").action((opts) => {
1576
+ const path = daemonLogPath();
1577
+ if (!existsSync2(path)) {
1578
+ console.log("");
1579
+ return;
1580
+ }
1581
+ const lines = readFileSync2(path, "utf8").trimEnd().split(`
1582
+ `);
1583
+ console.log(lines.slice(-Number(opts.lines)).join(`
1584
+ `));
1585
+ });
1586
+ await program.parseAsync(process.argv);