@cartanova/qgrid-cli 2.6.6 → 2.6.8

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 +37 -16
  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.model.js +236 -0
  12. package/bundle/dist/application/setting/setting.schema.js +157 -0
  13. package/bundle/dist/application/setting/setting.store.js +66 -0
  14. package/bundle/dist/application/setting/setting.types.js +48 -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 +43 -0
  25. package/bundle/dist/utils/slack-notify.js +12 -5
  26. package/bundle/src/application/qgrid/expired-token-reminder.test.ts +135 -13
  27. package/bundle/src/application/qgrid/expired-token-reminder.ts +60 -21
  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.entity.json +59 -0
  33. package/bundle/src/application/setting/setting.model.ts +263 -0
  34. package/bundle/src/application/setting/setting.schema.test.ts +80 -0
  35. package/bundle/src/application/setting/setting.schema.ts +174 -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 +51 -0
  39. package/bundle/src/application/sonamu.generated.http +47 -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 +10 -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 +40 -0
  50. package/bundle/src/utils/slack-notify.test.ts +88 -20
  51. package/bundle/src/utils/slack-notify.ts +24 -4
  52. package/bundle/web-dist/client/assets/index-7SZyiqAx.css +1 -0
  53. package/bundle/web-dist/client/assets/index-DrsXE13r.js +78 -0
  54. package/bundle/web-dist/client/assets/{logs-CvmI4KuC.js → logs-e4-6aYnO.js} +1 -1
  55. package/bundle/web-dist/client/assets/{monit-CUueNzoL.js → monit-eOAlYTK9.js} +1 -1
  56. package/bundle/web-dist/client/assets/plus-p4vtg6LN.js +1 -0
  57. package/bundle/web-dist/client/assets/{routes-CAfjRsMY.js → routes-Qie4ogtJ.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-T4ctVcia.js +1 -0
  60. package/bundle/web-dist/client/assets/settings-CTVZpAAg.js +1 -0
  61. package/bundle/web-dist/client/assets/{show-C5vKpp5z.js → show-60iOOM-e.js} +1 -1
  62. package/bundle/web-dist/client/assets/tokens-DdDccGlX.js +1 -0
  63. package/bundle/web-dist/client/assets/{use-oauth-login-flow-B3o4CXDW.js → use-oauth-login-flow-0ZGq74bZ.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-DmkzGxGU.js} +4 -4
  67. package/bundle/web-dist/server/assets/{monit-UmcqHroX.js → monit-DyKLJX4s.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-g62E9xmC.js} +5 -4
  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-DADr9OGH.js} +111 -2
  73. package/bundle/web-dist/server/assets/settings-CMJKayff.js +332 -0
  74. package/bundle/web-dist/server/assets/{show-C-oSdzHX.js → show-BGAiCPGq.js} +4 -4
  75. package/bundle/web-dist/server/assets/{tokens-Dkd_uXEk.js → tokens-DmhIfVct.js} +6 -21
  76. package/bundle/web-dist/server/assets/{use-oauth-login-flow-YM1nsd-H.js → use-oauth-login-flow-B6MY65IN.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 +14 -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 -1
  83. package/bundle/web-dist/client/assets/index-B4GlN1IX.css +0 -1
  84. package/bundle/web-dist/client/assets/index-qs8K8bOv.js +0 -78
  85. package/bundle/web-dist/client/assets/services.generated-BsAvotjG.js +0 -1
  86. package/bundle/web-dist/client/assets/tokens-C9KKP2Yq.js +0 -1
  87. /package/bundle/web-dist/server/assets/{cost-CSH_t17I.js → cost-DzjfVtUr.js} +0 -0
@@ -0,0 +1,51 @@
1
+ import { z } from "zod";
2
+
3
+ import { SettingBaseSchema, SettingBaseListParams } from "../sonamu.generated";
4
+
5
+ // Setting - ListParams
6
+ export const SettingListParams = SettingBaseListParams;
7
+ export type SettingListParams = z.infer<typeof SettingListParams>;
8
+
9
+ // Setting - SaveParams
10
+ export const SettingSaveParams = SettingBaseSchema.partial({ id: true, created_at: true });
11
+ export type SettingSaveParams = z.infer<typeof SettingSaveParams>;
12
+
13
+ // ── 설정 API 응답 스키마 ──
14
+
15
+ export const SettingKind = z.enum(["boolean", "integer", "number", "string", "secret"]);
16
+ export type SettingKind = z.infer<typeof SettingKind>;
17
+
18
+ export const SettingApplies = z.enum(["immediate", "restart"]);
19
+ export type SettingApplies = z.infer<typeof SettingApplies>;
20
+
21
+ export const SettingSource = z.enum(["db", "env", "default"]);
22
+ export type SettingSource = z.infer<typeof SettingSource>;
23
+
24
+ export const SettingItem = z.object({
25
+ key: z.string(),
26
+ group: z.string(),
27
+ label: z.string(),
28
+ kind: SettingKind,
29
+ applies: SettingApplies,
30
+ /** 현재 적용 중인 값. 눈 토글을 지원하기 위해 secret 도 원본으로 내려간다. */
31
+ value: z.string(),
32
+ /** 이 값이 어디서 왔는지. 화면에서 출처를 보여줘야 "왜 안 바뀌지"가 없다. */
33
+ source: SettingSource,
34
+ min: z.number().nullable(),
35
+ max: z.number().nullable(),
36
+ help: z.string().nullable(),
37
+ });
38
+ export type SettingItem = z.infer<typeof SettingItem>;
39
+
40
+ /** 화면에서 바꿀 수 없는 기동 시점 값들 — 어떤 환경에 붙어 있는지 확인용. */
41
+ export const RuntimeInfoItem = z.object({
42
+ label: z.string(),
43
+ value: z.string(),
44
+ });
45
+ export type RuntimeInfoItem = z.infer<typeof RuntimeInfoItem>;
46
+
47
+ export const SettingsResponse = z.object({
48
+ settings: SettingItem.array(),
49
+ runtime: RuntimeInfoItem.array(),
50
+ });
51
+ export type SettingsResponse = z.infer<typeof SettingsResponse>;
@@ -70,6 +70,53 @@ Content-Type: application/json
70
70
 
71
71
  ###
72
72
 
73
+ GET {{baseUrl}}/api/setting/findById
74
+ ?subset=A
75
+ &id=0
76
+ Content-Type: application/json
77
+
78
+ ###
79
+
80
+ GET {{baseUrl}}/api/setting/findMany
81
+ ?subset=A
82
+ &rawParams[num]=24
83
+ &rawParams[page]=1
84
+ &rawParams[search]=key
85
+ &rawParams[keyword]=KEYWORD
86
+ &rawParams[orderBy]=key-asc
87
+ &rawParams[queryMode]=both
88
+ &rawParams[id]=0
89
+ &rawParams[sonamuFilter]=unknown-custom
90
+ &rawParams[key]=KEY
91
+ Content-Type: application/json
92
+
93
+ ###
94
+
95
+ GET {{baseUrl}}/api/setting/listSettings
96
+ ?
97
+ Content-Type: application/json
98
+
99
+ ###
100
+
101
+ POST {{baseUrl}}/api/setting/updateSetting
102
+ Content-Type: application/json
103
+
104
+ {
105
+ "key": "KEY",
106
+ "value": "VALUE"
107
+ }
108
+
109
+ ###
110
+
111
+ POST {{baseUrl}}/api/setting/resetSetting
112
+ Content-Type: application/json
113
+
114
+ {
115
+ "key": "KEY"
116
+ }
117
+
118
+ ###
119
+
73
120
  GET {{baseUrl}}/api/requestLogStep/findById
74
121
  ?subset=A
75
122
  &id=0
@@ -8,9 +8,11 @@ import { type PuriWrapper, type DatabaseSchemaExtend, type PuriLoaderQueries } f
8
8
  import {
9
9
  type RequestLogSubsetKey,
10
10
  type RequestLogStepSubsetKey,
11
+ type SettingSubsetKey,
11
12
  type TokenSubsetKey,
12
13
  type RequestLogBaseSchema,
13
14
  type RequestLogStepBaseSchema,
15
+ type SettingBaseSchema,
14
16
  type TokenBaseSchema,
15
17
  } from "./sonamu.generated";
16
18
 
@@ -176,6 +178,24 @@ export const requestLogStepLoaderQueries = {
176
178
  I: [],
177
179
  } as const satisfies PuriLoaderQueries<RequestLogStepSubsetKey>;
178
180
 
181
+ // SubsetQuery: Setting
182
+ export const settingSubsetQueries = {
183
+ A: (qbWrapper: PuriWrapper<DatabaseSchemaExtend>) => {
184
+ return qbWrapper.from("settings").select({
185
+ id: "settings.id",
186
+ created_at: "settings.created_at",
187
+ key: "settings.key",
188
+ value: "settings.value",
189
+ updated_at: "settings.updated_at",
190
+ });
191
+ },
192
+ };
193
+
194
+ // LoaderQuery: Setting
195
+ export const settingLoaderQueries = {
196
+ A: [],
197
+ } as const satisfies PuriLoaderQueries<SettingSubsetKey>;
198
+
179
199
  // SubsetQuery: Token
180
200
  export const tokenSubsetQueries = {
181
201
  A: (qbWrapper: PuriWrapper<DatabaseSchemaExtend>) => {
@@ -206,6 +226,7 @@ declare module "sonamu" {
206
226
  export interface DatabaseSchemaExtend {
207
227
  request_logs: RequestLogBaseSchema;
208
228
  request_log_steps: RequestLogStepBaseSchema;
229
+ settings: SettingBaseSchema;
209
230
  tokens: TokenBaseSchema;
210
231
  }
211
232
 
@@ -127,6 +127,14 @@ export const RequestLogStepSearchField = z.enum(["id"]).describe("RequestLogStep
127
127
  export type RequestLogStepSearchField = z.infer<typeof RequestLogStepSearchField>;
128
128
  export const RequestLogStepSearchFieldLabel = { id: "ID" };
129
129
 
130
+ // Enums: Setting
131
+ export const SettingOrderBy = z.enum(["key-asc"]).describe("SettingOrderBy");
132
+ export type SettingOrderBy = z.infer<typeof SettingOrderBy>;
133
+ export const SettingOrderByLabel = { "key-asc": "키순" };
134
+ export const SettingSearchField = z.enum(["key"]).describe("SettingSearchField");
135
+ export type SettingSearchField = z.infer<typeof SettingSearchField>;
136
+ export const SettingSearchFieldLabel = { key: "키" };
137
+
130
138
  // Enums: Token
131
139
  export const TokenOrderBy = z.enum(["id-desc", "ord-asc"]).describe("TokenOrderBy");
132
140
  export type TokenOrderBy = z.infer<typeof TokenOrderBy>;
@@ -262,6 +270,18 @@ export type RequestLogStepBaseSchema = z.infer<typeof RequestLogStepBaseSchema>
262
270
  ];
263
271
  };
264
272
 
273
+ // BaseSchema: Setting
274
+ export const SettingBaseSchema = z.object({
275
+ id: z.int(),
276
+ created_at: z.date(),
277
+ key: z.string().max(100),
278
+ value: z.string(),
279
+ updated_at: z.date().nullable(),
280
+ });
281
+ export type SettingBaseSchema = z.infer<typeof SettingBaseSchema> & {
282
+ readonly __hasDefault__: readonly ["created_at", "updated_at", "id"];
283
+ };
284
+
265
285
  // BaseSchema: Token
266
286
  export const TokenBaseSchema = z.object({
267
287
  id: z.int(),
@@ -319,6 +339,22 @@ export const RequestLogStepBaseListParams = z
319
339
  .partial();
320
340
  export type RequestLogStepBaseListParams = z.infer<typeof RequestLogStepBaseListParams>;
321
341
 
342
+ // BaseListParams: Setting
343
+ export const SettingBaseListParams = z
344
+ .object({
345
+ num: z.number().int().nonnegative(),
346
+ page: z.number().int().min(1),
347
+ search: SettingSearchField,
348
+ keyword: z.string(),
349
+ orderBy: SettingOrderBy,
350
+ queryMode: SonamuQueryMode,
351
+ id: zArrayable(z.number().int().positive()),
352
+ sonamuFilter: z.custom<ApplySonamuFilter<SettingBaseSchema, never, never>>(),
353
+ key: z.string().max(100),
354
+ })
355
+ .partial();
356
+ export type SettingBaseListParams = z.infer<typeof SettingBaseListParams>;
357
+
322
358
  // BaseListParams: Token
323
359
  export const TokenBaseListParams = z
324
360
  .object({
@@ -486,6 +522,21 @@ export type RequestLogStepSubsetMapping = {
486
522
  export const RequestLogStepSubsetKey = z.enum(["A", "T", "I"]);
487
523
  export type RequestLogStepSubsetKey = z.infer<typeof RequestLogStepSubsetKey>;
488
524
 
525
+ // Subsets: Setting
526
+ export const SettingSubsetA = z.object({
527
+ id: z.int(),
528
+ created_at: z.date(),
529
+ key: z.string().max(100),
530
+ value: z.string(),
531
+ updated_at: z.date().nullable(),
532
+ });
533
+ export type SettingSubsetA = z.infer<typeof SettingSubsetA>;
534
+ export type SettingSubsetMapping = {
535
+ A: SettingSubsetA;
536
+ };
537
+ export const SettingSubsetKey = z.enum(["A"]);
538
+ export type SettingSubsetKey = z.infer<typeof SettingSubsetKey>;
539
+
489
540
  // Subsets: Token
490
541
  export const TokenSubsetA = z.object({
491
542
  id: z.int(),
@@ -47,6 +47,8 @@ export default {
47
47
  "error.internalServerError": "Internal server error",
48
48
  "error.notFound": "Not found",
49
49
  "error.serviceUnavailable": "Service unavailable",
50
+ "setting.invalidValue": (reason: string) => `Invalid setting value: ${reason}`,
51
+ "setting.unknownKey": (key: string) => `Unknown setting key ${key}`,
50
52
  "qgrid.dispatcherFailed": (provider: string) => `${provider} dispatcher failed to initialize`,
51
53
  "qgrid.dispatcherStarting": (provider: string) =>
52
54
  `${provider} dispatcher is starting up. Please retry shortly`,
@@ -46,6 +46,8 @@ export default {
46
46
  "error.internalServerError": "서버 오류가 발생했습니다",
47
47
  "error.notFound": "찾을 수 없습니다",
48
48
  "error.serviceUnavailable": "서비스를 사용할 수 없습니다",
49
+ "setting.invalidValue": (reason: string) => `설정 값이 올바르지 않습니다: ${reason}`,
50
+ "setting.unknownKey": (key: string) => `알 수 없는 설정 키 ${key}`,
49
51
  "qgrid.dispatcherFailed": (provider: string) => `${provider} dispatcher 초기화에 실패했습니다`,
50
52
  "qgrid.dispatcherStarting": (provider: string) =>
51
53
  `${provider} dispatcher 기동 중입니다. 잠시 후 다시 시도해주세요`,
@@ -297,6 +297,14 @@ const entityLabels = {
297
297
  "enum.RequestLogStepType.tool_call": "Tool 호출",
298
298
  "enum.RequestLogStepOrderBy.id-asc": "ID순",
299
299
  "enum.RequestLogStepSearchField.id": "ID",
300
+ "entity.Setting": "Setting",
301
+ "entity.Setting.id": "ID",
302
+ "entity.Setting.created_at": "등록일시",
303
+ "entity.Setting.key": "설정 키 (예: openai.maxWorkersPerToken)",
304
+ "entity.Setting.value": "설정 값 (문자열 저장, 앱에서 파싱·검증)",
305
+ "entity.Setting.updated_at": "최종 수정일시",
306
+ "enum.SettingOrderBy.key-asc": "키순",
307
+ "enum.SettingSearchField.key": "키",
300
308
  "entity.Token": "Token",
301
309
  "entity.Token.id": "ID",
302
310
  "entity.Token.created_at": "등록일시",
@@ -0,0 +1,19 @@
1
+ import { type Knex } from "knex";
2
+
3
+ export async function up(knex: Knex): Promise<void> {
4
+ await knex.schema.createTable("settings", (table) => {
5
+ table.increments().primary();
6
+ table
7
+ .timestamp("created_at", { useTz: true, precision: 3 })
8
+ .notNullable()
9
+ .defaultTo(knex.raw("CURRENT_TIMESTAMP"));
10
+ table.string("key", 100).notNullable();
11
+ table.text("value").notNullable();
12
+ table.timestamp("updated_at", { useTz: true, precision: 3 }).nullable();
13
+ });
14
+ await knex.raw(`CREATE UNIQUE INDEX settings_key_unique ON settings USING btree(key);`);
15
+ }
16
+
17
+ export async function down(knex: Knex): Promise<void> {
18
+ return knex.schema.dropTable("settings");
19
+ }
@@ -8,6 +8,7 @@ import { drivers as cacheDrivers, store } from "sonamu/cache";
8
8
 
9
9
  import { monitLogSink } from "./application/monit/log-buffer";
10
10
  import {
11
+ rescheduleExpiredTokenReminder,
11
12
  startExpiredTokenReminder,
12
13
  stopExpiredTokenReminder,
13
14
  } from "./application/qgrid/expired-token-reminder";
@@ -15,6 +16,7 @@ import { QgridDispatcher } from "./application/qgrid/qgrid.dispatcher";
15
16
  import { QgridFrame } from "./application/qgrid/qgrid.frame";
16
17
  import { TokenSubscriber } from "./application/qgrid/token-subscriber";
17
18
  import { ensureTokensTrigger } from "./application/qgrid/token-trigger-setup";
19
+ import { loadSettings, setSettingChangeHandler } from "./application/setting/setting.store";
18
20
  import { handleServerError } from "./server-error-handler";
19
21
  import { AnthropicDispatcher } from "./utils/providers/anthropic/anthropic-dispatcher";
20
22
  import { OpenAIDispatcher } from "./utils/providers/openai/openai-dispatcher";
@@ -170,6 +172,13 @@ export default defineConfig({
170
172
  onStart: async () => {
171
173
  const log = getLogger(["qgrid", "startup"]);
172
174
 
175
+ // dispatcher 가 생성자에서 워커 설정을 읽으므로 그 전에 올려야 한다.
176
+ await loadSettings();
177
+ // 런타임 설정 소비자를 상위 계층에서 연결해 setting 모듈이 qgrid 를 역참조하지 않게 한다.
178
+ setSettingChangeHandler((key) => {
179
+ if (key === "slack.expiryReminderMinutes") rescheduleExpiredTokenReminder();
180
+ });
181
+
173
182
  let triggerReady = true;
174
183
  try {
175
184
  await ensureTokensTrigger(connConfig);
@@ -221,6 +230,7 @@ export default defineConfig({
221
230
  onShutdown: async () => {
222
231
  const log = getLogger(["qgrid", "startup"]);
223
232
  stopExpiredTokenReminder();
233
+ setSettingChangeHandler(null);
224
234
  if (QgridDispatcher.openaiDispatcher) {
225
235
  await QgridDispatcher.openaiDispatcher.stop();
226
236
  }
@@ -251,6 +251,18 @@ describe("OpenAIDispatcher worker capacity", () => {
251
251
  expect(estimateOpenAIWorkerRssGiB(50)).toBeCloseTo(8.56, 2);
252
252
  expect(estimateOpenAIWorkerRssGiB(60)).toBeCloseTo(10.13, 2);
253
253
  });
254
+
255
+ it("clamps memory guard inputs to the 64 GiB host policy", () => {
256
+ expect(
257
+ resolveOpenAIWorkerPoolConfig({
258
+ QGRID_OPENAI_MAX_ESTIMATED_RSS_GIB: "999",
259
+ QGRID_OPENAI_MIN_HOST_AVAILABLE_GIB: "999",
260
+ }),
261
+ ).toMatchObject({
262
+ maxEstimatedRssGiB: 32,
263
+ minHostAvailableGiB: 64,
264
+ });
265
+ });
254
266
  });
255
267
 
256
268
  describe("OpenAIDispatcher monit stats", () => {
@@ -1,3 +1,10 @@
1
+ import {
2
+ MAX_OPENAI_ESTIMATED_RSS_GIB,
3
+ MAX_OPENAI_MIN_HOST_AVAILABLE_GIB,
4
+ SETTING_DEFS,
5
+ } from "../../../application/setting/setting.schema";
6
+ import { getSetting } from "../../../application/setting/setting.store";
7
+
1
8
  export const MAX_OPENAI_WORKERS_PER_TOKEN = 20;
2
9
  const DEFAULT_OPENAI_MIN_WORKERS_PER_TOKEN = 1;
3
10
  const DEFAULT_OPENAI_MAX_WORKERS_PER_TOKEN = 3;
@@ -26,14 +33,35 @@ function boundedInteger(
26
33
  return Math.max(min, Math.min(Math.floor(parsed), max));
27
34
  }
28
35
 
29
- function boundedNumber(value: string | undefined, fallback: number, min: number): number {
36
+ function boundedNumber(
37
+ value: string | undefined,
38
+ fallback: number,
39
+ min: number,
40
+ max: number,
41
+ ): number {
30
42
  const parsed = Number(value);
31
43
  if (!Number.isFinite(parsed)) return fallback;
32
- return Math.max(min, parsed);
44
+ return Math.max(min, Math.min(parsed, max));
45
+ }
46
+
47
+ /**
48
+ * 설정 저장소(DB) 를 먼저 보고 없으면 env 로 떨어지는 조회 객체.
49
+ *
50
+ * 기존 파싱 로직이 `env[KEY]` 형태를 전제하므로, 소스만 바꿔 끼우고 검증·기본값 처리는
51
+ * 그대로 둔다. `get` 접근만 지원하므로 `in`이나 `Object.keys()`로 조회하지 않는다. 테스트는
52
+ * `env` 인자에 순수한 맵을 넘겨 저장소를 우회한다.
53
+ */
54
+ function settingsBackedEnv(): Record<string, string | undefined> {
55
+ return new Proxy({} as Record<string, string | undefined>, {
56
+ get: (_target, prop: string) => {
57
+ const def = SETTING_DEFS.find((d) => d.envKey === prop);
58
+ return def ? getSetting(def.key, def.envKey) : process.env[prop];
59
+ },
60
+ });
33
61
  }
34
62
 
35
63
  export function resolveOpenAIWorkerPoolConfig(
36
- env: Record<string, string | undefined> = process.env,
64
+ env: Record<string, string | undefined> = settingsBackedEnv(),
37
65
  ): OpenAIWorkerPoolConfig {
38
66
  const autoscale = env.QGRID_OPENAI_AUTOSCALE !== "false" && env.QGRID_OPENAI_AUTOSCALE !== "0";
39
67
  const minWorkersPerToken = boundedInteger(
@@ -64,8 +92,18 @@ export function resolveOpenAIWorkerPoolConfig(
64
92
  1_000,
65
93
  24 * 60 * 60_000,
66
94
  ),
67
- maxEstimatedRssGiB: boundedNumber(env.QGRID_OPENAI_MAX_ESTIMATED_RSS_GIB, 16, 1),
68
- minHostAvailableGiB: boundedNumber(env.QGRID_OPENAI_MIN_HOST_AVAILABLE_GIB, 20, 0),
95
+ maxEstimatedRssGiB: boundedNumber(
96
+ env.QGRID_OPENAI_MAX_ESTIMATED_RSS_GIB,
97
+ 16,
98
+ 1,
99
+ MAX_OPENAI_ESTIMATED_RSS_GIB,
100
+ ),
101
+ minHostAvailableGiB: boundedNumber(
102
+ env.QGRID_OPENAI_MIN_HOST_AVAILABLE_GIB,
103
+ 20,
104
+ 0,
105
+ MAX_OPENAI_MIN_HOST_AVAILABLE_GIB,
106
+ ),
69
107
  };
70
108
  }
71
109
 
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Slack 알림 조용 시간.
3
+ *
4
+ * 토큰 만료는 사람이 재로그인해야 풀리는데, 그 사람이 자거나 쉬는 동안 알려도 할 수 있는
5
+ * 일이 없다. 평일 업무 시간에만 알리고 나머지는 다음 근무 시간으로 미룬다 — 만료 알림은
6
+ * 주기적으로 반복되므로 조용 시간에 버려도 아침 첫 주기에 다시 온다.
7
+ *
8
+ * 다만 provider 의 마지막 토큰이 죽는 것은 서비스 정지 신호라 이 규칙에서 제외한다
9
+ * (호출부가 `notifySlack` 을 그냥 호출하면 된다).
10
+ */
11
+
12
+ /** 이 시각부터(포함) 조용해진다. */
13
+ const QUIET_FROM_HOUR = 20;
14
+ /** 이 시각부터(포함) 다시 알린다. */
15
+ const QUIET_UNTIL_HOUR = 8;
16
+
17
+ /**
18
+ * 서버가 UTC 로 돌아도 한국 시간으로 판정한다. dev0 는 UTC 이므로 `getHours()` 를 그대로
19
+ * 쓰면 "저녁 8시"가 한국 시간 새벽 5시가 된다.
20
+ */
21
+ 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);
29
+ const hour = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
30
+ const weekday = parts.find((p) => p.type === "weekday")?.value ?? "";
31
+ // Intl 은 자정을 24 로 줄 수 있다. 0..23 으로 맞춘다.
32
+ return { hour: hour % 24, weekday };
33
+ }
34
+
35
+ /** 주말이거나 20시~익일 8시면 조용 시간. */
36
+ export function isQuietHours(at: Date = new Date()): boolean {
37
+ const { hour, weekday } = seoulParts(at);
38
+ if (weekday === "Sat" || weekday === "Sun") return true;
39
+ return hour >= QUIET_FROM_HOUR || hour < QUIET_UNTIL_HOUR;
40
+ }
@@ -1,21 +1,29 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
- import { notifySlack, SLACK_COLOR } from "./slack-notify";
3
+ import { isQuietHours } from "./quiet-hours";
4
+ import { notifySlack, SLACK_COLOR, type SlackNotification } from "./slack-notify";
4
5
 
5
- describe("notifySlack", () => {
6
- const savedToken = process.env.SLACK_BOT_TOKEN;
7
- const savedChannel = process.env.SLACK_CHANNEL_ID;
6
+ const settingMock = vi.fn();
7
+
8
+ const notify = (notification: SlackNotification) => notifySlack(notification, settingMock);
9
+
10
+ /** 한국 시간을 UTC 로 표현한다 — dev0 가 UTC 라 이 변환이 실제 운영 조건이다. */
11
+ function seoul(iso: string): Date {
12
+ return new Date(`${iso}+09:00`);
13
+ }
14
+
15
+ /** 평일 업무 시간. 조용 시간에 막히지 않도록 전송 케이스의 기준 시각으로 쓴다. */
16
+ const WORKING_HOURS = seoul("2026-08-05T14:00:00");
8
17
 
18
+ describe("notifySlack", () => {
9
19
  beforeEach(() => {
10
- process.env.SLACK_BOT_TOKEN = "xoxb-test";
11
- process.env.SLACK_CHANNEL_ID = "C123";
20
+ settingMock.mockImplementation((key: string) =>
21
+ key === "slack.botToken" ? "xoxb-test" : key === "slack.channelId" ? "C123" : undefined,
22
+ );
12
23
  });
13
24
 
14
25
  afterEach(() => {
15
- if (savedToken === undefined) delete process.env.SLACK_BOT_TOKEN;
16
- else process.env.SLACK_BOT_TOKEN = savedToken;
17
- if (savedChannel === undefined) delete process.env.SLACK_CHANNEL_ID;
18
- else process.env.SLACK_CHANNEL_ID = savedChannel;
26
+ settingMock.mockReset();
19
27
  vi.restoreAllMocks();
20
28
  });
21
29
 
@@ -24,11 +32,12 @@ describe("notifySlack", () => {
24
32
  .spyOn(globalThis, "fetch")
25
33
  .mockResolvedValue(new Response(JSON.stringify({ ok: true })));
26
34
 
27
- await notifySlack({
35
+ await notify({
28
36
  title: "토큰 추가",
29
37
  subject: "anthropic/test-token",
30
38
  context: "anthropic · 요청 처리에 사용됩니다",
31
39
  color: SLACK_COLOR.good,
40
+ now: WORKING_HOURS,
32
41
  });
33
42
 
34
43
  expect(fetchMock).toHaveBeenCalledTimes(1);
@@ -62,11 +71,12 @@ describe("notifySlack", () => {
62
71
  .spyOn(globalThis, "fetch")
63
72
  .mockResolvedValue(new Response(JSON.stringify({ ok: true })));
64
73
 
65
- await notifySlack({ title: "제목만" });
74
+ await notify({ title: "제목만", now: WORKING_HOURS });
66
75
 
67
- const body = JSON.parse(
68
- (fetchMock.mock.calls[0]![1] as RequestInit).body as string,
69
- ) as { text?: string; attachments: [{ fallback: string; color?: string; blocks: unknown[] }] };
76
+ const body = JSON.parse((fetchMock.mock.calls[0]![1] as RequestInit).body as string) as {
77
+ text?: string;
78
+ attachments: [{ fallback: string; color?: string; blocks: unknown[] }];
79
+ };
70
80
  // 최상위 text 를 보내지 않아야 blocks 렌더 화면에서 제목이 중복되지 않는다.
71
81
  expect(body.text).toBeUndefined();
72
82
  expect(body.attachments[0].fallback).toBe("제목만");
@@ -74,11 +84,11 @@ describe("notifySlack", () => {
74
84
  expect(body.attachments[0].blocks).toHaveLength(1);
75
85
  });
76
86
 
77
- it("skips the request entirely when env is not configured", async () => {
78
- delete process.env.SLACK_BOT_TOKEN;
87
+ it("skips the request entirely when Slack is not configured", async () => {
88
+ settingMock.mockReturnValue(undefined);
79
89
  const fetchMock = vi.spyOn(globalThis, "fetch");
80
90
 
81
- await expect(notifySlack({ title: "hello" })).resolves.toBeUndefined();
91
+ await expect(notify({ title: "hello", now: WORKING_HOURS })).resolves.toBeUndefined();
82
92
 
83
93
  expect(fetchMock).not.toHaveBeenCalled();
84
94
  });
@@ -88,12 +98,70 @@ describe("notifySlack", () => {
88
98
  new Response(JSON.stringify({ ok: false, error: "not_in_channel" })),
89
99
  );
90
100
 
91
- await expect(notifySlack({ title: "hello" })).resolves.toBeUndefined();
101
+ await expect(notify({ title: "hello", now: WORKING_HOURS })).resolves.toBeUndefined();
92
102
  });
93
103
 
94
104
  it("swallows network and timeout failures", async () => {
95
105
  vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timed out"));
96
106
 
97
- await expect(notifySlack({ title: "hello" })).resolves.toBeUndefined();
107
+ await expect(notify({ title: "hello", now: WORKING_HOURS })).resolves.toBeUndefined();
108
+ });
109
+
110
+ it("조용 시간에는 보통 알림을 보내지 않는다", async () => {
111
+ const fetchMock = vi.spyOn(globalThis, "fetch");
112
+
113
+ await notify({ title: "세션 만료", now: seoul("2026-08-05T22:00:00") });
114
+
115
+ expect(fetchMock).not.toHaveBeenCalled();
116
+ });
117
+
118
+ it("urgent 알림은 조용 시간에도 보낸다", async () => {
119
+ // provider 전체가 죽은 상황은 다음 근무일까지 미룰 수 없다.
120
+ const fetchMock = vi
121
+ .spyOn(globalThis, "fetch")
122
+ .mockResolvedValue(new Response(JSON.stringify({ ok: true })));
123
+
124
+ await notify({
125
+ title: "마지막 토큰 사망",
126
+ urgent: true,
127
+ now: seoul("2026-08-05T22:00:00"),
128
+ });
129
+
130
+ expect(fetchMock).toHaveBeenCalledTimes(1);
131
+ });
132
+ });
133
+
134
+ describe("isQuietHours", () => {
135
+ it("평일 업무 시간에는 알린다", () => {
136
+ // 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);
139
+ });
140
+
141
+ 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);
146
+ });
147
+
148
+ it("주말은 시간과 무관하게 조용하다", () => {
149
+ // 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);
152
+ });
153
+
154
+ it("월요일 8시에 다시 알리기 시작한다", () => {
155
+ expect(isQuietHours(seoul("2026-08-10T07:59:00"))).toBe(true);
156
+ expect(isQuietHours(seoul("2026-08-10T08:00:00"))).toBe(false);
157
+ });
158
+
159
+ it("서버가 UTC 여도 한국 시간으로 판정한다", () => {
160
+ // UTC 23시 = 한국 다음날 08시 → 업무 시간
161
+ expect(isQuietHours(new Date("2026-08-04T23:00:00Z"))).toBe(false);
162
+ // UTC 12시 = 한국 21시 → 조용 시간
163
+ expect(isQuietHours(new Date("2026-08-05T12:00:00Z"))).toBe(true);
164
+ // UTC 금요일 22시 = 한국 토요일 07시 → 주말
165
+ expect(isQuietHours(new Date("2026-08-07T22:00:00Z"))).toBe(true);
98
166
  });
99
167
  });
@@ -2,10 +2,14 @@
2
2
  * Slack 알림 (chat.postMessage).
3
3
  *
4
4
  * fail-open: env 미설정이면 조용히 no-op 이고, 전송 실패는 warn 로그로만 남긴다.
5
+ * 조용 시간(주말·20~8시)에는 `urgent` 가 아닌 알림을 보내지 않는다.
5
6
  * 알림 경로가 토큰 라우팅이나 오류 전파를 지연·차단해서는 안 된다.
6
7
  */
7
8
  import { getLogger } from "@logtape/logtape";
8
9
 
10
+ import { getSetting } from "../application/setting/setting.store";
11
+ import { isQuietHours } from "./quiet-hours";
12
+
9
13
  const logger = getLogger(["qgrid", "slack"]);
10
14
 
11
15
  const SLACK_POST_MESSAGE_URL = "https://slack.com/api/chat.postMessage";
@@ -25,17 +29,33 @@ export type SlackNotification = {
25
29
  /** 제목 아래 작은 글씨로 내려가는 부가 정보 — 읽지 않아도 되는 것들. */
26
30
  context?: string;
27
31
  color?: (typeof SLACK_COLOR)[keyof typeof SLACK_COLOR];
32
+ /**
33
+ * 조용 시간(주말·20~8시)에도 보낸다. 서비스가 멈춘 상태처럼 지금 알지 않으면 손해가
34
+ * 커지는 사건에만 쓴다 — 남용하면 조용 시간 자체가 무의미해진다.
35
+ */
36
+ urgent?: boolean;
37
+ /** 조용 시간 판정 기준 시각. 테스트에서 시계를 고정할 때만 넘긴다. */
38
+ now?: Date;
28
39
  };
29
40
 
30
- export async function notifySlack(notification: SlackNotification): Promise<void> {
31
- const botToken = process.env.SLACK_BOT_TOKEN;
32
- const channel = process.env.SLACK_CHANNEL_ID;
33
- const { title, subject, context, color } = notification;
41
+ export async function notifySlack(
42
+ notification: SlackNotification,
43
+ readSetting: typeof getSetting = getSetting,
44
+ ): Promise<void> {
45
+ const botToken = readSetting("slack.botToken", "SLACK_BOT_TOKEN");
46
+ const channel = readSetting("slack.channelId", "SLACK_CHANNEL_ID");
47
+ const { title, subject, context, color, urgent, now } = notification;
34
48
  if (!botToken || !channel) {
35
49
  logger.debug(`slack not configured, skipping notification: ${title} ${subject ?? ""}`);
36
50
  return;
37
51
  }
38
52
 
53
+ // 만료 알림은 주기적으로 반복되므로 여기서 버려도 다음 근무 시간 첫 주기에 다시 온다.
54
+ if (!urgent && isQuietHours(now)) {
55
+ logger.debug(`quiet hours, skipping notification: ${title} ${subject ?? ""}`);
56
+ return;
57
+ }
58
+
39
59
  const blocks: unknown[] = [
40
60
  {
41
61
  type: "section",