create-ironclaws 1.0.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.
Files changed (80) hide show
  1. package/README.md +101 -0
  2. package/bin/create.js +394 -0
  3. package/package.json +33 -0
  4. package/template/.env.example +38 -0
  5. package/template/CLAUDE.md +104 -0
  6. package/template/agent-credentials.yaml +33 -0
  7. package/template/agents.yaml +22 -0
  8. package/template/container/Dockerfile +70 -0
  9. package/template/container/Dockerfile.argus +34 -0
  10. package/template/container/agent-runner/package-lock.json +1524 -0
  11. package/template/container/agent-runner/package.json +23 -0
  12. package/template/container/agent-runner/src/index.ts +630 -0
  13. package/template/container/agent-runner/src/ipc-mcp-stdio.ts +339 -0
  14. package/template/container/agent-runner/tsconfig.json +15 -0
  15. package/template/container/build-argus.sh +25 -0
  16. package/template/container/build.sh +23 -0
  17. package/template/container/skills/agent-browser/SKILL.md +159 -0
  18. package/template/container/skills/agent-status/SKILL.md +69 -0
  19. package/template/container/skills/capabilities/SKILL.md +100 -0
  20. package/template/container/skills/edit-agent/SKILL.md +93 -0
  21. package/template/container/skills/slack-formatting/SKILL.md +92 -0
  22. package/template/container/skills/status/SKILL.md +104 -0
  23. package/template/container/tools/elastic_query.py +161 -0
  24. package/template/container/tools/gdrive_tool.py +185 -0
  25. package/template/container/tools/jira_tool.py +433 -0
  26. package/template/container/tools/slack_history_tool.py +144 -0
  27. package/template/container/tools/youtube_tool.py +174 -0
  28. package/template/docker-compose.yml +54 -0
  29. package/template/docs/how-it-works.md +496 -0
  30. package/template/eslint.config.js +32 -0
  31. package/template/groups/forge/CLAUDE.md +107 -0
  32. package/template/package-lock.json +5278 -0
  33. package/template/package.json +52 -0
  34. package/template/scripts/github-app-token.py +58 -0
  35. package/template/scripts/register-expense-agent.sh +121 -0
  36. package/template/scripts/run-migrations.ts +105 -0
  37. package/template/scripts/setup-onecli-secrets.sh +252 -0
  38. package/template/setup-agents.sh +142 -0
  39. package/template/src/channels/index.ts +13 -0
  40. package/template/src/channels/registry.test.ts +42 -0
  41. package/template/src/channels/registry.ts +28 -0
  42. package/template/src/channels/slack.test.ts +859 -0
  43. package/template/src/channels/slack.ts +373 -0
  44. package/template/src/claw-skill.test.ts +45 -0
  45. package/template/src/config.ts +94 -0
  46. package/template/src/container-runner.test.ts +221 -0
  47. package/template/src/container-runner.ts +1029 -0
  48. package/template/src/container-runtime.test.ts +149 -0
  49. package/template/src/container-runtime.ts +124 -0
  50. package/template/src/db-migration.test.ts +67 -0
  51. package/template/src/db.test.ts +484 -0
  52. package/template/src/db.ts +837 -0
  53. package/template/src/env.ts +42 -0
  54. package/template/src/formatting.test.ts +294 -0
  55. package/template/src/github-token.ts +48 -0
  56. package/template/src/google-token.ts +75 -0
  57. package/template/src/group-folder.test.ts +43 -0
  58. package/template/src/group-folder.ts +44 -0
  59. package/template/src/group-queue.test.ts +484 -0
  60. package/template/src/group-queue.ts +363 -0
  61. package/template/src/http-server.ts +343 -0
  62. package/template/src/index.ts +960 -0
  63. package/template/src/ipc-auth.test.ts +679 -0
  64. package/template/src/ipc.ts +548 -0
  65. package/template/src/logger.ts +16 -0
  66. package/template/src/mount-security.ts +421 -0
  67. package/template/src/network-policy.ts +119 -0
  68. package/template/src/remote-control.test.ts +397 -0
  69. package/template/src/remote-control.ts +224 -0
  70. package/template/src/router.ts +52 -0
  71. package/template/src/routing.test.ts +170 -0
  72. package/template/src/sender-allowlist.test.ts +216 -0
  73. package/template/src/sender-allowlist.ts +128 -0
  74. package/template/src/task-scheduler.test.ts +129 -0
  75. package/template/src/task-scheduler.ts +290 -0
  76. package/template/src/timezone.test.ts +73 -0
  77. package/template/src/timezone.ts +37 -0
  78. package/template/src/types.ts +114 -0
  79. package/template/src/worktree.ts +206 -0
  80. package/template/tsconfig.json +20 -0
@@ -0,0 +1,170 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+
3
+ import { _initTestDatabase, storeChatMetadata } from './db.js';
4
+ import { getAvailableGroups, _setRegisteredGroups } from './index.js';
5
+
6
+ beforeEach(() => {
7
+ _initTestDatabase();
8
+ _setRegisteredGroups({});
9
+ });
10
+
11
+ // --- JID ownership patterns ---
12
+
13
+ describe('JID ownership patterns', () => {
14
+ // These test the patterns that will become ownsJid() on the Channel interface
15
+
16
+ it('WhatsApp group JID: ends with @g.us', () => {
17
+ const jid = '12345678@g.us';
18
+ expect(jid.endsWith('@g.us')).toBe(true);
19
+ });
20
+
21
+ it('WhatsApp DM JID: ends with @s.whatsapp.net', () => {
22
+ const jid = '12345678@s.whatsapp.net';
23
+ expect(jid.endsWith('@s.whatsapp.net')).toBe(true);
24
+ });
25
+ });
26
+
27
+ // --- getAvailableGroups ---
28
+
29
+ describe('getAvailableGroups', () => {
30
+ it('returns only groups, excludes DMs', () => {
31
+ storeChatMetadata(
32
+ 'group1@g.us',
33
+ '2024-01-01T00:00:01.000Z',
34
+ 'Group 1',
35
+ 'whatsapp',
36
+ true,
37
+ );
38
+ storeChatMetadata(
39
+ 'user@s.whatsapp.net',
40
+ '2024-01-01T00:00:02.000Z',
41
+ 'User DM',
42
+ 'whatsapp',
43
+ false,
44
+ );
45
+ storeChatMetadata(
46
+ 'group2@g.us',
47
+ '2024-01-01T00:00:03.000Z',
48
+ 'Group 2',
49
+ 'whatsapp',
50
+ true,
51
+ );
52
+
53
+ const groups = getAvailableGroups();
54
+ expect(groups).toHaveLength(2);
55
+ expect(groups.map((g) => g.jid)).toContain('group1@g.us');
56
+ expect(groups.map((g) => g.jid)).toContain('group2@g.us');
57
+ expect(groups.map((g) => g.jid)).not.toContain('user@s.whatsapp.net');
58
+ });
59
+
60
+ it('excludes __group_sync__ sentinel', () => {
61
+ storeChatMetadata('__group_sync__', '2024-01-01T00:00:00.000Z');
62
+ storeChatMetadata(
63
+ 'group@g.us',
64
+ '2024-01-01T00:00:01.000Z',
65
+ 'Group',
66
+ 'whatsapp',
67
+ true,
68
+ );
69
+
70
+ const groups = getAvailableGroups();
71
+ expect(groups).toHaveLength(1);
72
+ expect(groups[0].jid).toBe('group@g.us');
73
+ });
74
+
75
+ it('marks registered groups correctly', () => {
76
+ storeChatMetadata(
77
+ 'reg@g.us',
78
+ '2024-01-01T00:00:01.000Z',
79
+ 'Registered',
80
+ 'whatsapp',
81
+ true,
82
+ );
83
+ storeChatMetadata(
84
+ 'unreg@g.us',
85
+ '2024-01-01T00:00:02.000Z',
86
+ 'Unregistered',
87
+ 'whatsapp',
88
+ true,
89
+ );
90
+
91
+ _setRegisteredGroups({
92
+ 'reg@g.us': {
93
+ name: 'Registered',
94
+ folder: 'registered',
95
+ trigger: '@Andy',
96
+ added_at: '2024-01-01T00:00:00.000Z',
97
+ },
98
+ });
99
+
100
+ const groups = getAvailableGroups();
101
+ const reg = groups.find((g) => g.jid === 'reg@g.us');
102
+ const unreg = groups.find((g) => g.jid === 'unreg@g.us');
103
+
104
+ expect(reg?.isRegistered).toBe(true);
105
+ expect(unreg?.isRegistered).toBe(false);
106
+ });
107
+
108
+ it('returns groups ordered by most recent activity', () => {
109
+ storeChatMetadata(
110
+ 'old@g.us',
111
+ '2024-01-01T00:00:01.000Z',
112
+ 'Old',
113
+ 'whatsapp',
114
+ true,
115
+ );
116
+ storeChatMetadata(
117
+ 'new@g.us',
118
+ '2024-01-01T00:00:05.000Z',
119
+ 'New',
120
+ 'whatsapp',
121
+ true,
122
+ );
123
+ storeChatMetadata(
124
+ 'mid@g.us',
125
+ '2024-01-01T00:00:03.000Z',
126
+ 'Mid',
127
+ 'whatsapp',
128
+ true,
129
+ );
130
+
131
+ const groups = getAvailableGroups();
132
+ expect(groups[0].jid).toBe('new@g.us');
133
+ expect(groups[1].jid).toBe('mid@g.us');
134
+ expect(groups[2].jid).toBe('old@g.us');
135
+ });
136
+
137
+ it('excludes non-group chats regardless of JID format', () => {
138
+ // Unknown JID format stored without is_group should not appear
139
+ storeChatMetadata(
140
+ 'unknown-format-123',
141
+ '2024-01-01T00:00:01.000Z',
142
+ 'Unknown',
143
+ );
144
+ // Explicitly non-group with unusual JID
145
+ storeChatMetadata(
146
+ 'custom:abc',
147
+ '2024-01-01T00:00:02.000Z',
148
+ 'Custom DM',
149
+ 'custom',
150
+ false,
151
+ );
152
+ // A real group for contrast
153
+ storeChatMetadata(
154
+ 'group@g.us',
155
+ '2024-01-01T00:00:03.000Z',
156
+ 'Group',
157
+ 'whatsapp',
158
+ true,
159
+ );
160
+
161
+ const groups = getAvailableGroups();
162
+ expect(groups).toHaveLength(1);
163
+ expect(groups[0].jid).toBe('group@g.us');
164
+ });
165
+
166
+ it('returns empty array when no chats exist', () => {
167
+ const groups = getAvailableGroups();
168
+ expect(groups).toHaveLength(0);
169
+ });
170
+ });
@@ -0,0 +1,216 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+
6
+ import {
7
+ isSenderAllowed,
8
+ isTriggerAllowed,
9
+ loadSenderAllowlist,
10
+ SenderAllowlistConfig,
11
+ shouldDropMessage,
12
+ } from './sender-allowlist.js';
13
+
14
+ let tmpDir: string;
15
+
16
+ function cfgPath(name = 'sender-allowlist.json'): string {
17
+ return path.join(tmpDir, name);
18
+ }
19
+
20
+ function writeConfig(config: unknown, name?: string): string {
21
+ const p = cfgPath(name);
22
+ fs.writeFileSync(p, JSON.stringify(config));
23
+ return p;
24
+ }
25
+
26
+ beforeEach(() => {
27
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'allowlist-test-'));
28
+ });
29
+
30
+ afterEach(() => {
31
+ fs.rmSync(tmpDir, { recursive: true, force: true });
32
+ });
33
+
34
+ describe('loadSenderAllowlist', () => {
35
+ it('returns allow-all defaults when file is missing', () => {
36
+ const cfg = loadSenderAllowlist(cfgPath());
37
+ expect(cfg.default.allow).toBe('*');
38
+ expect(cfg.default.mode).toBe('trigger');
39
+ expect(cfg.logDenied).toBe(true);
40
+ });
41
+
42
+ it('loads allow=* config', () => {
43
+ const p = writeConfig({
44
+ default: { allow: '*', mode: 'trigger' },
45
+ chats: {},
46
+ logDenied: false,
47
+ });
48
+ const cfg = loadSenderAllowlist(p);
49
+ expect(cfg.default.allow).toBe('*');
50
+ expect(cfg.logDenied).toBe(false);
51
+ });
52
+
53
+ it('loads allow=[] (deny all)', () => {
54
+ const p = writeConfig({
55
+ default: { allow: [], mode: 'trigger' },
56
+ chats: {},
57
+ });
58
+ const cfg = loadSenderAllowlist(p);
59
+ expect(cfg.default.allow).toEqual([]);
60
+ });
61
+
62
+ it('loads allow=[list]', () => {
63
+ const p = writeConfig({
64
+ default: { allow: ['alice', 'bob'], mode: 'drop' },
65
+ chats: {},
66
+ });
67
+ const cfg = loadSenderAllowlist(p);
68
+ expect(cfg.default.allow).toEqual(['alice', 'bob']);
69
+ expect(cfg.default.mode).toBe('drop');
70
+ });
71
+
72
+ it('per-chat override beats default', () => {
73
+ const p = writeConfig({
74
+ default: { allow: '*', mode: 'trigger' },
75
+ chats: { 'group-a': { allow: ['alice'], mode: 'drop' } },
76
+ });
77
+ const cfg = loadSenderAllowlist(p);
78
+ expect(cfg.chats['group-a'].allow).toEqual(['alice']);
79
+ expect(cfg.chats['group-a'].mode).toBe('drop');
80
+ });
81
+
82
+ it('returns allow-all on invalid JSON', () => {
83
+ const p = cfgPath();
84
+ fs.writeFileSync(p, '{ not valid json }}}');
85
+ const cfg = loadSenderAllowlist(p);
86
+ expect(cfg.default.allow).toBe('*');
87
+ });
88
+
89
+ it('returns allow-all on invalid schema', () => {
90
+ const p = writeConfig({ default: { oops: true } });
91
+ const cfg = loadSenderAllowlist(p);
92
+ expect(cfg.default.allow).toBe('*');
93
+ });
94
+
95
+ it('rejects non-string allow array items', () => {
96
+ const p = writeConfig({
97
+ default: { allow: [123, null, true], mode: 'trigger' },
98
+ chats: {},
99
+ });
100
+ const cfg = loadSenderAllowlist(p);
101
+ expect(cfg.default.allow).toBe('*'); // falls back to default
102
+ });
103
+
104
+ it('skips invalid per-chat entries', () => {
105
+ const p = writeConfig({
106
+ default: { allow: '*', mode: 'trigger' },
107
+ chats: {
108
+ good: { allow: ['alice'], mode: 'trigger' },
109
+ bad: { allow: 123 },
110
+ },
111
+ });
112
+ const cfg = loadSenderAllowlist(p);
113
+ expect(cfg.chats['good']).toBeDefined();
114
+ expect(cfg.chats['bad']).toBeUndefined();
115
+ });
116
+ });
117
+
118
+ describe('isSenderAllowed', () => {
119
+ it('allow=* allows any sender', () => {
120
+ const cfg: SenderAllowlistConfig = {
121
+ default: { allow: '*', mode: 'trigger' },
122
+ chats: {},
123
+ logDenied: true,
124
+ };
125
+ expect(isSenderAllowed('g1', 'anyone', cfg)).toBe(true);
126
+ });
127
+
128
+ it('allow=[] denies any sender', () => {
129
+ const cfg: SenderAllowlistConfig = {
130
+ default: { allow: [], mode: 'trigger' },
131
+ chats: {},
132
+ logDenied: true,
133
+ };
134
+ expect(isSenderAllowed('g1', 'anyone', cfg)).toBe(false);
135
+ });
136
+
137
+ it('allow=[list] allows exact match only', () => {
138
+ const cfg: SenderAllowlistConfig = {
139
+ default: { allow: ['alice', 'bob'], mode: 'trigger' },
140
+ chats: {},
141
+ logDenied: true,
142
+ };
143
+ expect(isSenderAllowed('g1', 'alice', cfg)).toBe(true);
144
+ expect(isSenderAllowed('g1', 'eve', cfg)).toBe(false);
145
+ });
146
+
147
+ it('uses per-chat entry over default', () => {
148
+ const cfg: SenderAllowlistConfig = {
149
+ default: { allow: '*', mode: 'trigger' },
150
+ chats: { g1: { allow: ['alice'], mode: 'trigger' } },
151
+ logDenied: true,
152
+ };
153
+ expect(isSenderAllowed('g1', 'bob', cfg)).toBe(false);
154
+ expect(isSenderAllowed('g2', 'bob', cfg)).toBe(true);
155
+ });
156
+ });
157
+
158
+ describe('shouldDropMessage', () => {
159
+ it('returns false for trigger mode', () => {
160
+ const cfg: SenderAllowlistConfig = {
161
+ default: { allow: '*', mode: 'trigger' },
162
+ chats: {},
163
+ logDenied: true,
164
+ };
165
+ expect(shouldDropMessage('g1', cfg)).toBe(false);
166
+ });
167
+
168
+ it('returns true for drop mode', () => {
169
+ const cfg: SenderAllowlistConfig = {
170
+ default: { allow: '*', mode: 'drop' },
171
+ chats: {},
172
+ logDenied: true,
173
+ };
174
+ expect(shouldDropMessage('g1', cfg)).toBe(true);
175
+ });
176
+
177
+ it('per-chat mode override', () => {
178
+ const cfg: SenderAllowlistConfig = {
179
+ default: { allow: '*', mode: 'trigger' },
180
+ chats: { g1: { allow: '*', mode: 'drop' } },
181
+ logDenied: true,
182
+ };
183
+ expect(shouldDropMessage('g1', cfg)).toBe(true);
184
+ expect(shouldDropMessage('g2', cfg)).toBe(false);
185
+ });
186
+ });
187
+
188
+ describe('isTriggerAllowed', () => {
189
+ it('allows trigger for allowed sender', () => {
190
+ const cfg: SenderAllowlistConfig = {
191
+ default: { allow: ['alice'], mode: 'trigger' },
192
+ chats: {},
193
+ logDenied: false,
194
+ };
195
+ expect(isTriggerAllowed('g1', 'alice', cfg)).toBe(true);
196
+ });
197
+
198
+ it('denies trigger for disallowed sender', () => {
199
+ const cfg: SenderAllowlistConfig = {
200
+ default: { allow: ['alice'], mode: 'trigger' },
201
+ chats: {},
202
+ logDenied: false,
203
+ };
204
+ expect(isTriggerAllowed('g1', 'eve', cfg)).toBe(false);
205
+ });
206
+
207
+ it('logs when logDenied is true', () => {
208
+ const cfg: SenderAllowlistConfig = {
209
+ default: { allow: ['alice'], mode: 'trigger' },
210
+ chats: {},
211
+ logDenied: true,
212
+ };
213
+ isTriggerAllowed('g1', 'eve', cfg);
214
+ // Logger.debug is called — we just verify no crash; logger is a real pino instance
215
+ });
216
+ });
@@ -0,0 +1,128 @@
1
+ import fs from 'fs';
2
+
3
+ import { SENDER_ALLOWLIST_PATH } from './config.js';
4
+ import { logger } from './logger.js';
5
+
6
+ export interface ChatAllowlistEntry {
7
+ allow: '*' | string[];
8
+ mode: 'trigger' | 'drop';
9
+ }
10
+
11
+ export interface SenderAllowlistConfig {
12
+ default: ChatAllowlistEntry;
13
+ chats: Record<string, ChatAllowlistEntry>;
14
+ logDenied: boolean;
15
+ }
16
+
17
+ const DEFAULT_CONFIG: SenderAllowlistConfig = {
18
+ default: { allow: '*', mode: 'trigger' },
19
+ chats: {},
20
+ logDenied: true,
21
+ };
22
+
23
+ function isValidEntry(entry: unknown): entry is ChatAllowlistEntry {
24
+ if (!entry || typeof entry !== 'object') return false;
25
+ const e = entry as Record<string, unknown>;
26
+ const validAllow =
27
+ e.allow === '*' ||
28
+ (Array.isArray(e.allow) && e.allow.every((v) => typeof v === 'string'));
29
+ const validMode = e.mode === 'trigger' || e.mode === 'drop';
30
+ return validAllow && validMode;
31
+ }
32
+
33
+ export function loadSenderAllowlist(
34
+ pathOverride?: string,
35
+ ): SenderAllowlistConfig {
36
+ const filePath = pathOverride ?? SENDER_ALLOWLIST_PATH;
37
+
38
+ let raw: string;
39
+ try {
40
+ raw = fs.readFileSync(filePath, 'utf-8');
41
+ } catch (err: unknown) {
42
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return DEFAULT_CONFIG;
43
+ logger.warn(
44
+ { err, path: filePath },
45
+ 'sender-allowlist: cannot read config',
46
+ );
47
+ return DEFAULT_CONFIG;
48
+ }
49
+
50
+ let parsed: unknown;
51
+ try {
52
+ parsed = JSON.parse(raw);
53
+ } catch {
54
+ logger.warn({ path: filePath }, 'sender-allowlist: invalid JSON');
55
+ return DEFAULT_CONFIG;
56
+ }
57
+
58
+ const obj = parsed as Record<string, unknown>;
59
+
60
+ if (!isValidEntry(obj.default)) {
61
+ logger.warn(
62
+ { path: filePath },
63
+ 'sender-allowlist: invalid or missing default entry',
64
+ );
65
+ return DEFAULT_CONFIG;
66
+ }
67
+
68
+ const chats: Record<string, ChatAllowlistEntry> = {};
69
+ if (obj.chats && typeof obj.chats === 'object') {
70
+ for (const [jid, entry] of Object.entries(
71
+ obj.chats as Record<string, unknown>,
72
+ )) {
73
+ if (isValidEntry(entry)) {
74
+ chats[jid] = entry;
75
+ } else {
76
+ logger.warn(
77
+ { jid, path: filePath },
78
+ 'sender-allowlist: skipping invalid chat entry',
79
+ );
80
+ }
81
+ }
82
+ }
83
+
84
+ return {
85
+ default: obj.default as ChatAllowlistEntry,
86
+ chats,
87
+ logDenied: obj.logDenied !== false,
88
+ };
89
+ }
90
+
91
+ function getEntry(
92
+ chatJid: string,
93
+ cfg: SenderAllowlistConfig,
94
+ ): ChatAllowlistEntry {
95
+ return cfg.chats[chatJid] ?? cfg.default;
96
+ }
97
+
98
+ export function isSenderAllowed(
99
+ chatJid: string,
100
+ sender: string,
101
+ cfg: SenderAllowlistConfig,
102
+ ): boolean {
103
+ const entry = getEntry(chatJid, cfg);
104
+ if (entry.allow === '*') return true;
105
+ return entry.allow.includes(sender);
106
+ }
107
+
108
+ export function shouldDropMessage(
109
+ chatJid: string,
110
+ cfg: SenderAllowlistConfig,
111
+ ): boolean {
112
+ return getEntry(chatJid, cfg).mode === 'drop';
113
+ }
114
+
115
+ export function isTriggerAllowed(
116
+ chatJid: string,
117
+ sender: string,
118
+ cfg: SenderAllowlistConfig,
119
+ ): boolean {
120
+ const allowed = isSenderAllowed(chatJid, sender, cfg);
121
+ if (!allowed && cfg.logDenied) {
122
+ logger.debug(
123
+ { chatJid, sender },
124
+ 'sender-allowlist: trigger denied for sender',
125
+ );
126
+ }
127
+ return allowed;
128
+ }
@@ -0,0 +1,129 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { _initTestDatabase, createTask, getTaskById } from './db.js';
4
+ import {
5
+ _resetSchedulerLoopForTests,
6
+ computeNextRun,
7
+ startSchedulerLoop,
8
+ } from './task-scheduler.js';
9
+
10
+ describe('task scheduler', () => {
11
+ beforeEach(() => {
12
+ _initTestDatabase();
13
+ _resetSchedulerLoopForTests();
14
+ vi.useFakeTimers();
15
+ });
16
+
17
+ afterEach(() => {
18
+ vi.useRealTimers();
19
+ });
20
+
21
+ it('pauses due tasks with invalid group folders to prevent retry churn', async () => {
22
+ createTask({
23
+ id: 'task-invalid-folder',
24
+ group_folder: '../../outside',
25
+ chat_jid: 'bad@g.us',
26
+ prompt: 'run',
27
+ schedule_type: 'once',
28
+ schedule_value: '2026-02-22T00:00:00.000Z',
29
+ context_mode: 'isolated',
30
+ next_run: new Date(Date.now() - 60_000).toISOString(),
31
+ status: 'active',
32
+ created_at: '2026-02-22T00:00:00.000Z',
33
+ });
34
+
35
+ const enqueueTask = vi.fn(
36
+ (_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
37
+ void fn();
38
+ },
39
+ );
40
+
41
+ startSchedulerLoop({
42
+ registeredGroups: () => ({}),
43
+ getSessions: () => ({}),
44
+ queue: { enqueueTask } as any,
45
+ onProcess: () => {},
46
+ sendMessage: async () => {},
47
+ });
48
+
49
+ await vi.advanceTimersByTimeAsync(10);
50
+
51
+ const task = getTaskById('task-invalid-folder');
52
+ expect(task?.status).toBe('paused');
53
+ });
54
+
55
+ it('computeNextRun anchors interval tasks to scheduled time to prevent drift', () => {
56
+ const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago
57
+ const task = {
58
+ id: 'drift-test',
59
+ group_folder: 'test',
60
+ chat_jid: 'test@g.us',
61
+ prompt: 'test',
62
+ schedule_type: 'interval' as const,
63
+ schedule_value: '60000', // 1 minute
64
+ context_mode: 'isolated' as const,
65
+ next_run: scheduledTime,
66
+ last_run: null,
67
+ last_result: null,
68
+ status: 'active' as const,
69
+ created_at: '2026-01-01T00:00:00.000Z',
70
+ };
71
+
72
+ const nextRun = computeNextRun(task);
73
+ expect(nextRun).not.toBeNull();
74
+
75
+ // Should be anchored to scheduledTime + 60s, NOT Date.now() + 60s
76
+ const expected = new Date(scheduledTime).getTime() + 60000;
77
+ expect(new Date(nextRun!).getTime()).toBe(expected);
78
+ });
79
+
80
+ it('computeNextRun returns null for once-tasks', () => {
81
+ const task = {
82
+ id: 'once-test',
83
+ group_folder: 'test',
84
+ chat_jid: 'test@g.us',
85
+ prompt: 'test',
86
+ schedule_type: 'once' as const,
87
+ schedule_value: '2026-01-01T00:00:00.000Z',
88
+ context_mode: 'isolated' as const,
89
+ next_run: new Date(Date.now() - 1000).toISOString(),
90
+ last_run: null,
91
+ last_result: null,
92
+ status: 'active' as const,
93
+ created_at: '2026-01-01T00:00:00.000Z',
94
+ };
95
+
96
+ expect(computeNextRun(task)).toBeNull();
97
+ });
98
+
99
+ it('computeNextRun skips missed intervals without infinite loop', () => {
100
+ // Task was due 10 intervals ago (missed)
101
+ const ms = 60000;
102
+ const missedBy = ms * 10;
103
+ const scheduledTime = new Date(Date.now() - missedBy).toISOString();
104
+
105
+ const task = {
106
+ id: 'skip-test',
107
+ group_folder: 'test',
108
+ chat_jid: 'test@g.us',
109
+ prompt: 'test',
110
+ schedule_type: 'interval' as const,
111
+ schedule_value: String(ms),
112
+ context_mode: 'isolated' as const,
113
+ next_run: scheduledTime,
114
+ last_run: null,
115
+ last_result: null,
116
+ status: 'active' as const,
117
+ created_at: '2026-01-01T00:00:00.000Z',
118
+ };
119
+
120
+ const nextRun = computeNextRun(task);
121
+ expect(nextRun).not.toBeNull();
122
+ // Must be in the future
123
+ expect(new Date(nextRun!).getTime()).toBeGreaterThan(Date.now());
124
+ // Must be aligned to the original schedule grid
125
+ const offset =
126
+ (new Date(nextRun!).getTime() - new Date(scheduledTime).getTime()) % ms;
127
+ expect(offset).toBe(0);
128
+ });
129
+ });