@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
@@ -0,0 +1,85 @@
1
+ /**
2
+ * 런타임 설정 조회 캐시.
3
+ *
4
+ * 값의 출처는 DB(`settings`) → env 순이다. env 를 남겨두는 이유는 전환 때문이다 — dev0 는
5
+ * `ecosystem.config.json` 으로 값을 주입하는데, DB 만 보게 만들면 배포 직후 설정이 전부
6
+ * 기본값으로 떨어진다.
7
+ *
8
+ * 설정은 요청 경로(워커 스케일링, 알림 전송)에서 읽히므로 매번 DB 를 때리지 않고 메모리에
9
+ * 둔다. `immediate` 변경도 현재 API 프로세스에만 즉시 반영된다. 인스턴스가 여러 개면 다른
10
+ * 인스턴스의 캐시는 갱신되지 않으며, 이 범위에서는 프로세스 간 전파를 두지 않는다.
11
+ */
12
+ import { getLogger } from "@logtape/logtape";
13
+
14
+ import { SettingModel } from "./setting.model";
15
+
16
+ const logger = getLogger(["qgrid", "setting"]);
17
+
18
+ /** 로드 전에는 null 이라 전부 env 로 떨어진다. */
19
+ let cache: Map<string, string> | null = null;
20
+
21
+ type SettingChangeHandler = (key: string) => void;
22
+ export type SettingPersistence = Pick<
23
+ typeof SettingModel,
24
+ "clearByKey" | "findAllAsMap" | "setByKey"
25
+ >;
26
+ let settingChangeHandler: SettingChangeHandler | null = null;
27
+
28
+ /**
29
+ * 런타임 소비자는 상위 조립 계층에서 하나만 등록한다. handler 는 동기 작업만 해야 하며,
30
+ * 실패해도 이미 성공한 DB 저장을 실패로 바꾸지 않는다.
31
+ */
32
+ export function setSettingChangeHandler(handler: SettingChangeHandler | null): void {
33
+ settingChangeHandler = handler;
34
+ }
35
+
36
+ function notifySettingChanged(key: string): void {
37
+ try {
38
+ settingChangeHandler?.(key);
39
+ } catch (e) {
40
+ const message = e instanceof Error ? e.message : String(e);
41
+ logger.warn(`setting change handler failed for ${key}: ${message}`);
42
+ }
43
+ }
44
+
45
+ export async function loadSettings(persistence: SettingPersistence = SettingModel): Promise<void> {
46
+ try {
47
+ cache = await persistence.findAllAsMap();
48
+ logger.info(`settings loaded: ${cache.size} stored`);
49
+ } catch (e) {
50
+ // 테이블이 아직 없는 배포(마이그레이션 전)에서도 서버는 떠야 한다.
51
+ cache = new Map();
52
+ logger.warn(`settings load failed, using env only: ${(e as Error).message}`);
53
+ }
54
+ }
55
+
56
+ /** DB → env 순. 둘 다 없으면 undefined 라 호출부의 기본값으로 떨어진다. */
57
+ export function getSetting(key: string, envKey: string): string | undefined {
58
+ return cache?.get(key) ?? process.env[envKey];
59
+ }
60
+
61
+ /** 저장된 값인지 — 화면에서 출처를 구분해 보여준다. */
62
+ export function isStored(key: string): boolean {
63
+ return cache?.has(key) ?? false;
64
+ }
65
+
66
+ export async function saveSetting(
67
+ key: string,
68
+ value: string,
69
+ persistence: SettingPersistence = SettingModel,
70
+ ): Promise<void> {
71
+ await persistence.setByKey(key, value);
72
+ cache ??= new Map();
73
+ cache.set(key, value);
74
+ notifySettingChanged(key);
75
+ }
76
+
77
+ /** 저장값을 지워 env 기본값으로 되돌린다. */
78
+ export async function resetSetting(
79
+ key: string,
80
+ persistence: SettingPersistence = SettingModel,
81
+ ): Promise<void> {
82
+ await persistence.clearByKey(key);
83
+ cache?.delete(key);
84
+ notifySettingChanged(key);
85
+ }
@@ -0,0 +1,53 @@
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", "preset"]);
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
+ /** `preset` 종류에서 고를 수 있는 값. 다른 종류는 빈 배열. */
37
+ presets: z.number().array(),
38
+ help: z.string().nullable(),
39
+ });
40
+ export type SettingItem = z.infer<typeof SettingItem>;
41
+
42
+ /** 화면에서 바꿀 수 없는 기동 시점 값들 — 어떤 환경에 붙어 있는지 확인용. */
43
+ export const RuntimeInfoItem = z.object({
44
+ label: z.string(),
45
+ value: z.string(),
46
+ });
47
+ export type RuntimeInfoItem = z.infer<typeof RuntimeInfoItem>;
48
+
49
+ export const SettingsResponse = z.object({
50
+ settings: SettingItem.array(),
51
+ runtime: RuntimeInfoItem.array(),
52
+ });
53
+ export type SettingsResponse = z.infer<typeof SettingsResponse>;
@@ -70,6 +70,60 @@ 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
+
120
+ POST {{baseUrl}}/api/setting/triggerExpiryReminder
121
+ Content-Type: application/json
122
+
123
+ {}
124
+
125
+ ###
126
+
73
127
  GET {{baseUrl}}/api/requestLogStep/findById
74
128
  ?subset=A
75
129
  &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,15 @@ 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" || key === "slack.remindersEnabled") {
180
+ rescheduleExpiredTokenReminder();
181
+ }
182
+ });
183
+
173
184
  let triggerReady = true;
174
185
  try {
175
186
  await ensureTokensTrigger(connConfig);
@@ -221,6 +232,7 @@ export default defineConfig({
221
232
  onShutdown: async () => {
222
233
  const log = getLogger(["qgrid", "startup"]);
223
234
  stopExpiredTokenReminder();
235
+ setSettingChangeHandler(null);
224
236
  if (QgridDispatcher.openaiDispatcher) {
225
237
  await QgridDispatcher.openaiDispatcher.stop();
226
238
  }
@@ -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.constant";
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,79 @@
1
+ /**
2
+ * Slack 알림 조용 시간.
3
+ *
4
+ * 토큰 만료는 사람이 재로그인해야 풀리는데, 그 사람이 자거나 쉬는 동안 알려도 할 수 있는
5
+ * 일이 없다. 업무 시간에만 알리고 나머지는 다음 근무 시간으로 미룬다 — 만료 알림은
6
+ * 주기적으로 반복되므로 조용 시간에 버려도 아침 첫 주기에 다시 온다.
7
+ *
8
+ * 다만 provider 의 마지막 토큰이 죽는 것은 서비스 정지 신호라 이 규칙에서 제외한다
9
+ * (호출부가 `urgent: true` 로 보내면 된다).
10
+ */
11
+ import { type getSetting } from "../application/setting/setting.store";
12
+
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;
26
+
27
+ /**
28
+ * 서버가 UTC 로 돌아도 한국 시간으로 판정한다. dev0 는 UTC 이므로 `getHours()` 를 그대로
29
+ * 쓰면 "저녁 8시"가 한국 시간 새벽 5시가 된다.
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
+
38
+ function seoulParts(at: Date): { hour: number; weekday: string } {
39
+ const parts = SEOUL_FORMATTER.formatToParts(at);
40
+ const hour = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
41
+ const weekday = parts.find((p) => p.type === "weekday")?.value ?? "";
42
+ // Intl 은 자정을 24 로 줄 수 있다. 0..23 으로 맞춘다.
43
+ return { hour: hour % 24, weekday };
44
+ }
45
+
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 {
60
+ const { hour, weekday } = seoulParts(at);
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;
79
+ }