@cartanova/qgrid-cli 2.6.8 → 2.6.10

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 (50) hide show
  1. package/bundle/dist/application/qgrid/expired-token-reminder.js +20 -5
  2. package/bundle/dist/application/setting/setting.constant.js +226 -0
  3. package/bundle/dist/application/setting/setting.model.js +20 -2
  4. package/bundle/dist/application/setting/setting.types.js +5 -2
  5. package/bundle/dist/sonamu.config.js +2 -2
  6. package/bundle/dist/utils/providers/openai/openai-worker-pool-config.js +2 -2
  7. package/bundle/dist/utils/quiet-hours.js +30 -26
  8. package/bundle/dist/utils/slack-notify.js +6 -2
  9. package/bundle/src/application/qgrid/expired-token-reminder.test.ts +43 -0
  10. package/bundle/src/application/qgrid/expired-token-reminder.ts +22 -2
  11. package/bundle/src/application/setting/{setting.schema.test.ts → setting.constant.test.ts} +30 -1
  12. package/bundle/src/application/setting/{setting.schema.ts → setting.constant.ts} +86 -10
  13. package/bundle/src/application/setting/setting.model.ts +15 -1
  14. package/bundle/src/application/setting/setting.types.ts +3 -1
  15. package/bundle/src/application/sonamu.generated.http +7 -0
  16. package/bundle/src/sonamu.config.ts +3 -1
  17. package/bundle/src/utils/providers/openai/openai-worker-pool-config.ts +1 -1
  18. package/bundle/src/utils/quiet-hours.ts +56 -17
  19. package/bundle/src/utils/slack-notify.test.ts +96 -13
  20. package/bundle/src/utils/slack-notify.ts +8 -1
  21. package/bundle/web-dist/client/assets/{index-DrsXE13r.js → index-BRRNJRgu.js} +3 -3
  22. package/bundle/web-dist/client/assets/index-rdbxJinT.css +1 -0
  23. package/bundle/web-dist/client/assets/{logs-e4-6aYnO.js → logs-BI5K8oe4.js} +1 -1
  24. package/bundle/web-dist/client/assets/{monit-eOAlYTK9.js → monit-4Y-Razet.js} +1 -1
  25. package/bundle/web-dist/client/assets/{routes-Qie4ogtJ.js → routes-CkBXB-V4.js} +1 -1
  26. package/bundle/web-dist/client/assets/services.generated-BzwTuZRP.js +1 -0
  27. package/bundle/web-dist/client/assets/settings-B62_r1i4.js +1 -0
  28. package/bundle/web-dist/client/assets/{show-60iOOM-e.js → show-CGRchJj7.js} +1 -1
  29. package/bundle/web-dist/client/assets/tokens-Bx5mQnoJ.js +1 -0
  30. package/bundle/web-dist/client/assets/{use-oauth-login-flow-0ZGq74bZ.js → use-oauth-login-flow-Bz87RV5v.js} +1 -1
  31. package/bundle/web-dist/client/index.html +2 -2
  32. package/bundle/web-dist/server/assets/{logs-DmkzGxGU.js → logs-C1rxLh8H.js} +1 -1
  33. package/bundle/web-dist/server/assets/{monit-DyKLJX4s.js → monit-C8gx9Sar.js} +1 -1
  34. package/bundle/web-dist/server/assets/{routes-g62E9xmC.js → routes-ZW_Ih5Em.js} +8 -5
  35. package/bundle/web-dist/server/assets/{services.generated-DADr9OGH.js → services.generated-CZIChgHV.js} +8 -0
  36. package/bundle/web-dist/server/assets/{settings-CMJKayff.js → settings--aoatERH.js} +72 -22
  37. package/bundle/web-dist/server/assets/{show-BGAiCPGq.js → show-BY27w9oi.js} +1 -1
  38. package/bundle/web-dist/server/assets/{tokens-DmhIfVct.js → tokens-DIObtTuZ.js} +13 -53
  39. package/bundle/web-dist/server/assets/{use-oauth-login-flow-B6MY65IN.js → use-oauth-login-flow-V3k8PSqQ.js} +1 -1
  40. package/bundle/web-dist/server/entry-server.generated.js +7 -7
  41. package/package.json +1 -1
  42. package/skills/qgrid/references/cli-env-and-server-boot.md +5 -2
  43. package/skills/qgrid/references/decision-rationale.md +7 -0
  44. package/skills/qgrid/references/token-auth-quota-lifecycle.md +2 -1
  45. package/skills/qgrid/references/tool-calling-and-multiturn.md +21 -0
  46. package/bundle/dist/application/setting/setting.schema.js +0 -157
  47. package/bundle/web-dist/client/assets/index-7SZyiqAx.css +0 -1
  48. package/bundle/web-dist/client/assets/services.generated-T4ctVcia.js +0 -1
  49. package/bundle/web-dist/client/assets/settings-CTVZpAAg.js +0 -1
  50. package/bundle/web-dist/client/assets/tokens-DdDccGlX.js +0 -1
@@ -2,39 +2,78 @@
2
2
  * Slack 알림 조용 시간.
3
3
  *
4
4
  * 토큰 만료는 사람이 재로그인해야 풀리는데, 그 사람이 자거나 쉬는 동안 알려도 할 수 있는
5
- * 일이 없다. 평일 업무 시간에만 알리고 나머지는 다음 근무 시간으로 미룬다 — 만료 알림은
5
+ * 일이 없다. 업무 시간에만 알리고 나머지는 다음 근무 시간으로 미룬다 — 만료 알림은
6
6
  * 주기적으로 반복되므로 조용 시간에 버려도 아침 첫 주기에 다시 온다.
7
7
  *
8
8
  * 다만 provider 의 마지막 토큰이 죽는 것은 서비스 정지 신호라 이 규칙에서 제외한다
9
- * (호출부가 `notifySlack` 을 그냥 호출하면 된다).
9
+ * (호출부가 `urgent: true` 로 보내면 된다).
10
10
  */
11
+ import { type getSetting } from "../application/setting/setting.store";
11
12
 
12
- /** 이 시각부터(포함) 조용해진다. */
13
- const QUIET_FROM_HOUR = 20;
14
- /** 이 시각부터(포함) 다시 알린다. */
15
- const QUIET_UNTIL_HOUR = 8;
13
+ /**
14
+ * 설정 조회 함수의 형태만 빌린다 — `type` import 라 런타임 의존이 생기지 않는다.
15
+ *
16
+ * 값 자체는 항상 호출부(`slack-notify`)가 넘긴다. 여기서 `getSetting` 을 실제로 import 하면
17
+ * `setting.store → setting.model → token.model` 이 딸려와, `token.model` 을 mock 하는
18
+ * 테스트들이 실제 모듈을 먼저 로드해 버린다. 조용 시간 판정은 시각 계산이지 설정 조회가
19
+ * 아니므로, 읽는 책임은 이미 설정을 읽고 있는 호출부에 둔다.
20
+ */
21
+ type ReadSetting = typeof getSetting;
22
+
23
+ /** `setting.constant.ts` 의 두 fallback 과 같아야 한다. 일치 여부는 테스트가 지킨다. */
24
+ export const DEFAULT_QUIET_FROM_HOUR = 20;
25
+ export const DEFAULT_QUIET_UNTIL_HOUR = 8;
16
26
 
17
27
  /**
18
28
  * 서버가 UTC 로 돌아도 한국 시간으로 판정한다. dev0 는 UTC 이므로 `getHours()` 를 그대로
19
29
  * 쓰면 "저녁 8시"가 한국 시간 새벽 5시가 된다.
20
30
  */
31
+ const SEOUL_FORMATTER = new Intl.DateTimeFormat("en-US", {
32
+ timeZone: "Asia/Seoul",
33
+ hour: "numeric",
34
+ hour12: false,
35
+ weekday: "short",
36
+ });
37
+
21
38
  function seoulParts(at: Date): { hour: number; weekday: string } {
22
- const formatter = new Intl.DateTimeFormat("en-US", {
23
- timeZone: "Asia/Seoul",
24
- hour: "numeric",
25
- hour12: false,
26
- weekday: "short",
27
- });
28
- const parts = formatter.formatToParts(at);
39
+ const parts = SEOUL_FORMATTER.formatToParts(at);
29
40
  const hour = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
30
41
  const weekday = parts.find((p) => p.type === "weekday")?.value ?? "";
31
42
  // Intl 은 자정을 24 로 줄 수 있다. 0..23 으로 맞춘다.
32
43
  return { hour: hour % 24, weekday };
33
44
  }
34
45
 
35
- /** 주말이거나 20시~익일 8시면 조용 시간. */
36
- export function isQuietHours(at: Date = new Date()): boolean {
46
+ /** 0..23 범위를 벗어나거나 숫자가 아니면 기본값으로 떨어진다. */
47
+ function boundedHour(raw: string | undefined, fallback: number): number {
48
+ const parsed = Number(raw);
49
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 23) return fallback;
50
+ return parsed;
51
+ }
52
+
53
+ /**
54
+ * 조용 시간인지 판정한다.
55
+ *
56
+ * 시작 > 종료면 자정을 넘는 구간(20시~익일 8시), 시작 < 종료면 같은 날 구간(0시~8시처럼)
57
+ * 으로 읽는다. 야간 근무처럼 낮에 조용하고 싶은 팀도 있어 뒤집힌 값을 막지 않는다.
58
+ */
59
+ export function isQuietHours(at: Date, readSetting: ReadSetting): boolean {
37
60
  const { hour, weekday } = seoulParts(at);
38
- if (weekday === "Sat" || weekday === "Sun") return true;
39
- return hour >= QUIET_FROM_HOUR || hour < QUIET_UNTIL_HOUR;
61
+
62
+ const isWeekend = weekday === "Sat" || weekday === "Sun";
63
+ if (isWeekend && readSetting("slack.notifyOnWeekends", "SLACK_NOTIFY_ON_WEEKENDS") !== "true") {
64
+ return true;
65
+ }
66
+
67
+ const from = boundedHour(
68
+ readSetting("slack.quietFromHour", "SLACK_QUIET_FROM_HOUR"),
69
+ DEFAULT_QUIET_FROM_HOUR,
70
+ );
71
+ const until = boundedHour(
72
+ readSetting("slack.quietUntilHour", "SLACK_QUIET_UNTIL_HOUR"),
73
+ DEFAULT_QUIET_UNTIL_HOUR,
74
+ );
75
+
76
+ // 시작과 종료가 같으면 구간이 비어 있다는 뜻으로 읽어 조용 시간을 두지 않는다.
77
+ if (from === until) return false;
78
+ return from > until ? hour >= from || hour < until : hour >= from && hour < until;
40
79
  }
@@ -12,6 +12,9 @@ function seoul(iso: string): Date {
12
12
  return new Date(`${iso}+09:00`);
13
13
  }
14
14
 
15
+ /** 저장값도 env 도 없는 상태 — 조용 시간이 코드 기본값(20~8시, 주말 차단)으로 동작한다. */
16
+ const noSettings = () => undefined;
17
+
15
18
  /** 평일 업무 시간. 조용 시간에 막히지 않도록 전송 케이스의 기준 시각으로 쓴다. */
16
19
  const WORKING_HOURS = seoul("2026-08-05T14:00:00");
17
20
 
@@ -129,39 +132,119 @@ describe("notifySlack", () => {
129
132
 
130
133
  expect(fetchMock).toHaveBeenCalledTimes(1);
131
134
  });
135
+
136
+ it("마스터 스위치를 내리면 보통 알림을 보내지 않는다", async () => {
137
+ // 연휴처럼 규칙으로 잡을 수 없는 기간에 관리자가 직접 내리는 스위치.
138
+ settingMock.mockImplementation((key: string) =>
139
+ key === "slack.botToken"
140
+ ? "xoxb-test"
141
+ : key === "slack.channelId"
142
+ ? "C123"
143
+ : key === "slack.enabled"
144
+ ? "false"
145
+ : undefined,
146
+ );
147
+ const fetchMock = vi.spyOn(globalThis, "fetch");
148
+
149
+ await notify({ title: "토큰 추가", now: WORKING_HOURS });
150
+
151
+ expect(fetchMock).not.toHaveBeenCalled();
152
+ });
153
+
154
+ it("마스터 스위치를 내려도 urgent 는 통과한다", async () => {
155
+ // 끈 채로 provider 가 전부 죽으면 연휴 내내 서비스 정지를 아무도 모른다.
156
+ settingMock.mockImplementation((key: string) =>
157
+ key === "slack.botToken"
158
+ ? "xoxb-test"
159
+ : key === "slack.channelId"
160
+ ? "C123"
161
+ : key === "slack.enabled"
162
+ ? "false"
163
+ : undefined,
164
+ );
165
+ const fetchMock = vi
166
+ .spyOn(globalThis, "fetch")
167
+ .mockResolvedValue(new Response(JSON.stringify({ ok: true })));
168
+
169
+ await notify({ title: "마지막 토큰 사망", urgent: true, now: WORKING_HOURS });
170
+
171
+ expect(fetchMock).toHaveBeenCalledTimes(1);
172
+ });
173
+ });
174
+
175
+ describe("isQuietHours 설정", () => {
176
+
177
+ /** 설정 조회를 고정해 실행 환경의 env 에 기대지 않게 한다. */
178
+ const withSettings = (values: Record<string, string>) => (key: string) => values[key];
179
+
180
+ it("조용 시간 구간을 설정으로 바꾼다", () => {
181
+ const read = withSettings({ "slack.quietFromHour": "22", "slack.quietUntilHour": "6" });
182
+ // 21시는 기본값(20시 시작)이면 조용하지만 22시 시작으로 바꾸면 알린다.
183
+ expect(isQuietHours(seoul("2026-08-05T21:00:00"), read)).toBe(false);
184
+ expect(isQuietHours(seoul("2026-08-05T22:00:00"), read)).toBe(true);
185
+ expect(isQuietHours(seoul("2026-08-06T05:59:00"), read)).toBe(true);
186
+ expect(isQuietHours(seoul("2026-08-06T06:00:00"), read)).toBe(false);
187
+ });
188
+
189
+ it("주말 알림을 켜면 주말에도 시간 규칙만 본다", () => {
190
+ const read = withSettings({ "slack.notifyOnWeekends": "true" });
191
+ // 2026-08-08 토요일
192
+ expect(isQuietHours(seoul("2026-08-08T10:00:00"), read)).toBe(false);
193
+ expect(isQuietHours(seoul("2026-08-08T22:00:00"), read)).toBe(true);
194
+ });
195
+
196
+ it("시작과 종료가 같으면 조용 시간을 두지 않는다", () => {
197
+ const read = withSettings({ "slack.quietFromHour": "8", "slack.quietUntilHour": "8" });
198
+ expect(isQuietHours(seoul("2026-08-05T03:00:00"), read)).toBe(false);
199
+ expect(isQuietHours(seoul("2026-08-05T23:00:00"), read)).toBe(false);
200
+ });
201
+
202
+ it("시작이 종료보다 빠르면 같은 날 구간으로 읽는다", () => {
203
+ // 야간 근무처럼 낮에 조용하고 싶은 경우를 막지 않는다.
204
+ const read = withSettings({ "slack.quietFromHour": "9", "slack.quietUntilHour": "18" });
205
+ expect(isQuietHours(seoul("2026-08-05T12:00:00"), read)).toBe(true);
206
+ expect(isQuietHours(seoul("2026-08-05T20:00:00"), read)).toBe(false);
207
+ });
208
+
209
+ it("범위를 벗어난 값은 기본 구간으로 떨어진다", () => {
210
+ // 저장을 막고 있지만 env 로도 들어올 수 있어 런타임에서 한 번 더 지킨다.
211
+ const read = withSettings({ "slack.quietFromHour": "99", "slack.quietUntilHour": "-1" });
212
+ expect(isQuietHours(seoul("2026-08-05T21:00:00"), read)).toBe(true);
213
+ expect(isQuietHours(seoul("2026-08-05T14:00:00"), read)).toBe(false);
214
+ });
132
215
  });
133
216
 
134
217
  describe("isQuietHours", () => {
135
218
  it("평일 업무 시간에는 알린다", () => {
136
219
  // 2026-08-05 는 수요일
137
- expect(isQuietHours(seoul("2026-08-05T09:00:00"))).toBe(false);
138
- expect(isQuietHours(seoul("2026-08-05T19:59:00"))).toBe(false);
220
+ expect(isQuietHours(seoul("2026-08-05T09:00:00"), noSettings)).toBe(false);
221
+ expect(isQuietHours(seoul("2026-08-05T19:59:00"), noSettings)).toBe(false);
139
222
  });
140
223
 
141
224
  it("평일 20시부터 다음날 8시까지 조용하다", () => {
142
- expect(isQuietHours(seoul("2026-08-05T20:00:00"))).toBe(true);
143
- expect(isQuietHours(seoul("2026-08-06T00:00:00"))).toBe(true);
144
- expect(isQuietHours(seoul("2026-08-06T07:59:00"))).toBe(true);
145
- expect(isQuietHours(seoul("2026-08-06T08:00:00"))).toBe(false);
225
+ expect(isQuietHours(seoul("2026-08-05T20:00:00"), noSettings)).toBe(true);
226
+ expect(isQuietHours(seoul("2026-08-06T00:00:00"), noSettings)).toBe(true);
227
+ expect(isQuietHours(seoul("2026-08-06T07:59:00"), noSettings)).toBe(true);
228
+ expect(isQuietHours(seoul("2026-08-06T08:00:00"), noSettings)).toBe(false);
146
229
  });
147
230
 
148
231
  it("주말은 시간과 무관하게 조용하다", () => {
149
232
  // 2026-08-08 토요일, 08-09 일요일
150
- expect(isQuietHours(seoul("2026-08-08T10:00:00"))).toBe(true);
151
- expect(isQuietHours(seoul("2026-08-09T12:00:00"))).toBe(true);
233
+ expect(isQuietHours(seoul("2026-08-08T10:00:00"), noSettings)).toBe(true);
234
+ expect(isQuietHours(seoul("2026-08-09T12:00:00"), noSettings)).toBe(true);
152
235
  });
153
236
 
154
237
  it("월요일 8시에 다시 알리기 시작한다", () => {
155
- expect(isQuietHours(seoul("2026-08-10T07:59:00"))).toBe(true);
156
- expect(isQuietHours(seoul("2026-08-10T08:00:00"))).toBe(false);
238
+ expect(isQuietHours(seoul("2026-08-10T07:59:00"), noSettings)).toBe(true);
239
+ expect(isQuietHours(seoul("2026-08-10T08:00:00"), noSettings)).toBe(false);
157
240
  });
158
241
 
159
242
  it("서버가 UTC 여도 한국 시간으로 판정한다", () => {
160
243
  // UTC 23시 = 한국 다음날 08시 → 업무 시간
161
- expect(isQuietHours(new Date("2026-08-04T23:00:00Z"))).toBe(false);
244
+ expect(isQuietHours(new Date("2026-08-04T23:00:00Z"), noSettings)).toBe(false);
162
245
  // UTC 12시 = 한국 21시 → 조용 시간
163
- expect(isQuietHours(new Date("2026-08-05T12:00:00Z"))).toBe(true);
246
+ expect(isQuietHours(new Date("2026-08-05T12:00:00Z"), noSettings)).toBe(true);
164
247
  // UTC 금요일 22시 = 한국 토요일 07시 → 주말
165
- expect(isQuietHours(new Date("2026-08-07T22:00:00Z"))).toBe(true);
248
+ expect(isQuietHours(new Date("2026-08-07T22:00:00Z"), noSettings)).toBe(true);
166
249
  });
167
250
  });
@@ -50,8 +50,15 @@ export async function notifySlack(
50
50
  return;
51
51
  }
52
52
 
53
+ // 연휴처럼 규칙으로 잡을 수 없는 기간에 관리자가 내리는 스위치. urgent 는 통과시킨다 —
54
+ // 끈 상태로 provider 가 전부 죽으면 연휴 내내 서비스가 멈춘 줄 아무도 모른다.
55
+ if (!urgent && readSetting("slack.enabled", "SLACK_ENABLED") === "false") {
56
+ logger.debug(`slack disabled, skipping notification: ${title} ${subject ?? ""}`);
57
+ return;
58
+ }
59
+
53
60
  // 만료 알림은 주기적으로 반복되므로 여기서 버려도 다음 근무 시간 첫 주기에 다시 온다.
54
- if (!urgent && isQuietHours(now)) {
61
+ if (!urgent && isQuietHours(now ?? new Date(), readSetting)) {
55
62
  logger.debug(`quiet hours, skipping notification: ${title} ${subject ?? ""}`);
56
63
  return;
57
64
  }