companionbot 0.5.0 → 0.7.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/README.md CHANGED
@@ -29,12 +29,21 @@
29
29
  - **일회성 예약** - "내일 오전 9시에 알려줘"
30
30
  - **반복 작업** - "30분마다 주식 가격 확인해줘"
31
31
 
32
- ### 🤖 고급 기능 (v0.3.0)
32
+ ### 🤖 고급 기능 (v0.3.0+)
33
33
  - **서브 에이전트** - 복잡한 작업을 백그라운드에서 처리
34
34
  - **백그라운드 실행** - 긴 명령어를 백그라운드에서 실행하고 결과 확인
35
35
  - **파일 시스템** - 워크스페이스 내 파일 읽기/쓰기/편집
36
36
  - **일일 메모리** - 대화 내용 자동 저장
37
37
 
38
+ ### 🧠 시맨틱 메모리 (v0.6.0)
39
+ - **벡터 검색** - 임베딩 기반 관련 메모리 검색
40
+ - **자동 인덱싱** - 메모리 파일 자동 청크 분할 및 임베딩
41
+ - **유사도 매칭** - 의미 기반으로 관련 대화 기록 찾기
42
+
43
+ ### 🔧 시스템 (v0.6.0)
44
+ - **헬스 체크** - 봇 상태 모니터링 (uptime, 메시지 수, 오류 수)
45
+ - **업데이트 알림** - 새 버전 출시 시 자동 알림
46
+
38
47
  ## 설치
39
48
 
40
49
  ### 사전 준비
@@ -273,7 +282,22 @@ npm test # 테스트 실행
273
282
 
274
283
  ## 버전 히스토리
275
284
 
276
- ### v0.3.0 (현재)
285
+ ### v0.6.0 (현재)
286
+ - 🧠 시맨틱 메모리 검색 (임베딩 기반)
287
+ - 💚 헬스 체크 모니터링
288
+ - 🔄 자동 업데이트 알림
289
+
290
+ ### v0.5.0
291
+ - 🔒 보안 강화 (SSRF 방지, 경로 검증)
292
+ - ✅ 테스트 추가 (vitest)
293
+ - 📖 문서 개선
294
+
295
+ ### v0.4.0
296
+ - 🛡️ 보안 강화 (TOCTOU, 심볼릭 링크 검증)
297
+ - 🧹 세션 자동 정리
298
+ - 🔧 환경변수 기반 경로 설정
299
+
300
+ ### v0.3.0
277
301
  - 🔍 웹 검색 (Brave Search API)
278
302
  - 🕐 Cron 스케줄링 (한국어 지원)
279
303
  - 🤖 서브 에이전트 (백그라운드 작업)
@@ -1,4 +1,4 @@
1
1
  /**
2
2
  * Sub-agent 시스템 - 복잡한 작업을 독립적인 agent에게 위임
3
3
  */
4
- export { setAgentBot, spawnAgent, listAgents, cancelAgent, getAgent, abortAllAgents, stopCleanup } from "./manager.js";
4
+ export { setAgentBot, spawnAgent, listAgents, cancelAgent, getAgent } from "./manager.js";
@@ -198,14 +198,6 @@ export function cleanupOldAgents() {
198
198
  // running 상태도 1시간 지나면 정리 (stuck agent 방지)
199
199
  if (agent.status === "running" && agent.createdAt.getTime() < oneHourAgo) {
200
200
  console.log(`[Agent ${id}] Cleaning up stuck agent (running > 1h)`);
201
- // 실행 중인 API 호출 취소
202
- const controller = abortControllers.get(id);
203
- if (controller) {
204
- controller.abort();
205
- abortControllers.delete(id);
206
- }
207
- agent.status = "cancelled";
208
- agent.completedAt = new Date();
209
201
  agents.delete(id);
210
202
  }
211
203
  }
@@ -231,27 +223,5 @@ export function stopCleanup() {
231
223
  console.log("[AgentManager] Cleanup interval stopped");
232
224
  }
233
225
  }
234
- /**
235
- * 모든 진행 중인 agent abort 및 정리 (shutdown 시 사용)
236
- */
237
- export function abortAllAgents() {
238
- console.log("[AgentManager] Aborting all running agents...");
239
- // 모든 AbortController abort
240
- for (const [id, controller] of abortControllers) {
241
- console.log(`[Agent ${id}] Aborting`);
242
- controller.abort();
243
- }
244
- abortControllers.clear();
245
- // 모든 running agent 상태 업데이트
246
- for (const [id, agent] of agents) {
247
- if (agent.status === "running") {
248
- agent.status = "cancelled";
249
- agent.completedAt = new Date();
250
- }
251
- }
252
- // cleanup interval 중지
253
- stopCleanup();
254
- console.log("[AgentManager] All agents aborted");
255
- }
256
226
  // 자동 시작
257
227
  startCleanup();
package/dist/ai/claude.js CHANGED
@@ -1,5 +1,48 @@
1
- import Anthropic from "@anthropic-ai/sdk";
1
+ import Anthropic, { APIError } from "@anthropic-ai/sdk";
2
2
  import { tools, executeTool } from "../tools/index.js";
3
+ import { sleep } from "../utils/time.js";
4
+ import { MAX_RETRIES, BASE_RETRY_DELAY_MS } from "../utils/constants.js";
5
+ async function withRetry(fn, retries = MAX_RETRIES) {
6
+ let lastError = null;
7
+ for (let attempt = 0; attempt < retries; attempt++) {
8
+ try {
9
+ return await fn();
10
+ }
11
+ catch (error) {
12
+ // APIError 타입 체크
13
+ if (error instanceof APIError) {
14
+ lastError = error;
15
+ // Rate limit (429)
16
+ if (error.status === 429) {
17
+ const retryAfter = error.headers?.["retry-after"];
18
+ const delay = retryAfter
19
+ ? parseInt(retryAfter) * 1000
20
+ : BASE_RETRY_DELAY_MS * Math.pow(2, attempt);
21
+ console.log(`[RateLimit] 429 received, waiting ${delay}ms (attempt ${attempt + 1}/${retries})`);
22
+ await sleep(delay);
23
+ continue;
24
+ }
25
+ // 서버 에러 (500+)
26
+ if (error.status >= 500) {
27
+ const delay = BASE_RETRY_DELAY_MS * Math.pow(2, attempt);
28
+ console.log(`[ServerError] ${error.status}, waiting ${delay}ms (attempt ${attempt + 1}/${retries})`);
29
+ await sleep(delay);
30
+ continue;
31
+ }
32
+ }
33
+ // 일반 Error 처리
34
+ if (error instanceof Error) {
35
+ lastError = error;
36
+ }
37
+ else {
38
+ lastError = new Error(String(error));
39
+ }
40
+ // 다른 에러는 바로 throw
41
+ throw error;
42
+ }
43
+ }
44
+ throw lastError;
45
+ }
3
46
  let anthropic = null;
4
47
  function getClient() {
5
48
  if (!anthropic) {
@@ -7,41 +50,58 @@ function getClient() {
7
50
  }
8
51
  return anthropic;
9
52
  }
53
+ // 모델별 max_tokens 및 thinking budget 설정
54
+ // 참고: Claude API에서 thinking + output이 모델 한도 초과하면 안 됨
10
55
  export const MODELS = {
11
- sonnet: { id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4" },
12
- opus: { id: "claude-opus-4-20250514", name: "Claude Opus 4" },
13
- haiku: { id: "claude-haiku-3-5-20241022", name: "Claude Haiku 3.5" },
56
+ haiku: {
57
+ id: "claude-haiku-3-5-20241022",
58
+ name: "Claude Haiku 3.5",
59
+ maxTokens: 4096, // 빠른 응답
60
+ thinkingBudget: 0, // Haiku는 thinking 미지원
61
+ },
62
+ sonnet: {
63
+ id: "claude-sonnet-4-20250514",
64
+ name: "Claude Sonnet 4",
65
+ maxTokens: 8192, // 일반 작업
66
+ thinkingBudget: 10000, // 적당한 thinking
67
+ },
68
+ opus: {
69
+ id: "claude-opus-4-20250514",
70
+ name: "Claude Opus 4",
71
+ maxTokens: 16384, // 복잡한 작업
72
+ thinkingBudget: 32000, // 깊은 thinking
73
+ },
14
74
  };
15
- export async function chat(messages, systemPrompt, modelId = "sonnet", options) {
75
+ export async function chat(messages, systemPrompt, modelId = "sonnet") {
16
76
  const client = getClient();
17
- const model = MODELS[modelId].id;
18
- const signal = options?.signal;
77
+ const modelConfig = MODELS[modelId];
19
78
  // 메시지를 API 형식으로 변환
20
79
  const apiMessages = messages.map((m) => ({
21
80
  role: m.role,
22
81
  content: m.content,
23
82
  }));
24
- let response;
25
- try {
26
- response = await client.messages.create({
27
- model,
28
- max_tokens: 4096,
29
- system: systemPrompt,
83
+ // API 요청 파라미터 빌드 (도구 루프에서도 동일하게 사용)
84
+ const buildRequestParams = () => {
85
+ const params = {
86
+ model: modelConfig.id,
87
+ max_tokens: modelConfig.maxTokens,
30
88
  messages: apiMessages,
31
89
  tools: tools,
32
- }, { signal });
33
- }
34
- catch (error) {
35
- if (error instanceof Anthropic.APIError) {
36
- if (error.status === 429) {
37
- throw new Error("API 요청이 너무 많아. 잠시 후 다시 시도해줘.");
38
- }
39
- if (error.status >= 500) {
40
- throw new Error("AI 서버에 문제가 생겼어. 잠시 후 다시 시도해줘.");
41
- }
90
+ };
91
+ if (systemPrompt) {
92
+ params.system = systemPrompt;
42
93
  }
43
- throw error;
44
- }
94
+ // thinking 활성화 (budget > 0인 경우)
95
+ if (modelConfig.thinkingBudget > 0) {
96
+ params.thinking = {
97
+ type: "enabled",
98
+ budget_tokens: modelConfig.thinkingBudget,
99
+ };
100
+ }
101
+ return params;
102
+ };
103
+ let response;
104
+ response = await withRetry(() => client.messages.create(buildRequestParams()));
45
105
  // Tool use 루프 - Claude가 도구 사용을 멈출 때까지 반복 (최대 10회)
46
106
  const MAX_TOOL_ITERATIONS = 10;
47
107
  let iterations = 0;
@@ -72,27 +132,8 @@ export async function chat(messages, systemPrompt, modelId = "sonnet", options)
72
132
  role: "user",
73
133
  content: toolResults,
74
134
  });
75
- // 다음 응답 요청
76
- try {
77
- response = await client.messages.create({
78
- model,
79
- max_tokens: 4096,
80
- system: systemPrompt,
81
- messages: apiMessages,
82
- tools: tools,
83
- }, { signal });
84
- }
85
- catch (error) {
86
- if (error instanceof Anthropic.APIError) {
87
- if (error.status === 429) {
88
- throw new Error("API 요청이 너무 많아. 잠시 후 다시 시도해줘.");
89
- }
90
- if (error.status >= 500) {
91
- throw new Error("AI 서버에 문제가 생겼어. 잠시 후 다시 시도해줘.");
92
- }
93
- }
94
- throw error;
95
- }
135
+ // 다음 응답 요청 (도구 루프에서도 thinking 유지)
136
+ response = await withRetry(() => client.messages.create(buildRequestParams()));
96
137
  }
97
138
  // 반복 횟수 초과 시 경고
98
139
  if (iterations >= MAX_TOOL_ITERATIONS) {
@@ -103,3 +144,67 @@ export async function chat(messages, systemPrompt, modelId = "sonnet", options)
103
144
  const textBlock = response.content.find((block) => block.type === "text");
104
145
  return textBlock?.text ?? "응답을 생성하지 못했어. 다시 시도해줄래?";
105
146
  }
147
+ /**
148
+ * 스마트 채팅 - 가능하면 스트리밍, 도구 필요하면 일반 호출
149
+ *
150
+ * 전략:
151
+ * - 먼저 스트리밍으로 시도
152
+ * - 도구 호출이 감지되면 (stop_reason === "tool_use") 기존 chat()으로 폴백
153
+ * - 스트리밍은 최종 텍스트 응답에만 사용
154
+ */
155
+ export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
156
+ // 스트리밍 콜백이 없으면 그냥 일반 chat 사용
157
+ if (!onChunk) {
158
+ const text = await chat(messages, systemPrompt, modelId);
159
+ return { text, usedTools: false };
160
+ }
161
+ const client = getClient();
162
+ const modelConfig = MODELS[modelId];
163
+ // 메시지를 API 형식으로 변환
164
+ const apiMessages = messages.map((m) => ({
165
+ role: m.role,
166
+ content: m.content,
167
+ }));
168
+ // 스트리밍 요청 파라미터
169
+ const params = {
170
+ model: modelConfig.id,
171
+ max_tokens: modelConfig.maxTokens,
172
+ messages: apiMessages,
173
+ tools: tools,
174
+ stream: true,
175
+ };
176
+ if (systemPrompt) {
177
+ params.system = systemPrompt;
178
+ }
179
+ // Thinking은 스트리밍에서 복잡해지므로 일단 비활성화
180
+ // (도구 호출 폴백 시 chat()에서 thinking 사용됨)
181
+ let accumulated = "";
182
+ let stopReason = null;
183
+ // 스트리밍에 withRetry 적용 - 실패 시 자동 재시도
184
+ return await withRetry(async () => {
185
+ accumulated = ""; // 재시도 시 초기화
186
+ const stream = client.messages.stream(params);
187
+ // 스트리밍 이벤트 처리
188
+ stream.on("text", async (text) => {
189
+ accumulated += text;
190
+ try {
191
+ await onChunk(text, accumulated);
192
+ }
193
+ catch (err) {
194
+ // editMessageText 실패 등은 무시하고 계속
195
+ console.warn("[Stream] Chunk callback error (ignored):", err);
196
+ }
197
+ });
198
+ // 스트림 완료 대기
199
+ const finalMessage = await stream.finalMessage();
200
+ stopReason = finalMessage.stop_reason;
201
+ // 도구 호출이 필요한 경우 - 일반 chat으로 폴백
202
+ if (stopReason === "tool_use") {
203
+ console.log("[Stream] Tool use detected, falling back to chat()");
204
+ const text = await chat(messages, systemPrompt, modelId);
205
+ return { text, usedTools: true };
206
+ }
207
+ // 성공적으로 스트리밍 완료
208
+ return { text: accumulated, usedTools: false };
209
+ });
210
+ }
@@ -1,4 +1,5 @@
1
- import { google } from "googleapis";
1
+ import { calendar } from "@googleapis/calendar";
2
+ import { OAuth2Client } from "google-auth-library";
2
3
  import * as fs from "fs/promises";
3
4
  import * as path from "path";
4
5
  import * as http from "http";
@@ -69,7 +70,7 @@ export async function getAuthUrl() {
69
70
  const creds = await loadCredentials();
70
71
  if (!creds)
71
72
  return null;
72
- const oauth2Client = new google.auth.OAuth2(creds.client_id, creds.client_secret, REDIRECT_URI);
73
+ const oauth2Client = new OAuth2Client(creds.client_id, creds.client_secret, REDIRECT_URI);
73
74
  return oauth2Client.generateAuthUrl({
74
75
  access_type: "offline",
75
76
  scope: SCOPES,
@@ -120,7 +121,7 @@ export async function exchangeCodeForToken(code) {
120
121
  const creds = await loadCredentials();
121
122
  if (!creds)
122
123
  return false;
123
- const oauth2Client = new google.auth.OAuth2(creds.client_id, creds.client_secret, REDIRECT_URI);
124
+ const oauth2Client = new OAuth2Client(creds.client_id, creds.client_secret, REDIRECT_URI);
124
125
  try {
125
126
  const { tokens } = await oauth2Client.getToken(code);
126
127
  if (tokens.access_token && tokens.refresh_token) {
@@ -145,7 +146,7 @@ async function getAuthClient() {
145
146
  if (!creds || !token) {
146
147
  throw new Error("Calendar not configured. Use /calendar_setup");
147
148
  }
148
- const oauth2Client = new google.auth.OAuth2(creds.client_id, creds.client_secret, REDIRECT_URI);
149
+ const oauth2Client = new OAuth2Client(creds.client_id, creds.client_secret, REDIRECT_URI);
149
150
  oauth2Client.setCredentials({
150
151
  access_token: token.access_token,
151
152
  refresh_token: token.refresh_token,
@@ -169,17 +170,18 @@ async function getAuthClient() {
169
170
  // 캘린더 API 인스턴스
170
171
  async function getCalendar() {
171
172
  const auth = await getAuthClient();
172
- return google.calendar({ version: "v3", auth });
173
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
174
+ return calendar({ version: "v3", auth: auth });
173
175
  }
174
176
  // 오늘 일정 조회
175
177
  export async function getTodayEvents() {
176
- const calendar = await getCalendar();
178
+ const cal = await getCalendar();
177
179
  const now = new Date();
178
180
  const startOfDay = new Date(now);
179
181
  startOfDay.setHours(0, 0, 0, 0);
180
182
  const endOfDay = new Date(now);
181
183
  endOfDay.setHours(23, 59, 59, 999);
182
- const response = await calendar.events.list({
184
+ const response = await cal.events.list({
183
185
  calendarId: "primary",
184
186
  timeMin: startOfDay.toISOString(),
185
187
  timeMax: endOfDay.toISOString(),
@@ -190,8 +192,8 @@ export async function getTodayEvents() {
190
192
  }
191
193
  // 특정 기간 일정 조회
192
194
  export async function getEvents(startDate, endDate) {
193
- const calendar = await getCalendar();
194
- const response = await calendar.events.list({
195
+ const cal = await getCalendar();
196
+ const response = await cal.events.list({
195
197
  calendarId: "primary",
196
198
  timeMin: startDate.toISOString(),
197
199
  timeMax: endDate.toISOString(),
@@ -203,7 +205,7 @@ export async function getEvents(startDate, endDate) {
203
205
  }
204
206
  // 일정 추가
205
207
  export async function addEvent(summary, startTime, endTime, description) {
206
- const calendar = await getCalendar();
208
+ const cal = await getCalendar();
207
209
  // 종료 시간이 없으면 1시간 후
208
210
  const end = endTime || new Date(startTime.getTime() + 60 * 60 * 1000);
209
211
  const event = {
@@ -218,7 +220,7 @@ export async function addEvent(summary, startTime, endTime, description) {
218
220
  timeZone: "Asia/Seoul",
219
221
  },
220
222
  };
221
- const response = await calendar.events.insert({
223
+ const response = await cal.events.insert({
222
224
  calendarId: "primary",
223
225
  requestBody: event,
224
226
  });
@@ -227,8 +229,8 @@ export async function addEvent(summary, startTime, endTime, description) {
227
229
  // 일정 삭제
228
230
  export async function deleteEvent(eventId) {
229
231
  try {
230
- const calendar = await getCalendar();
231
- await calendar.events.delete({
232
+ const cal = await getCalendar();
233
+ await cal.events.delete({
232
234
  calendarId: "primary",
233
235
  eventId,
234
236
  });
@@ -8,7 +8,7 @@ export { isValidCronExpression, parseCronExpression, getNextCronRun, getNextRun,
8
8
  // Storage functions
9
9
  export { loadJobs, saveJobs, addJob, removeJob, updateJob, getDueJobs, markJobExecuted, getJobsByChat, getJob, calculateNextRun, } from "./store.js";
10
10
  // Scheduler functions
11
- export { CronScheduler, executeJob, setCronBot, startCronScheduler, stopCronScheduler, isCronSchedulerRunning, initCronSystem, restoreCronJobs, createCronJob, deleteCronJob, toggleCronJob, getCronJobs, getAllCronJobs, getActiveJobCount, } from "./scheduler.js";
11
+ export { CronScheduler, executeJob, setCronBot, startCronScheduler, stopCronScheduler, isCronSchedulerRunning, initCronSystem, restoreCronJobs, createCronJob, deleteCronJob, toggleCronJob, getCronJobs, getAllCronJobs, getActiveJobCount, ensureDefaultCronJobs, } from "./scheduler.js";
12
12
  // Command handlers (for tools)
13
13
  export { addCronJob, removeCronJob, setCronJobEnabled, listCronJobs, getCronStatus, } from "./commands.js";
14
14
  // Aliases for backward compatibility
@@ -130,184 +130,83 @@ export function parseCronExpression(expr) {
130
130
  };
131
131
  }
132
132
  /**
133
- * Get time components in a specific timezone using Intl API
133
+ * Calculate the next run time for a cron expression
134
+ * Returns null if no valid next run time is found (safer than throwing)
134
135
  */
135
- function getTimeInTimezone(date, timezone) {
136
- try {
137
- const formatter = new Intl.DateTimeFormat("en-US", {
138
- timeZone: timezone,
139
- year: "numeric",
140
- month: "2-digit",
141
- day: "2-digit",
142
- hour: "2-digit",
143
- minute: "2-digit",
144
- weekday: "short",
145
- hour12: false,
146
- });
147
- const parts = formatter.formatToParts(date);
148
- const getValue = (type) => parts.find((p) => p.type === type)?.value ?? "0";
149
- const dayOfWeekMap = {
150
- Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6,
151
- };
152
- return {
153
- year: parseInt(getValue("year"), 10),
154
- month: parseInt(getValue("month"), 10),
155
- day: parseInt(getValue("day"), 10),
156
- hour: parseInt(getValue("hour"), 10),
157
- minute: parseInt(getValue("minute"), 10),
158
- dayOfWeek: dayOfWeekMap[getValue("weekday")] ?? 0,
159
- };
160
- }
161
- catch {
162
- // Fallback to local time if timezone is invalid
163
- return {
164
- year: date.getFullYear(),
165
- month: date.getMonth() + 1,
166
- day: date.getDate(),
167
- hour: date.getHours(),
168
- minute: date.getMinutes(),
169
- dayOfWeek: date.getDay(),
170
- };
136
+ export function getNextCronRun(expression, fromDate = new Date(), timezone) {
137
+ // null/undefined/빈 문자열 처리
138
+ if (!expression || expression.trim() === "") {
139
+ console.warn("[Cron] Empty cron expression provided");
140
+ return null;
171
141
  }
172
- }
173
- /**
174
- * Create a Date object for a specific time in a timezone
175
- */
176
- function createDateInTimezone(year, month, day, hour, minute, timezone) {
177
142
  try {
178
- const dateStr = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00`;
179
- const formatter = new Intl.DateTimeFormat("en-US", {
180
- timeZone: timezone,
181
- year: "numeric",
182
- month: "2-digit",
183
- day: "2-digit",
184
- hour: "2-digit",
185
- minute: "2-digit",
186
- hour12: false,
187
- });
188
- // Binary search for the correct UTC time
189
- let guess = new Date(dateStr);
190
- for (let i = 0; i < 3; i++) {
191
- const parts = formatter.formatToParts(guess);
192
- const getValue = (type) => parseInt(parts.find((p) => p.type === type)?.value ?? "0", 10);
193
- const guessHour = getValue("hour");
194
- const guessMinute = getValue("minute");
195
- const guessDay = getValue("day");
196
- let diffMinutes = (hour - guessHour) * 60 + (minute - guessMinute);
197
- if (day !== guessDay) {
198
- if (day > guessDay || (day === 1 && guessDay > 20)) {
199
- diffMinutes += 24 * 60;
200
- }
201
- else {
202
- diffMinutes -= 24 * 60;
203
- }
204
- }
205
- if (diffMinutes === 0)
206
- break;
207
- guess = new Date(guess.getTime() + diffMinutes * 60 * 1000);
208
- }
209
- return guess;
210
- }
211
- catch {
212
- return new Date(year, month - 1, day, hour, minute, 0, 0);
213
- }
214
- }
215
- /**
216
- * Get number of days in a month
217
- */
218
- function getDaysInMonth(year, month) {
219
- return new Date(year, month, 0).getDate();
220
- }
221
- /**
222
- * Calculate the next run time for a cron expression with timezone support
223
- */
224
- export function getNextCronRun(expression, fromDate = new Date(), timezone) {
225
- const parsed = parseCronExpression(expression);
226
- const now = fromDate;
227
- const tz = timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
228
- // Get current time in target timezone
229
- const current = getTimeInTimezone(now, tz);
230
- // Search for next valid time (up to 366 days ahead)
231
- const searchDate = { ...current };
232
- const maxIterations = 366 * 24 * 60;
233
- for (let i = 0; i < maxIterations; i++) {
234
- // Advance by one minute each iteration
235
- if (i > 0) {
236
- searchDate.minute++;
237
- if (searchDate.minute > 59) {
238
- searchDate.minute = 0;
239
- searchDate.hour++;
240
- if (searchDate.hour > 23) {
241
- searchDate.hour = 0;
242
- searchDate.day++;
243
- searchDate.dayOfWeek = (searchDate.dayOfWeek + 1) % 7;
244
- const daysInMonth = getDaysInMonth(searchDate.year, searchDate.month);
245
- if (searchDate.day > daysInMonth) {
246
- searchDate.day = 1;
247
- searchDate.month++;
248
- if (searchDate.month > 12) {
249
- searchDate.month = 1;
250
- searchDate.year++;
251
- }
252
- }
253
- }
254
- }
143
+ const parsed = parseCronExpression(expression);
144
+ const from = new Date(fromDate);
145
+ // Invalid date 체크
146
+ if (isNaN(from.getTime())) {
147
+ console.warn("[Cron] Invalid fromDate provided");
148
+ return null;
255
149
  }
256
- // Check if this time matches all constraints
257
- if (!parsed.month.values.includes(searchDate.month))
258
- continue;
259
- // For dayOfMonth and dayOfWeek: if both are restricted, match either (OR logic)
260
- // If only one is restricted, use that one
261
- const domRestricted = parsed.dayOfMonth.type !== "wildcard";
262
- const dowRestricted = parsed.dayOfWeek.type !== "wildcard";
263
- if (domRestricted && dowRestricted) {
264
- // Both restricted: match either
265
- if (!parsed.dayOfMonth.values.includes(searchDate.day) &&
266
- !parsed.dayOfWeek.values.includes(searchDate.dayOfWeek)) {
267
- continue;
150
+ // Start from the next minute
151
+ from.setSeconds(0);
152
+ from.setMilliseconds(0);
153
+ from.setMinutes(from.getMinutes() + 1);
154
+ // Search for the next matching time (max 2 years ahead)
155
+ const maxIterations = 365 * 24 * 60 * 2; // 2 years in minutes
156
+ for (let i = 0; i < maxIterations; i++) {
157
+ const candidate = new Date(from.getTime() + i * 60000);
158
+ const minute = candidate.getMinutes();
159
+ const hour = candidate.getHours();
160
+ const dayOfMonth = candidate.getDate();
161
+ const month = candidate.getMonth() + 1;
162
+ const dayOfWeek = candidate.getDay();
163
+ if (parsed.minute.values.includes(minute) &&
164
+ parsed.hour.values.includes(hour) &&
165
+ parsed.month.values.includes(month) &&
166
+ (parsed.dayOfMonth.values.includes(dayOfMonth) ||
167
+ parsed.dayOfWeek.values.includes(dayOfWeek))) {
168
+ return candidate;
268
169
  }
269
170
  }
270
- else if (domRestricted) {
271
- if (!parsed.dayOfMonth.values.includes(searchDate.day))
272
- continue;
273
- }
274
- else if (dowRestricted) {
275
- if (!parsed.dayOfWeek.values.includes(searchDate.dayOfWeek))
276
- continue;
277
- }
278
- if (!parsed.hour.values.includes(searchDate.hour))
279
- continue;
280
- if (!parsed.minute.values.includes(searchDate.minute))
281
- continue;
282
- // Skip if this is the current minute (must be in the future)
283
- if (i === 0)
284
- continue;
285
- // Create the date in the target timezone
286
- const nextRun = createDateInTimezone(searchDate.year, searchDate.month, searchDate.day, searchDate.hour, searchDate.minute, tz);
287
- // Verify it's in the future
288
- if (nextRun > now) {
289
- return nextRun;
290
- }
171
+ console.warn(`[Cron] Could not find next run time for: ${expression}`);
172
+ return null;
173
+ }
174
+ catch (error) {
175
+ console.error(`[Cron] Error parsing expression "${expression}":`, error);
176
+ return null;
291
177
  }
292
- throw new Error(`Could not find next run time for: ${expression}`);
293
178
  }
294
179
  /**
295
180
  * Get the next run time for any schedule type
181
+ * Returns NaN if schedule is invalid (check with isNaN())
296
182
  */
297
183
  export function getNextRun(schedule, now = new Date()) {
184
+ // null/undefined 처리
185
+ if (!schedule) {
186
+ console.warn("[Cron] No schedule provided to getNextRun");
187
+ return NaN;
188
+ }
298
189
  switch (schedule.kind) {
299
190
  case "at":
300
- return schedule.atMs;
191
+ return schedule.atMs ?? NaN;
301
192
  case "every": {
193
+ const everyMs = schedule.everyMs || schedule.intervalMs;
194
+ if (!everyMs || everyMs <= 0) {
195
+ console.warn("[Cron] Invalid everyMs in schedule");
196
+ return NaN;
197
+ }
302
198
  const startMs = schedule.startMs ?? now.getTime();
303
199
  const elapsed = now.getTime() - startMs;
304
- const intervals = Math.floor(elapsed / schedule.everyMs);
305
- return startMs + (intervals + 1) * schedule.everyMs;
200
+ const intervals = Math.floor(elapsed / everyMs);
201
+ return startMs + (intervals + 1) * everyMs;
202
+ }
203
+ case "cron": {
204
+ const nextRun = getNextCronRun(schedule.expression, now, schedule.timezone);
205
+ return nextRun ? nextRun.getTime() : NaN;
306
206
  }
307
- case "cron":
308
- return getNextCronRun(schedule.expression, now, schedule.timezone).getTime();
309
207
  default:
310
- throw new Error(`Unknown schedule kind: ${schedule.kind}`);
208
+ console.warn(`[Cron] Unknown schedule kind: ${schedule.kind}`);
209
+ return NaN;
311
210
  }
312
211
  }
313
212
  /**