macroclaw 0.22.0 → 0.24.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "macroclaw",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "Telegram-to-Claude-Code bridge",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cron.test.ts CHANGED
@@ -39,6 +39,12 @@ function minutesAgoCron(minutesAgo: number): string {
39
39
  return `${past.getMinutes()} ${past.getHours()} ${past.getDate()} ${past.getMonth() + 1} *`;
40
40
  }
41
41
 
42
+ // Build a cron expression that matched N days ago
43
+ function daysAgoCron(daysAgo: number): string {
44
+ const past = new Date(Date.now() - daysAgo * 24 * 60 * 60_000);
45
+ return `${past.getMinutes()} ${past.getHours()} ${past.getDate()} ${past.getMonth() + 1} *`;
46
+ }
47
+
42
48
  beforeEach(() => {
43
49
  mkdirSync(SCHEDULE_DIR, { recursive: true });
44
50
  });
@@ -337,4 +343,41 @@ describe("missed non-recurring events", () => {
337
343
  const updated = readScheduleConfig();
338
344
  expect(updated.jobs).toHaveLength(0);
339
345
  });
346
+
347
+ it("discards non-recurring job missed by more than a week without firing", () => {
348
+ writeScheduleConfig({
349
+ jobs: [
350
+ { name: "stale", cron: daysAgoCron(10), prompt: "too old", recurring: false },
351
+ { name: "keeper", cron: nonMatchingCron(), prompt: "stay" },
352
+ ],
353
+ });
354
+
355
+ const onJob = makeOnJob();
356
+ const cron = new CronScheduler(TEST_DIR, { onJob });
357
+ cron.start();
358
+ cron.stop();
359
+
360
+ expect(onJob).not.toHaveBeenCalled();
361
+
362
+ const updated = readScheduleConfig();
363
+ expect(updated.jobs).toHaveLength(1);
364
+ expect(updated.jobs[0].name).toBe("keeper");
365
+ });
366
+
367
+ it("fires non-recurring job missed by 6 days (within week window)", () => {
368
+ writeScheduleConfig({
369
+ jobs: [{ name: "recent", cron: daysAgoCron(6), prompt: "still valid", recurring: false }],
370
+ });
371
+
372
+ const onJob = makeOnJob();
373
+ const cron = new CronScheduler(TEST_DIR, { onJob });
374
+ cron.start();
375
+ cron.stop();
376
+
377
+ expect(onJob).toHaveBeenCalledTimes(1);
378
+ const call = onJob.mock.calls[0];
379
+ expect(call[0]).toBe("recent");
380
+ expect(call[1]).toContain("[missed event, should have fired");
381
+ expect(call[1]).toContain("still valid");
382
+ });
340
383
  });
package/src/cron.ts CHANGED
@@ -25,6 +25,7 @@ export interface CronSchedulerConfig {
25
25
  }
26
26
 
27
27
  const TICK_INTERVAL = 10_000; // 10 seconds
28
+ const MAX_MISSED_MS = 7 * 24 * 60 * 60 * 1000; // 1 week
28
29
 
29
30
  export class CronScheduler {
30
31
  #lastMinute = -1;
@@ -87,14 +88,18 @@ export class CronScheduler {
87
88
  if (job.recurring === false) {
88
89
  firedNonRecurring.push(i);
89
90
  }
90
- } else if (job.recurring === false && diff >= 60_000) {
91
- // Non-recurring job in the past — fire regardless of how late
91
+ } else if (job.recurring === false && diff >= 60_000 && diff <= MAX_MISSED_MS) {
92
+ // Non-recurring job in the past — fire if missed within the last week
92
93
  const missedMinutes = Math.round(diff / 60_000);
93
94
  const firedAt = prev.toISOString();
94
95
  const missedPrompt = `[missed event, should have fired ${missedMinutes} min ago at ${firedAt}] ${job.prompt}`;
95
96
  log.info({ name: job.name, missedMinutes, firedAt }, "Firing missed non-recurring job");
96
97
  this.#config.onJob(job.name, missedPrompt, job.model);
97
98
  firedNonRecurring.push(i);
99
+ } else if (job.recurring === false && diff > MAX_MISSED_MS) {
100
+ // Non-recurring job missed by more than a week — discard without firing
101
+ log.warn({ name: job.name, missedMinutes: Math.round(diff / 60_000) }, "Discarding stale non-recurring job");
102
+ firedNonRecurring.push(i);
98
103
  }
99
104
  } catch (err) {
100
105
  log.warn({ cron: job.cron, err: err instanceof Error ? err.message : err }, "Invalid cron expression");
@@ -1,10 +1,15 @@
1
1
  import { beforeEach, describe, expect, it, mock } from "bun:test";
2
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
2
  import { join } from "node:path";
4
3
 
4
+ // Capture real fs functions before mocking
5
+ const realFs = await import("node:fs");
6
+ const { existsSync: realExistsSync, mkdirSync, readFileSync, rmSync, writeFileSync } = realFs;
7
+ const existsSync = realExistsSync;
8
+
5
9
  // Mock child_process and os — safe since no other tests depend on real execSync or userInfo
6
10
  const mockExecSync = mock((_cmd: string, _opts?: object) => "");
7
11
  const mockUserInfo = mock(() => ({ username: "testuser", homedir: "/home/testuser", uid: 1000, gid: 1000, shell: "/bin/bash" }));
12
+ const mockExistsSync = mock((path: string) => realExistsSync(path));
8
13
 
9
14
  mock.module("node:child_process", () => ({
10
15
  execSync: (...args: unknown[]) => mockExecSync(args[0] as string, args[1] as object),
@@ -15,6 +20,14 @@ mock.module("node:os", () => ({
15
20
  tmpdir: () => "/tmp",
16
21
  }));
17
22
 
23
+ mock.module("node:fs", () => {
24
+ const result: Record<string, unknown> = {};
25
+ for (const [key, value] of Object.entries(realFs)) {
26
+ result[key] = key === "existsSync" ? (path: string) => mockExistsSync(path) : value;
27
+ }
28
+ return result;
29
+ });
30
+
18
31
  const { SystemServiceManager } = await import("./system-service");
19
32
 
20
33
  function createManager(opts?: { platform?: string; home?: string }): InstanceType<typeof SystemServiceManager> {
@@ -29,8 +42,10 @@ const SYSTEMD_INACTIVE = "inactive";
29
42
  beforeEach(() => {
30
43
  mockExecSync.mockClear();
31
44
  mockUserInfo.mockClear();
45
+ mockExistsSync.mockClear();
32
46
  mockExecSync.mockImplementation((_cmd: string, _opts?: object) => "");
33
47
  mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: "/home/testuser", uid: 1000, gid: 1000, shell: "/bin/bash" }));
48
+ mockExistsSync.mockImplementation((path: string) => realExistsSync(path));
34
49
  });
35
50
 
36
51
  describe("constructor", () => {
@@ -662,6 +677,7 @@ describe("status", () => {
662
677
  if (cmd === "systemctl is-active macroclaw") throw new Error("not found");
663
678
  return "";
664
679
  });
680
+ mockExistsSync.mockReturnValue(false);
665
681
  const mgr = createManager({ home: "/nonexistent" });
666
682
  const s = mgr.status();
667
683
  expect(s.installed).toBe(false);