@cartanova/qgrid-cli 2.6.7 → 2.6.9

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 (87) hide show
  1. package/bundle/dist/application/monit/monit.frame.js +2 -2
  2. package/bundle/dist/application/qgrid/expired-token-reminder.js +45 -15
  3. package/bundle/dist/application/qgrid/oauth.js +1 -1
  4. package/bundle/dist/application/qgrid/qgrid.frame.js +3 -3
  5. package/bundle/dist/application/qgrid/slack-user-map.js +5 -14
  6. package/bundle/dist/application/qgrid/token-death.js +3 -2
  7. package/bundle/dist/application/qgrid/tool-emulation.js +1 -1
  8. package/bundle/dist/application/queries.generated.js +8 -2
  9. package/bundle/dist/application/request-log/request-log.model.js +2 -2
  10. package/bundle/dist/application/request-log-step/request-log-step.model.js +1 -1
  11. package/bundle/dist/application/setting/setting.constant.js +226 -0
  12. package/bundle/dist/application/setting/setting.model.js +254 -0
  13. package/bundle/dist/application/setting/setting.store.js +66 -0
  14. package/bundle/dist/application/setting/setting.types.js +51 -0
  15. package/bundle/dist/application/sonamu.generated.js +32 -2
  16. package/bundle/dist/application/sonamu.generated.sso.js +12 -2
  17. package/bundle/dist/application/token/token.model.js +1 -1
  18. package/bundle/dist/i18n/en.js +3 -1
  19. package/bundle/dist/i18n/ko.js +3 -1
  20. package/bundle/dist/i18n/sd.generated.js +9 -1
  21. package/bundle/dist/migrations/20260808194708_create__settings.js +24 -0
  22. package/bundle/dist/sonamu.config.js +8 -2
  23. package/bundle/dist/utils/providers/openai/openai-worker-pool-config.js +21 -6
  24. package/bundle/dist/utils/quiet-hours.js +47 -0
  25. package/bundle/dist/utils/slack-notify.js +16 -5
  26. package/bundle/src/application/qgrid/expired-token-reminder.test.ts +147 -2
  27. package/bundle/src/application/qgrid/expired-token-reminder.ts +66 -16
  28. package/bundle/src/application/qgrid/slack-user-map.test.ts +23 -0
  29. package/bundle/src/application/qgrid/slack-user-map.ts +4 -14
  30. package/bundle/src/application/qgrid/token-death.ts +2 -0
  31. package/bundle/src/application/queries.generated.ts +21 -1
  32. package/bundle/src/application/setting/setting.constant.test.ts +109 -0
  33. package/bundle/src/application/setting/setting.constant.ts +250 -0
  34. package/bundle/src/application/setting/setting.entity.json +59 -0
  35. package/bundle/src/application/setting/setting.model.ts +277 -0
  36. package/bundle/src/application/setting/setting.store.test.ts +87 -0
  37. package/bundle/src/application/setting/setting.store.ts +85 -0
  38. package/bundle/src/application/setting/setting.types.ts +53 -0
  39. package/bundle/src/application/sonamu.generated.http +54 -0
  40. package/bundle/src/application/sonamu.generated.sso.ts +21 -0
  41. package/bundle/src/application/sonamu.generated.ts +51 -0
  42. package/bundle/src/i18n/en.ts +2 -0
  43. package/bundle/src/i18n/ko.ts +2 -0
  44. package/bundle/src/i18n/sd.generated.ts +8 -0
  45. package/bundle/src/migrations/20260808194708_create__settings.ts +19 -0
  46. package/bundle/src/sonamu.config.ts +12 -0
  47. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +12 -0
  48. package/bundle/src/utils/providers/openai/openai-worker-pool-config.ts +43 -5
  49. package/bundle/src/utils/quiet-hours.ts +79 -0
  50. package/bundle/src/utils/slack-notify.test.ts +171 -20
  51. package/bundle/src/utils/slack-notify.ts +31 -4
  52. package/bundle/web-dist/client/assets/index-DnnliAzf.js +78 -0
  53. package/bundle/web-dist/client/assets/index-rdbxJinT.css +1 -0
  54. package/bundle/web-dist/client/assets/{logs-CUO9Q0UT.js → logs-5gACO5tJ.js} +1 -1
  55. package/bundle/web-dist/client/assets/{monit-Dy7UtqC2.js → monit-DRCc50GU.js} +1 -1
  56. package/bundle/web-dist/client/assets/plus-p4vtg6LN.js +1 -0
  57. package/bundle/web-dist/client/assets/{routes-B6gfQrph.js → routes-DBdI9Ba1.js} +2 -2
  58. package/bundle/web-dist/client/assets/{sd.generated-DYdwSB2I.js → sd.generated-Bwa7rses.js} +1 -1
  59. package/bundle/web-dist/client/assets/services.generated-BzwTuZRP.js +1 -0
  60. package/bundle/web-dist/client/assets/settings-B62_r1i4.js +1 -0
  61. package/bundle/web-dist/client/assets/{show-Bi0QYUVm.js → show-BCRrSc8-.js} +1 -1
  62. package/bundle/web-dist/client/assets/tokens-CDiXKYGf.js +1 -0
  63. package/bundle/web-dist/client/assets/{use-oauth-login-flow-Cx6BlvXM.js → use-oauth-login-flow-Qqg4koSg.js} +1 -1
  64. package/bundle/web-dist/client/index.html +3 -3
  65. package/bundle/web-dist/server/assets/{lazyRouteComponent-BaZaveNc.js → lazyRouteComponent-Ws7I6fL-.js} +2 -1
  66. package/bundle/web-dist/server/assets/{logs-DMOw3SjD.js → logs-C1rxLh8H.js} +4 -4
  67. package/bundle/web-dist/server/assets/{monit-UmcqHroX.js → monit-C8gx9Sar.js} +3 -2
  68. package/bundle/web-dist/server/assets/plus-BISXNqRU.js +19 -0
  69. package/bundle/web-dist/server/assets/react-dom-D7duWOwf.js +462 -0
  70. package/bundle/web-dist/server/assets/{routes-BfoQmYud.js → routes-ZW_Ih5Em.js} +11 -7
  71. package/bundle/web-dist/server/assets/{sd.generated-B0xqnHEo.js → sd.generated-LO-h1_16.js} +13 -461
  72. package/bundle/web-dist/server/assets/{services.generated-DqAKskAS.js → services.generated-CZIChgHV.js} +119 -2
  73. package/bundle/web-dist/server/assets/settings--aoatERH.js +382 -0
  74. package/bundle/web-dist/server/assets/{show-C-oSdzHX.js → show-BY27w9oi.js} +4 -4
  75. package/bundle/web-dist/server/assets/{tokens-Dkd_uXEk.js → tokens-DIObtTuZ.js} +17 -72
  76. package/bundle/web-dist/server/assets/{use-oauth-login-flow-YM1nsd-H.js → use-oauth-login-flow-V3k8PSqQ.js} +2 -2
  77. package/bundle/web-dist/server/entry-server.generated.js +54 -18
  78. package/package.json +1 -1
  79. package/skills/qgrid/references/cli-env-and-server-boot.md +17 -2
  80. package/skills/qgrid/references/openai-codex-runtime.md +1 -1
  81. package/skills/qgrid/references/sonamu-api-web-flow.md +2 -0
  82. package/skills/qgrid/references/token-auth-quota-lifecycle.md +2 -0
  83. package/bundle/web-dist/client/assets/index-B4GlN1IX.css +0 -1
  84. package/bundle/web-dist/client/assets/index-CV_rp3zE.js +0 -78
  85. package/bundle/web-dist/client/assets/services.generated-BsAvotjG.js +0 -1
  86. package/bundle/web-dist/client/assets/tokens-BAuYgBl6.js +0 -1
  87. /package/bundle/web-dist/server/assets/{cost-CSH_t17I.js → cost-DzjfVtUr.js} +0 -0
@@ -1,6 +1,20 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
- import { buildReminderContext } from "./expired-token-reminder";
3
+ const { findInactiveMock, getSettingMock, notifySlackMock } = vi.hoisted(() => ({
4
+ findInactiveMock: vi.fn(),
5
+ getSettingMock: vi.fn(),
6
+ notifySlackMock: vi.fn(),
7
+ }));
8
+
9
+ import {
10
+ buildReminderContext,
11
+ type ExpiredTokenReminderDeps,
12
+ rescheduleExpiredTokenReminder,
13
+ sendExpiredTokenReminderNow,
14
+ startExpiredTokenReminder,
15
+ stopExpiredTokenReminder,
16
+ } from "./expired-token-reminder";
17
+ import { getSlackUserMap } from "./slack-user-map";
4
18
 
5
19
  const userMap = new Map([
6
20
  ["yds", "U09NAJUQSFQ"],
@@ -71,3 +85,134 @@ describe("buildReminderContext", () => {
71
85
  ]);
72
86
  });
73
87
  });
88
+
89
+ describe("expired token reminder scheduling", () => {
90
+ let minutes = "10";
91
+ let rawUserMap = "yds:U-OLD";
92
+ let remindersEnabled = "true";
93
+ let deps: ExpiredTokenReminderDeps;
94
+
95
+ beforeEach(() => {
96
+ vi.useFakeTimers();
97
+ minutes = "10";
98
+ rawUserMap = "yds:U-OLD";
99
+ remindersEnabled = "true";
100
+ findInactiveMock.mockReset();
101
+ getSettingMock.mockReset();
102
+ notifySlackMock.mockReset();
103
+ findInactiveMock.mockResolvedValue([{ name: "anthropic/yds", provider: "anthropic" }]);
104
+ getSettingMock.mockImplementation((key: string) => {
105
+ if (key === "slack.expiryReminderMinutes") return minutes;
106
+ if (key === "slack.userMap") return rawUserMap;
107
+ if (key === "slack.remindersEnabled") return remindersEnabled;
108
+ return undefined;
109
+ });
110
+ notifySlackMock.mockResolvedValue(undefined);
111
+ deps = {
112
+ findInactive: findInactiveMock,
113
+ getSlackUserMap: () => getSlackUserMap(getSettingMock),
114
+ readSetting: getSettingMock,
115
+ sendSlack: notifySlackMock,
116
+ };
117
+ });
118
+
119
+ afterEach(() => {
120
+ stopExpiredTokenReminder();
121
+ vi.useRealTimers();
122
+ });
123
+
124
+ it("부팅 시 만료 토큰을 즉시 한 번 알린다", async () => {
125
+ startExpiredTokenReminder(deps);
126
+ await vi.advanceTimersByTimeAsync(0);
127
+
128
+ expect(notifySlackMock).toHaveBeenCalledTimes(1);
129
+ });
130
+
131
+ it("반복을 끄면 부팅해도 알리지 않는다", async () => {
132
+ remindersEnabled = "false";
133
+
134
+ startExpiredTokenReminder(deps);
135
+ await vi.advanceTimersByTimeAsync(0);
136
+ await vi.advanceTimersByTimeAsync(10 * 60_000);
137
+
138
+ expect(notifySlackMock).not.toHaveBeenCalled();
139
+ });
140
+
141
+ it("반복을 꺼도 주기 값은 남아 다시 켜면 그대로 쓴다", async () => {
142
+ remindersEnabled = "false";
143
+ rescheduleExpiredTokenReminder(deps);
144
+
145
+ remindersEnabled = "true";
146
+ rescheduleExpiredTokenReminder(deps);
147
+ await vi.advanceTimersByTimeAsync(10 * 60_000);
148
+
149
+ // 저장된 10분 주기가 그대로 살아 있다.
150
+ expect(notifySlackMock).toHaveBeenCalledTimes(1);
151
+ });
152
+
153
+ it("수동 트리거는 조용 시간을 통과하도록 urgent 로 보낸다", async () => {
154
+ // 사람이 직접 누른 것이라 밤이라고 삼키면 버튼이 고장 난 것으로 읽힌다.
155
+ const sent = await sendExpiredTokenReminderNow(deps);
156
+
157
+ expect(sent).toBe(1);
158
+ expect(notifySlackMock).toHaveBeenCalledWith(expect.objectContaining({ urgent: true }));
159
+ });
160
+
161
+ it("보낼 대상이 없으면 0을 돌려주고 아무것도 보내지 않는다", async () => {
162
+ findInactiveMock.mockResolvedValue([]);
163
+
164
+ const sent = await sendExpiredTokenReminderNow(deps);
165
+
166
+ expect(sent).toBe(0);
167
+ expect(notifySlackMock).not.toHaveBeenCalled();
168
+ });
169
+
170
+ it("start를 다시 호출해도 interval은 하나만 남는다", async () => {
171
+ startExpiredTokenReminder(deps);
172
+ startExpiredTokenReminder(deps);
173
+ await vi.advanceTimersByTimeAsync(0);
174
+ notifySlackMock.mockClear();
175
+
176
+ await vi.advanceTimersByTimeAsync(10 * 60_000);
177
+
178
+ expect(notifySlackMock).toHaveBeenCalledTimes(1);
179
+ });
180
+
181
+ it("설정 변경은 즉시 알리지 않고 새 주기로 재예약한다", async () => {
182
+ startExpiredTokenReminder(deps);
183
+ await vi.advanceTimersByTimeAsync(0);
184
+ notifySlackMock.mockClear();
185
+ minutes = "20";
186
+
187
+ rescheduleExpiredTokenReminder(deps);
188
+ await vi.advanceTimersByTimeAsync(19 * 60_000);
189
+ expect(notifySlackMock).not.toHaveBeenCalled();
190
+
191
+ await vi.advanceTimersByTimeAsync(60_000);
192
+ expect(notifySlackMock).toHaveBeenCalledTimes(1);
193
+ });
194
+
195
+ it.each(["0", "-1", "not-a-number"])("%s로 재설정하면 기존 타이머를 멈춘다", async (next) => {
196
+ startExpiredTokenReminder(deps);
197
+ await vi.advanceTimersByTimeAsync(0);
198
+ notifySlackMock.mockClear();
199
+ minutes = next;
200
+
201
+ rescheduleExpiredTokenReminder(deps);
202
+ await vi.advanceTimersByTimeAsync(20 * 60_000);
203
+
204
+ expect(notifySlackMock).not.toHaveBeenCalled();
205
+ });
206
+
207
+ it("각 실행 시점의 최신 Slack 사용자 매핑을 사용한다", async () => {
208
+ startExpiredTokenReminder(deps);
209
+ await vi.advanceTimersByTimeAsync(0);
210
+ expect(notifySlackMock.mock.calls[0]![0]).toMatchObject({ context: "<@U-OLD>\n재로그인이 필요합니다" });
211
+ notifySlackMock.mockClear();
212
+ rawUserMap = "yds:U-NEW";
213
+
214
+ await vi.advanceTimersByTimeAsync(10 * 60_000);
215
+
216
+ expect(notifySlackMock.mock.calls[0]![0]).toMatchObject({ context: "<@U-NEW>\n재로그인이 필요합니다" });
217
+ });
218
+ });
@@ -13,6 +13,7 @@
13
13
  import { getLogger } from "@logtape/logtape";
14
14
 
15
15
  import { notifySlack, SLACK_COLOR } from "../../utils/slack-notify";
16
+ import { getSetting } from "../setting/setting.store";
16
17
  import { TokenModel } from "../token/token.model";
17
18
  import { getSlackUserMap, mentionFor } from "./slack-user-map";
18
19
 
@@ -20,6 +21,20 @@ const logger = getLogger(["qgrid", "expired-reminder"]);
20
21
 
21
22
  const MINUTE_MS = 60_000;
22
23
 
24
+ export type ExpiredTokenReminderDeps = {
25
+ findInactive: () => Promise<{ name: string | null; provider: string }[]>;
26
+ getSlackUserMap: () => Map<string, string>;
27
+ readSetting: typeof getSetting;
28
+ sendSlack: typeof notifySlack;
29
+ };
30
+
31
+ const defaultDeps: ExpiredTokenReminderDeps = {
32
+ findInactive: () => TokenModel.findInactive("A"),
33
+ getSlackUserMap,
34
+ readSetting: getSetting,
35
+ sendSlack: notifySlack,
36
+ };
37
+
23
38
  /**
24
39
  * 1건이면 제목(subject)에 토큰명이 이미 있으므로 본문에서는 뺀다 — 같은 이름을 두 번 쓰면
25
40
  * 읽는 사람이 다른 정보인 줄 알고 한 번 더 본다. 여러 건이면 제목이 건수라 본문에 남긴다.
@@ -38,42 +53,77 @@ export function buildReminderContext(
38
53
  return [...lines, "재로그인이 필요합니다"].filter(Boolean).join("\n");
39
54
  }
40
55
 
41
- async function sendReminder(userMap: Map<string, string>): Promise<void> {
42
- const inactive = await TokenModel.findInactive("A");
43
- if (inactive.length === 0) return;
56
+ async function sendReminder(deps: ExpiredTokenReminderDeps, urgent = false): Promise<number> {
57
+ const inactive = await deps.findInactive();
58
+ if (inactive.length === 0) return 0;
44
59
 
45
- await notifySlack({
60
+ const userMap = deps.getSlackUserMap();
61
+
62
+ await deps.sendSlack({
46
63
  // 만료 순간 알림과 같은 제목을 쓴다. 같은 사건이므로 다른 이름을 붙이면 별개 문제로 읽힌다.
47
64
  title: "세션 만료",
48
65
  subject: inactive.length > 1 ? `${inactive.length}건` : (inactive[0]!.name ?? "unnamed"),
49
66
  context: buildReminderContext(inactive, userMap),
50
67
  color: SLACK_COLOR.bad,
68
+ urgent,
51
69
  });
70
+ return inactive.length;
52
71
  }
53
72
 
54
- let timer: ReturnType<typeof setInterval> | null = null;
55
-
56
73
  /**
57
- * `SLACK_EXPIRY_REMINDER_INTERVAL_MINUTES` 가 양수일 때만 켜진다. 미설정·0 이면 비활성.
74
+ * 화면의 "지금 보내기". 사람이 직접 누른 것이므로 조용 시간·마스터 스위치를 통과시킨다 —
75
+ * 눌렀는데 아무 일도 안 일어나면 버튼이 고장 난 것으로 읽힌다.
58
76
  *
59
- * 인스턴스가 여러 개면 각자 보낸다. 만료 순간 알림과 달리 상태 변화가 없어 DB 로 억제할 수
60
- * 없고, 하루 몇 건 수준이라 중복을 감수하는 편이 잠금 장치를 두는 것보다 낫다.
77
+ * 보낸 건수를 돌려줘 호출부가 "보낼 대상 없음"과 "보냄"을 구분해 보여준다.
61
78
  */
62
- export function startExpiredTokenReminder(): void {
63
- const minutes = Number(process.env.SLACK_EXPIRY_REMINDER_INTERVAL_MINUTES ?? 0);
79
+ export function sendExpiredTokenReminderNow(
80
+ deps: ExpiredTokenReminderDeps = defaultDeps,
81
+ ): Promise<number> {
82
+ return sendReminder(deps, true);
83
+ }
84
+
85
+ let timer: ReturnType<typeof setInterval> | null = null;
86
+
87
+ function scheduleExpiredTokenReminder(runNow: boolean, deps: ExpiredTokenReminderDeps): void {
88
+ stopExpiredTokenReminder();
89
+
90
+ // 주기 값과 별개인 스위치다. 꺼도 주기는 남아 있어 다시 켤 때 고르던 값이 그대로다.
91
+ if (deps.readSetting("slack.remindersEnabled", "SLACK_REMINDERS_ENABLED") === "false") {
92
+ logger.info("expired token reminder disabled");
93
+ return;
94
+ }
95
+
96
+ const minutes = Number(
97
+ deps.readSetting("slack.expiryReminderMinutes", "SLACK_EXPIRY_REMINDER_INTERVAL_MINUTES") ?? 0,
98
+ );
64
99
  if (!Number.isFinite(minutes) || minutes <= 0) return;
65
100
 
66
- const userMap = getSlackUserMap();
67
- logger.info(`expired token reminder every ${minutes}m (${userMap.size} user mappings)`);
101
+ logger.info(
102
+ `expired token reminder every ${minutes}m (${deps.getSlackUserMap().size} user mappings)`,
103
+ );
68
104
 
69
105
  const run = () =>
70
- sendReminder(userMap).catch((e) => logger.warn(`reminder failed: ${(e as Error).message}`));
106
+ sendReminder(deps).catch((e) => logger.warn(`reminder failed: ${(e as Error).message}`));
107
+
108
+ if (runNow) void run();
109
+ timer = setInterval(run, minutes * MINUTE_MS);
110
+ }
71
111
 
112
+ /**
113
+ * `SLACK_EXPIRY_REMINDER_INTERVAL_MINUTES` 가 양수일 때만 켜진다. 미설정·0 이면 비활성.
114
+ *
115
+ * 인스턴스가 여러 개면 각자 보낸다. 만료 순간 알림과 달리 상태 변화가 없어 DB 로 억제할 수
116
+ * 없고, 하루 몇 건 수준이라 중복을 감수하는 편이 잠금 장치를 두는 것보다 낫다.
117
+ */
118
+ export function startExpiredTokenReminder(deps: ExpiredTokenReminderDeps = defaultDeps): void {
72
119
  // 기동 직후에도 한 번 보낸다. 재기동은 배포나 장애 복구 때 일어나는데, 그 시점에 이미
73
120
  // 만료된 토큰이 있으면 첫 주기가 돌 때까지 조용한 것이 이상하다.
74
- void run();
121
+ scheduleExpiredTokenReminder(true, deps);
122
+ }
75
123
 
76
- timer = setInterval(run, minutes * MINUTE_MS);
124
+ /** 설정 변경 시 기존 타이머만 새 주기로 교체한다. 저장 자체가 알림을 보내지는 않는다. */
125
+ export function rescheduleExpiredTokenReminder(deps: ExpiredTokenReminderDeps = defaultDeps): void {
126
+ scheduleExpiredTokenReminder(false, deps);
77
127
  }
78
128
 
79
129
  export function stopExpiredTokenReminder(): void {
@@ -0,0 +1,23 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { getSlackUserMap } from "./slack-user-map";
4
+
5
+ const getSettingMock = vi.fn();
6
+
7
+ describe("getSlackUserMap", () => {
8
+ let raw = "yds:U-OLD";
9
+
10
+ beforeEach(() => {
11
+ raw = "yds:U-OLD";
12
+ getSettingMock.mockReset();
13
+ getSettingMock.mockImplementation(() => raw);
14
+ });
15
+
16
+ it("설정이 바뀌면 다음 조회부터 새 매핑을 사용한다", () => {
17
+ expect(getSlackUserMap(getSettingMock).get("yds")).toBe("U-OLD");
18
+
19
+ raw = "yds:U-NEW";
20
+
21
+ expect(getSlackUserMap(getSettingMock).get("yds")).toBe("U-NEW");
22
+ });
23
+ });
@@ -9,6 +9,8 @@
9
9
  * 맞는 동작이다.
10
10
  */
11
11
 
12
+ import { getSetting } from "../setting/setting.store";
13
+
12
14
  /** `토큰명:SlackUserId` 목록을 파싱한다. */
13
15
  export function parseSlackUserMap(raw: string | undefined): Map<string, string> {
14
16
  const map = new Map<string, string>();
@@ -34,18 +36,6 @@ export function mentionFor(tokenName: string | null, userMap: Map<string, string
34
36
  return userId ? `<@${userId}>` : "";
35
37
  }
36
38
 
37
- /**
38
- * 환경변수는 프로세스 수명 동안 고정이라 매번 파싱할 이유가 없다. 사망 알림은 요청 경로에서
39
- * 호출되므로 캐시해 둔다.
40
- */
41
- let cached: Map<string, string> | null = null;
42
-
43
- export function getSlackUserMap(): Map<string, string> {
44
- cached ??= parseSlackUserMap(process.env.SLACK_USER_MAP);
45
- return cached;
46
- }
47
-
48
- /** 테스트에서 env 를 바꿔 끼울 때 쓴다. */
49
- export function resetSlackUserMapCache(): void {
50
- cached = null;
39
+ export function getSlackUserMap(readSetting: typeof getSetting = getSetting): Map<string, string> {
40
+ return parseSlackUserMap(readSetting("slack.userMap", "SLACK_USER_MAP"));
51
41
  }
@@ -89,6 +89,8 @@ function notifyLastActiveTokenDying(token: DeadTokenRef, reasonCode: string): vo
89
89
  ` · 풀이 비지 않도록 유지 중입니다. ` +
90
90
  `전 토큰이 동시에 실패했다면 client_id 취소나 OAuth 계약 변경을 의심하세요`,
91
91
  color: SLACK_COLOR.bad,
92
+ // provider 전체가 죽었다는 신호다. 조용 시간에 묻히면 다음 근무일까지 서비스가 멈춘다.
93
+ urgent: true,
92
94
  });
93
95
  }
94
96
 
@@ -19,7 +19,13 @@ function createSSRQuery(
19
19
 
20
20
  import { RequestLogStepListParams } from "./request-log-step/request-log-step.types";
21
21
  import { RequestLogListParams } from "./request-log/request-log.types";
22
- import { TokenSubsetKey, RequestLogStepSubsetKey, RequestLogSubsetKey } from "./sonamu.generated";
22
+ import { SettingListParams } from "./setting/setting.types";
23
+ import {
24
+ TokenSubsetKey,
25
+ SettingSubsetKey,
26
+ RequestLogStepSubsetKey,
27
+ RequestLogSubsetKey,
28
+ } from "./sonamu.generated";
23
29
  import { TokenListParams } from "./token/token.types";
24
30
 
25
31
  export namespace TokenService {
@@ -33,6 +39,20 @@ export namespace TokenService {
33
39
  createSSRQuery("TokenModel", "findMany", [subset, rawParams], ["Token", "getTokens"]);
34
40
  }
35
41
 
42
+ export namespace SettingService {
43
+ export const getSetting = <T extends SettingSubsetKey>(subset: T, id: number): SSRQuery =>
44
+ createSSRQuery("SettingModel", "findById", [subset, id], ["Setting", "getSetting"]);
45
+
46
+ export const getSettings = <T extends SettingSubsetKey, LP extends SettingListParams>(
47
+ subset: T,
48
+ rawParams?: LP,
49
+ ): SSRQuery =>
50
+ createSSRQuery("SettingModel", "findMany", [subset, rawParams], ["Setting", "getSettings"]);
51
+
52
+ export const getSettingList = (): SSRQuery =>
53
+ createSSRQuery("SettingModel", "listSettings", [], ["Setting", "getSettingList"]);
54
+ }
55
+
36
56
  export namespace RequestLogStepService {
37
57
  export const getRequestLogStep = <T extends RequestLogStepSubsetKey>(
38
58
  subset: T,
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { findSettingDef, maskSecret, SETTING_DEFS, validateSettingValue } from "./setting.constant";
4
+
5
+ describe("validateSettingValue", () => {
6
+ const intDef = findSettingDef("openai.maxWorkersPerToken")!;
7
+ const boolDef = findSettingDef("openai.autoscale")!;
8
+ const numDef = findSettingDef("openai.maxEstimatedRssGiB")!;
9
+
10
+ it("정수 범위를 벗어나면 거부한다", () => {
11
+ // 저장을 막지 않으면 런타임에서 조용히 기본값으로 떨어져 원인을 찾기 어렵다.
12
+ expect(validateSettingValue(intDef, "21")).toMatchObject({ ok: false });
13
+ expect(validateSettingValue(intDef, "0")).toMatchObject({ ok: false });
14
+ expect(validateSettingValue(intDef, "20")).toMatchObject({ ok: true, value: "20" });
15
+ });
16
+
17
+ it("정수 자리에 소수를 넣으면 거부한다", () => {
18
+ expect(validateSettingValue(intDef, "3.5")).toMatchObject({ ok: false });
19
+ });
20
+
21
+ it("숫자가 아닌 값을 거부한다", () => {
22
+ expect(validateSettingValue(intDef, "많이")).toMatchObject({ ok: false });
23
+ });
24
+
25
+ it("실수 설정은 소수를 허용한다", () => {
26
+ expect(validateSettingValue(numDef, "16.5")).toMatchObject({ ok: true, value: "16.5" });
27
+ });
28
+
29
+ it("boolean 은 true/false 만 받는다", () => {
30
+ expect(validateSettingValue(boolDef, "true")).toMatchObject({ ok: true });
31
+ expect(validateSettingValue(boolDef, "false")).toMatchObject({ ok: true });
32
+ expect(validateSettingValue(boolDef, "1")).toMatchObject({ ok: false });
33
+ });
34
+
35
+ it("앞뒤 공백은 저장 전에 떨어뜨린다", () => {
36
+ expect(validateSettingValue(intDef, " 7 ")).toMatchObject({ ok: true, value: "7" });
37
+ });
38
+ });
39
+
40
+ describe("preset 검증", () => {
41
+ const presetDef = findSettingDef("slack.expiryReminderMinutes")!;
42
+
43
+ it("목록에 있는 값만 받는다", () => {
44
+ expect(validateSettingValue(presetDef, "30")).toMatchObject({ ok: true, value: "30" });
45
+ expect(validateSettingValue(presetDef, "180")).toMatchObject({ ok: true });
46
+ });
47
+
48
+ it("목록 밖의 값은 거부한다", () => {
49
+ // 저장되면 화면에서 아무 버튼도 선택돼 보이지 않아 "왜 안 바뀌지"가 된다.
50
+ expect(validateSettingValue(presetDef, "45")).toMatchObject({ ok: false });
51
+ expect(validateSettingValue(presetDef, "0")).toMatchObject({ ok: false });
52
+ expect(validateSettingValue(presetDef, "많이")).toMatchObject({ ok: false });
53
+ });
54
+ });
55
+
56
+ describe("조용 시간 설정", () => {
57
+ // fallback("20"/"8")은 quiet-hours.ts 의 DEFAULT_QUIET_* 와 같아야 하지만 테스트로 묶지
58
+ // 않는다 — 두 모듈을 한 파일에서 import 하면 setting.store → token.model 이 딸려와,
59
+ // token.model 을 mock 하는 테스트가 같은 워커에 배치될 때 실제 모듈을 먼저 로드해 버린다.
60
+ it("0..23 을 벗어나면 거부한다", () => {
61
+ const fromDef = findSettingDef("slack.quietFromHour")!;
62
+ expect(validateSettingValue(fromDef, "24")).toMatchObject({ ok: false });
63
+ expect(validateSettingValue(fromDef, "-1")).toMatchObject({ ok: false });
64
+ expect(validateSettingValue(fromDef, "0")).toMatchObject({ ok: true });
65
+ expect(validateSettingValue(fromDef, "23")).toMatchObject({ ok: true });
66
+ });
67
+ });
68
+
69
+ describe("maskSecret", () => {
70
+ it("긴 값은 앞뒤만 남긴다", () => {
71
+ expect(maskSecret("xoxb-1234567890-abcdefghij")).toBe("xoxb-123...ghij");
72
+ });
73
+
74
+ it("짧은 값은 통째로 가린다", () => {
75
+ // 앞뒤를 남기면 짧은 값은 사실상 전부 노출된다.
76
+ expect(maskSecret("short")).toBe("***");
77
+ });
78
+ });
79
+
80
+ describe("SETTING_DEFS", () => {
81
+ it("키가 중복되지 않는다", () => {
82
+ const keys = SETTING_DEFS.map((d) => d.key);
83
+ expect(new Set(keys).size).toBe(keys.length);
84
+ });
85
+
86
+ it("env 키도 중복되지 않는다", () => {
87
+ // 겹치면 한 env 를 두 설정이 서로 덮어쓴다.
88
+ const envKeys = SETTING_DEFS.map((d) => d.envKey);
89
+ expect(new Set(envKeys).size).toBe(envKeys.length);
90
+ });
91
+
92
+ it("정의되지 않은 키는 찾지 못한다", () => {
93
+ expect(findSettingDef("nope.bad")).toBeUndefined();
94
+ });
95
+
96
+ it("64 GiB 호스트 기준 메모리 설정의 오입력 상한을 둔다", () => {
97
+ const rss = findSettingDef("openai.maxEstimatedRssGiB")!;
98
+ const available = findSettingDef("openai.minHostAvailableGiB")!;
99
+
100
+ expect(rss.max).toBe(32);
101
+ expect(available.max).toBe(64);
102
+ expect(validateSettingValue(rss, "32")).toMatchObject({ ok: true });
103
+ expect(validateSettingValue(rss, "32.5")).toMatchObject({ ok: false });
104
+ expect(validateSettingValue(available, "64")).toMatchObject({ ok: true });
105
+ expect(validateSettingValue(available, "64.5")).toMatchObject({ ok: false });
106
+ expect(rss.help).toContain("OOM");
107
+ expect(available.help).toContain("오토스케일");
108
+ });
109
+ });