@rozek/nanoclaw 0.0.4 → 0.0.6

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 (132) hide show
  1. package/container/agent-runner/package-lock.json +1524 -0
  2. package/dist/cli.js +75 -4
  3. package/dist/cli.js.map +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +34 -0
  6. package/dist/index.js.map +1 -1
  7. package/package.json +7 -1
  8. package/.claude/settings.json +0 -1
  9. package/.claude/skills/add-compact/SKILL.md +0 -135
  10. package/.claude/skills/add-discord/SKILL.md +0 -203
  11. package/.claude/skills/add-gmail/SKILL.md +0 -220
  12. package/.claude/skills/add-image-vision/SKILL.md +0 -94
  13. package/.claude/skills/add-ollama-tool/SKILL.md +0 -153
  14. package/.claude/skills/add-parallel/SKILL.md +0 -290
  15. package/.claude/skills/add-pdf-reader/SKILL.md +0 -104
  16. package/.claude/skills/add-reactions/SKILL.md +0 -117
  17. package/.claude/skills/add-slack/SKILL.md +0 -207
  18. package/.claude/skills/add-telegram/SKILL.md +0 -222
  19. package/.claude/skills/add-telegram-swarm/SKILL.md +0 -384
  20. package/.claude/skills/add-voice-transcription/SKILL.md +0 -148
  21. package/.claude/skills/add-whatsapp/SKILL.md +0 -372
  22. package/.claude/skills/convert-to-apple-container/SKILL.md +0 -175
  23. package/.claude/skills/customize/SKILL.md +0 -110
  24. package/.claude/skills/debug/SKILL.md +0 -349
  25. package/.claude/skills/get-qodo-rules/SKILL.md +0 -122
  26. package/.claude/skills/get-qodo-rules/references/output-format.md +0 -41
  27. package/.claude/skills/get-qodo-rules/references/pagination.md +0 -33
  28. package/.claude/skills/get-qodo-rules/references/repository-scope.md +0 -26
  29. package/.claude/skills/qodo-pr-resolver/SKILL.md +0 -326
  30. package/.claude/skills/qodo-pr-resolver/resources/providers.md +0 -329
  31. package/.claude/skills/setup/SKILL.md +0 -218
  32. package/.claude/skills/update-nanoclaw/SKILL.md +0 -235
  33. package/.claude/skills/update-skills/SKILL.md +0 -130
  34. package/.claude/skills/use-local-whisper/SKILL.md +0 -152
  35. package/.claude/skills/x-integration/SKILL.md +0 -417
  36. package/.claude/skills/x-integration/agent.ts +0 -243
  37. package/.claude/skills/x-integration/host.ts +0 -159
  38. package/.claude/skills/x-integration/lib/browser.ts +0 -148
  39. package/.claude/skills/x-integration/lib/config.ts +0 -62
  40. package/.claude/skills/x-integration/scripts/like.ts +0 -56
  41. package/.claude/skills/x-integration/scripts/post.ts +0 -66
  42. package/.claude/skills/x-integration/scripts/quote.ts +0 -80
  43. package/.claude/skills/x-integration/scripts/reply.ts +0 -74
  44. package/.claude/skills/x-integration/scripts/retweet.ts +0 -62
  45. package/.claude/skills/x-integration/scripts/setup.ts +0 -87
  46. package/.env.example +0 -1
  47. package/.github/CODEOWNERS +0 -10
  48. package/.github/PULL_REQUEST_TEMPLATE.md +0 -14
  49. package/.github/workflows/bump-version.yml +0 -32
  50. package/.github/workflows/ci.yml +0 -25
  51. package/.github/workflows/merge-forward-skills.yml +0 -160
  52. package/.github/workflows/update-tokens.yml +0 -42
  53. package/.husky/pre-commit +0 -1
  54. package/.mcp.json +0 -3
  55. package/.nvmrc +0 -1
  56. package/.prettierrc +0 -3
  57. package/CHANGELOG.md +0 -8
  58. package/CONTRIBUTING.md +0 -23
  59. package/CONTRIBUTORS.md +0 -15
  60. package/NanoClaw_with_Web-Support.md +0 -325
  61. package/README_zh.md +0 -200
  62. package/assets/nanoclaw-favicon.png +0 -0
  63. package/assets/nanoclaw-icon.png +0 -0
  64. package/assets/nanoclaw-logo-dark.png +0 -0
  65. package/assets/nanoclaw-logo.png +0 -0
  66. package/assets/nanoclaw-profile.jpeg +0 -0
  67. package/assets/nanoclaw-sales.png +0 -0
  68. package/assets/social-preview.jpg +0 -0
  69. package/config-examples/mount-allowlist.json +0 -25
  70. package/docs/APPLE-CONTAINER-NETWORKING.md +0 -90
  71. package/docs/DEBUG_CHECKLIST.md +0 -143
  72. package/docs/REQUIREMENTS.md +0 -196
  73. package/docs/SDK_DEEP_DIVE.md +0 -643
  74. package/docs/SECURITY.md +0 -122
  75. package/docs/SPEC.md +0 -785
  76. package/docs/docker-sandboxes.md +0 -359
  77. package/docs/nanoclaw-architecture-final.md +0 -1063
  78. package/docs/nanorepo-architecture.md +0 -168
  79. package/docs/skills-as-branches.md +0 -662
  80. package/groups/global/CLAUDE.md +0 -58
  81. package/groups/main/CLAUDE.md +0 -246
  82. package/launchd/com.nanoclaw.plist +0 -32
  83. package/repo-tokens/README.md +0 -113
  84. package/repo-tokens/action.yml +0 -186
  85. package/repo-tokens/badge.svg +0 -23
  86. package/repo-tokens/examples/green.svg +0 -14
  87. package/repo-tokens/examples/red.svg +0 -14
  88. package/repo-tokens/examples/yellow-green.svg +0 -14
  89. package/repo-tokens/examples/yellow.svg +0 -14
  90. package/scripts/run-migrations.ts +0 -105
  91. package/setup.sh +0 -161
  92. package/src/channels/index.ts +0 -15
  93. package/src/channels/registry.test.ts +0 -42
  94. package/src/channels/registry.ts +0 -32
  95. package/src/channels/web.ts +0 -1931
  96. package/src/cli.ts +0 -210
  97. package/src/config.ts +0 -73
  98. package/src/container-runner.test.ts +0 -210
  99. package/src/container-runner.ts +0 -768
  100. package/src/container-runtime.test.ts +0 -149
  101. package/src/container-runtime.ts +0 -127
  102. package/src/credential-proxy.test.ts +0 -192
  103. package/src/credential-proxy.ts +0 -125
  104. package/src/db.test.ts +0 -484
  105. package/src/db.ts +0 -803
  106. package/src/env.ts +0 -42
  107. package/src/formatting.test.ts +0 -256
  108. package/src/group-folder.test.ts +0 -43
  109. package/src/group-folder.ts +0 -44
  110. package/src/group-queue.test.ts +0 -484
  111. package/src/group-queue.ts +0 -379
  112. package/src/index.ts +0 -854
  113. package/src/ipc-auth.test.ts +0 -679
  114. package/src/ipc.ts +0 -461
  115. package/src/logger.ts +0 -16
  116. package/src/mount-security.ts +0 -419
  117. package/src/remote-control.test.ts +0 -397
  118. package/src/remote-control.ts +0 -224
  119. package/src/router.ts +0 -52
  120. package/src/routing.test.ts +0 -170
  121. package/src/sender-allowlist.test.ts +0 -216
  122. package/src/sender-allowlist.ts +0 -128
  123. package/src/session-commands.test.ts +0 -247
  124. package/src/session-commands.ts +0 -163
  125. package/src/task-scheduler.test.ts +0 -129
  126. package/src/task-scheduler.ts +0 -328
  127. package/src/timezone.test.ts +0 -29
  128. package/src/timezone.ts +0 -16
  129. package/src/types.ts +0 -109
  130. package/tsconfig.json +0 -20
  131. package/vitest.config.ts +0 -7
  132. package/vitest.skills.config.ts +0 -7
@@ -1,247 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import {
3
- extractSessionCommand,
4
- handleSessionCommand,
5
- isSessionCommandAllowed,
6
- } from './session-commands.js';
7
- import type { NewMessage } from './types.js';
8
- import type { SessionCommandDeps } from './session-commands.js';
9
-
10
- describe('extractSessionCommand', () => {
11
- const trigger = /^@Andy\b/i;
12
-
13
- it('detects bare /compact', () => {
14
- expect(extractSessionCommand('/compact', trigger)).toBe('/compact');
15
- });
16
-
17
- it('detects /compact with trigger prefix', () => {
18
- expect(extractSessionCommand('@Andy /compact', trigger)).toBe('/compact');
19
- });
20
-
21
- it('rejects /compact with extra text', () => {
22
- expect(extractSessionCommand('/compact now please', trigger)).toBeNull();
23
- });
24
-
25
- it('rejects partial matches', () => {
26
- expect(extractSessionCommand('/compaction', trigger)).toBeNull();
27
- });
28
-
29
- it('rejects regular messages', () => {
30
- expect(
31
- extractSessionCommand('please compact the conversation', trigger),
32
- ).toBeNull();
33
- });
34
-
35
- it('handles whitespace', () => {
36
- expect(extractSessionCommand(' /compact ', trigger)).toBe('/compact');
37
- });
38
-
39
- it('is case-sensitive for the command', () => {
40
- expect(extractSessionCommand('/Compact', trigger)).toBeNull();
41
- });
42
- });
43
-
44
- describe('isSessionCommandAllowed', () => {
45
- it('allows main group regardless of sender', () => {
46
- expect(isSessionCommandAllowed(true, false)).toBe(true);
47
- });
48
-
49
- it('allows trusted/admin sender (is_from_me) in non-main group', () => {
50
- expect(isSessionCommandAllowed(false, true)).toBe(true);
51
- });
52
-
53
- it('denies untrusted sender in non-main group', () => {
54
- expect(isSessionCommandAllowed(false, false)).toBe(false);
55
- });
56
-
57
- it('allows trusted sender in main group', () => {
58
- expect(isSessionCommandAllowed(true, true)).toBe(true);
59
- });
60
- });
61
-
62
- function makeMsg(
63
- content: string,
64
- overrides: Partial<NewMessage> = {},
65
- ): NewMessage {
66
- return {
67
- id: 'msg-1',
68
- chat_jid: 'group@test',
69
- sender: 'user@test',
70
- sender_name: 'User',
71
- content,
72
- timestamp: '100',
73
- ...overrides,
74
- };
75
- }
76
-
77
- function makeDeps(
78
- overrides: Partial<SessionCommandDeps> = {},
79
- ): SessionCommandDeps {
80
- return {
81
- sendMessage: vi.fn().mockResolvedValue(undefined),
82
- setTyping: vi.fn().mockResolvedValue(undefined),
83
- runAgent: vi.fn().mockResolvedValue('success'),
84
- closeStdin: vi.fn(),
85
- advanceCursor: vi.fn(),
86
- formatMessages: vi.fn().mockReturnValue('<formatted>'),
87
- canSenderInteract: vi.fn().mockReturnValue(true),
88
- ...overrides,
89
- };
90
- }
91
-
92
- const trigger = /^@Andy\b/i;
93
-
94
- describe('handleSessionCommand', () => {
95
- it('returns handled:false when no session command found', async () => {
96
- const deps = makeDeps();
97
- const result = await handleSessionCommand({
98
- missedMessages: [makeMsg('hello')],
99
- isMainGroup: true,
100
- groupName: 'test',
101
- triggerPattern: trigger,
102
- timezone: 'UTC',
103
- deps,
104
- });
105
- expect(result.handled).toBe(false);
106
- });
107
-
108
- it('handles authorized /compact in main group', async () => {
109
- const deps = makeDeps();
110
- const result = await handleSessionCommand({
111
- missedMessages: [makeMsg('/compact')],
112
- isMainGroup: true,
113
- groupName: 'test',
114
- triggerPattern: trigger,
115
- timezone: 'UTC',
116
- deps,
117
- });
118
- expect(result).toEqual({ handled: true, success: true });
119
- expect(deps.runAgent).toHaveBeenCalledWith(
120
- '/compact',
121
- expect.any(Function),
122
- );
123
- expect(deps.advanceCursor).toHaveBeenCalledWith('100');
124
- });
125
-
126
- it('sends denial to interactable sender in non-main group', async () => {
127
- const deps = makeDeps();
128
- const result = await handleSessionCommand({
129
- missedMessages: [makeMsg('/compact', { is_from_me: false })],
130
- isMainGroup: false,
131
- groupName: 'test',
132
- triggerPattern: trigger,
133
- timezone: 'UTC',
134
- deps,
135
- });
136
- expect(result).toEqual({ handled: true, success: true });
137
- expect(deps.sendMessage).toHaveBeenCalledWith(
138
- 'Session commands require admin access.',
139
- );
140
- expect(deps.runAgent).not.toHaveBeenCalled();
141
- expect(deps.advanceCursor).toHaveBeenCalledWith('100');
142
- });
143
-
144
- it('silently consumes denied command when sender cannot interact', async () => {
145
- const deps = makeDeps({
146
- canSenderInteract: vi.fn().mockReturnValue(false),
147
- });
148
- const result = await handleSessionCommand({
149
- missedMessages: [makeMsg('/compact', { is_from_me: false })],
150
- isMainGroup: false,
151
- groupName: 'test',
152
- triggerPattern: trigger,
153
- timezone: 'UTC',
154
- deps,
155
- });
156
- expect(result).toEqual({ handled: true, success: true });
157
- expect(deps.sendMessage).not.toHaveBeenCalled();
158
- expect(deps.advanceCursor).toHaveBeenCalledWith('100');
159
- });
160
-
161
- it('processes pre-compact messages before /compact', async () => {
162
- const deps = makeDeps();
163
- const msgs = [
164
- makeMsg('summarize this', { timestamp: '99' }),
165
- makeMsg('/compact', { timestamp: '100' }),
166
- ];
167
- const result = await handleSessionCommand({
168
- missedMessages: msgs,
169
- isMainGroup: true,
170
- groupName: 'test',
171
- triggerPattern: trigger,
172
- timezone: 'UTC',
173
- deps,
174
- });
175
- expect(result).toEqual({ handled: true, success: true });
176
- expect(deps.formatMessages).toHaveBeenCalledWith([msgs[0]], 'UTC');
177
- // Two runAgent calls: pre-compact + /compact
178
- expect(deps.runAgent).toHaveBeenCalledTimes(2);
179
- expect(deps.runAgent).toHaveBeenCalledWith(
180
- '<formatted>',
181
- expect.any(Function),
182
- );
183
- expect(deps.runAgent).toHaveBeenCalledWith(
184
- '/compact',
185
- expect.any(Function),
186
- );
187
- });
188
-
189
- it('allows is_from_me sender in non-main group', async () => {
190
- const deps = makeDeps();
191
- const result = await handleSessionCommand({
192
- missedMessages: [makeMsg('/compact', { is_from_me: true })],
193
- isMainGroup: false,
194
- groupName: 'test',
195
- triggerPattern: trigger,
196
- timezone: 'UTC',
197
- deps,
198
- });
199
- expect(result).toEqual({ handled: true, success: true });
200
- expect(deps.runAgent).toHaveBeenCalledWith(
201
- '/compact',
202
- expect.any(Function),
203
- );
204
- });
205
-
206
- it('reports failure when command-stage runAgent returns error without streamed status', async () => {
207
- // runAgent resolves 'error' but callback never gets status: 'error'
208
- const deps = makeDeps({
209
- runAgent: vi.fn().mockImplementation(async (prompt, onOutput) => {
210
- await onOutput({ status: 'success', result: null });
211
- return 'error';
212
- }),
213
- });
214
- const result = await handleSessionCommand({
215
- missedMessages: [makeMsg('/compact')],
216
- isMainGroup: true,
217
- groupName: 'test',
218
- triggerPattern: trigger,
219
- timezone: 'UTC',
220
- deps,
221
- });
222
- expect(result).toEqual({ handled: true, success: true });
223
- expect(deps.sendMessage).toHaveBeenCalledWith(
224
- expect.stringContaining('failed'),
225
- );
226
- });
227
-
228
- it('returns success:false on pre-compact failure with no output', async () => {
229
- const deps = makeDeps({ runAgent: vi.fn().mockResolvedValue('error') });
230
- const msgs = [
231
- makeMsg('summarize this', { timestamp: '99' }),
232
- makeMsg('/compact', { timestamp: '100' }),
233
- ];
234
- const result = await handleSessionCommand({
235
- missedMessages: msgs,
236
- isMainGroup: true,
237
- groupName: 'test',
238
- triggerPattern: trigger,
239
- timezone: 'UTC',
240
- deps,
241
- });
242
- expect(result).toEqual({ handled: true, success: false });
243
- expect(deps.sendMessage).toHaveBeenCalledWith(
244
- expect.stringContaining('Failed to process'),
245
- );
246
- });
247
- });
@@ -1,163 +0,0 @@
1
- import type { NewMessage } from './types.js';
2
- import { logger } from './logger.js';
3
-
4
- /**
5
- * Extract a session slash command from a message, stripping the trigger prefix if present.
6
- * Returns the slash command (e.g., '/compact') or null if not a session command.
7
- */
8
- export function extractSessionCommand(
9
- content: string,
10
- triggerPattern: RegExp,
11
- ): string | null {
12
- let text = content.trim();
13
- text = text.replace(triggerPattern, '').trim();
14
- if (text === '/compact') return '/compact';
15
- return null;
16
- }
17
-
18
- /**
19
- * Check if a session command sender is authorized.
20
- * Allowed: main group (any sender), or trusted/admin sender (is_from_me) in any group.
21
- */
22
- export function isSessionCommandAllowed(
23
- isMainGroup: boolean,
24
- isFromMe: boolean,
25
- ): boolean {
26
- return isMainGroup || isFromMe;
27
- }
28
-
29
- /** Minimal agent result interface — matches the subset of ContainerOutput used here. */
30
- export interface AgentResult {
31
- status: 'success' | 'error';
32
- result?: string | object | null;
33
- }
34
-
35
- /** Dependencies injected by the orchestrator. */
36
- export interface SessionCommandDeps {
37
- sendMessage: (text: string) => Promise<void>;
38
- setTyping: (typing: boolean) => Promise<void>;
39
- runAgent: (
40
- prompt: string,
41
- onOutput: (result: AgentResult) => Promise<void>,
42
- ) => Promise<'success' | 'error'>;
43
- closeStdin: () => void;
44
- advanceCursor: (timestamp: string) => void;
45
- formatMessages: (msgs: NewMessage[], timezone: string) => string;
46
- /** Whether the denied sender would normally be allowed to interact (for denial messages). */
47
- canSenderInteract: (msg: NewMessage) => boolean;
48
- }
49
-
50
- function resultToText(result: string | object | null | undefined): string {
51
- if (!result) return '';
52
- const raw = typeof result === 'string' ? result : JSON.stringify(result);
53
- return raw.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
54
- }
55
-
56
- /**
57
- * Handle session command interception in processGroupMessages.
58
- * Scans messages for a session command, handles auth + execution.
59
- * Returns { handled: true, success } if a command was found; { handled: false } otherwise.
60
- * success=false means the caller should retry (cursor was not advanced).
61
- */
62
- export async function handleSessionCommand(opts: {
63
- missedMessages: NewMessage[];
64
- isMainGroup: boolean;
65
- groupName: string;
66
- triggerPattern: RegExp;
67
- timezone: string;
68
- deps: SessionCommandDeps;
69
- }): Promise<{ handled: false } | { handled: true; success: boolean }> {
70
- const {
71
- missedMessages,
72
- isMainGroup,
73
- groupName,
74
- triggerPattern,
75
- timezone,
76
- deps,
77
- } = opts;
78
-
79
- const cmdMsg = missedMessages.find(
80
- (m) => extractSessionCommand(m.content, triggerPattern) !== null,
81
- );
82
- const command = cmdMsg
83
- ? extractSessionCommand(cmdMsg.content, triggerPattern)
84
- : null;
85
-
86
- if (!command || !cmdMsg) return { handled: false };
87
-
88
- if (!isSessionCommandAllowed(isMainGroup, cmdMsg.is_from_me === true)) {
89
- // DENIED: send denial if the sender would normally be allowed to interact,
90
- // then silently consume the command by advancing the cursor past it.
91
- // Trade-off: other messages in the same batch are also consumed (cursor is
92
- // a high-water mark). Acceptable for this narrow edge case.
93
- if (deps.canSenderInteract(cmdMsg)) {
94
- await deps.sendMessage('Session commands require admin access.');
95
- }
96
- deps.advanceCursor(cmdMsg.timestamp);
97
- return { handled: true, success: true };
98
- }
99
-
100
- // AUTHORIZED: process pre-compact messages first, then run the command
101
- logger.info({ group: groupName, command }, 'Session command');
102
-
103
- const cmdIndex = missedMessages.indexOf(cmdMsg);
104
- const preCompactMsgs = missedMessages.slice(0, cmdIndex);
105
-
106
- // Send pre-compact messages to the agent so they're in the session context.
107
- if (preCompactMsgs.length > 0) {
108
- const prePrompt = deps.formatMessages(preCompactMsgs, timezone);
109
- let hadPreError = false;
110
- let preOutputSent = false;
111
-
112
- const preResult = await deps.runAgent(prePrompt, async (result) => {
113
- if (result.status === 'error') hadPreError = true;
114
- const text = resultToText(result.result);
115
- if (text) {
116
- await deps.sendMessage(text);
117
- preOutputSent = true;
118
- }
119
- // Close stdin on session-update marker — emitted after query completes,
120
- // so all results (including multi-result runs) are already written.
121
- if (result.status === 'success' && result.result === null) {
122
- deps.closeStdin();
123
- }
124
- });
125
-
126
- if (preResult === 'error' || hadPreError) {
127
- logger.warn(
128
- { group: groupName },
129
- 'Pre-compact processing failed, aborting session command',
130
- );
131
- await deps.sendMessage(
132
- `Failed to process messages before ${command}. Try again.`,
133
- );
134
- if (preOutputSent) {
135
- // Output was already sent — don't retry or it will duplicate.
136
- // Advance cursor past pre-compact messages, leave command pending.
137
- deps.advanceCursor(preCompactMsgs[preCompactMsgs.length - 1].timestamp);
138
- return { handled: true, success: true };
139
- }
140
- return { handled: true, success: false };
141
- }
142
- }
143
-
144
- // Forward the literal slash command as the prompt (no XML formatting)
145
- await deps.setTyping(true);
146
-
147
- let hadCmdError = false;
148
- const cmdOutput = await deps.runAgent(command, async (result) => {
149
- if (result.status === 'error') hadCmdError = true;
150
- const text = resultToText(result.result);
151
- if (text) await deps.sendMessage(text);
152
- });
153
-
154
- // Advance cursor to the command — messages AFTER it remain pending for next poll.
155
- deps.advanceCursor(cmdMsg.timestamp);
156
- await deps.setTyping(false);
157
-
158
- if (cmdOutput === 'error' || hadCmdError) {
159
- await deps.sendMessage(`${command} failed. The session is unchanged.`);
160
- }
161
-
162
- return { handled: true, success: true };
163
- }
@@ -1,129 +0,0 @@
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
- });