@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,332 @@
1
+ import { Y as __toESM, g as require_react, h as require_jsx_runtime, m as useQueryClient } from "./sd.generated-LO-h1_16.js";
2
+ import { a as SettingService } from "./services.generated-DADr9OGH.js";
3
+ import { t as lucidePlus } from "./plus-BISXNqRU.js";
4
+ //#region ~icons/lucide/eye.jsx
5
+ var import_react = /* @__PURE__ */ __toESM(require_react());
6
+ var import_jsx_runtime = require_jsx_runtime();
7
+ var lucideEye = (props) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", {
8
+ viewBox: "0 0 24 24",
9
+ width: "1.2em",
10
+ height: "1.2em",
11
+ ...props,
12
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("g", {
13
+ fill: "none",
14
+ stroke: "currentColor",
15
+ strokeLinecap: "round",
16
+ strokeLinejoin: "round",
17
+ strokeWidth: 2,
18
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M2.062 12.348a1 1 0 0 1 0-.696a10.75 10.75 0 0 1 19.876 0a1 1 0 0 1 0 .696a10.75 10.75 0 0 1-19.876 0" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("circle", {
19
+ cx: 12,
20
+ cy: 12,
21
+ r: 3
22
+ })]
23
+ })
24
+ });
25
+ //#endregion
26
+ //#region ~icons/lucide/eye-off.jsx
27
+ var lucideEyeOff = (props) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", {
28
+ viewBox: "0 0 24 24",
29
+ width: "1.2em",
30
+ height: "1.2em",
31
+ ...props,
32
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("g", {
33
+ fill: "none",
34
+ stroke: "currentColor",
35
+ strokeLinecap: "round",
36
+ strokeLinejoin: "round",
37
+ strokeWidth: 2,
38
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575a1 1 0 0 1 0 .696a10.8 10.8 0 0 1-1.444 2.49m-6.41-.679a3 3 0 0 1-4.242-4.242" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151a1 1 0 0 1 0-.696a10.75 10.75 0 0 1 4.446-5.143M2 2l20 20" })]
39
+ })
40
+ });
41
+ //#endregion
42
+ //#region ~icons/lucide/minus.jsx
43
+ var lucideMinus = (props) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", {
44
+ viewBox: "0 0 24 24",
45
+ width: "1.2em",
46
+ height: "1.2em",
47
+ ...props,
48
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", {
49
+ fill: "none",
50
+ stroke: "currentColor",
51
+ strokeLinecap: "round",
52
+ strokeLinejoin: "round",
53
+ strokeWidth: 2,
54
+ d: "M5 12h14"
55
+ })
56
+ });
57
+ //#endregion
58
+ //#region ~icons/lucide/rotate-ccw.jsx
59
+ var lucideRotateCcw = (props) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", {
60
+ viewBox: "0 0 24 24",
61
+ width: "1.2em",
62
+ height: "1.2em",
63
+ ...props,
64
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("g", {
65
+ fill: "none",
66
+ stroke: "currentColor",
67
+ strokeLinecap: "round",
68
+ strokeLinejoin: "round",
69
+ strokeWidth: 2,
70
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M3 12a9 9 0 1 0 9-9a9.75 9.75 0 0 0-6.74 2.74L3 8" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M3 3v5h5" })]
71
+ })
72
+ });
73
+ //#endregion
74
+ //#region src/components/qgrid/SettingsPanel.tsx
75
+ var GROUP_LABELS = {
76
+ openai: "OpenAI 워커",
77
+ slack: "Slack 알림"
78
+ };
79
+ function maskValue(value) {
80
+ if (!value) return "";
81
+ if (value.length <= 12) return "•".repeat(value.length);
82
+ return `${value.slice(0, 8)}${"•".repeat(12)}${value.slice(-4)}`;
83
+ }
84
+ /** 정수·실수는 타이핑보다 증감이 빠르다. 범위도 버튼이 알아서 지킨다. */
85
+ function Stepper({ value, min, max, step, disabled, onChange }) {
86
+ const current = Number(value);
87
+ const valid = Number.isFinite(current);
88
+ const lower = min ?? Number.NEGATIVE_INFINITY;
89
+ const upper = max ?? Number.POSITIVE_INFINITY;
90
+ const adjust = (delta) => {
91
+ const next = Math.min(Math.max((valid ? current : min ?? 0) + delta, lower), upper);
92
+ onChange(String(Number(next.toFixed(2))));
93
+ };
94
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
95
+ className: "inline-flex items-center rounded-lg border border-sand-200/80 bg-white overflow-hidden",
96
+ children: [
97
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
98
+ type: "button",
99
+ "aria-label": "감소",
100
+ onClick: () => adjust(-step),
101
+ disabled: disabled || valid && current <= lower,
102
+ className: "size-7 grid place-items-center text-sand-500 hover:bg-sand-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors duration-150",
103
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(lucideMinus, { className: "size-3" })
104
+ }),
105
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", {
106
+ value,
107
+ onChange: (e) => onChange(e.target.value),
108
+ disabled,
109
+ inputMode: "decimal",
110
+ className: "w-12 text-center text-[12px] tabular-nums text-sand-800 bg-transparent border-x border-sand-200/80 py-1 focus:outline-none focus:bg-sand-50"
111
+ }),
112
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
113
+ type: "button",
114
+ "aria-label": "증가",
115
+ onClick: () => adjust(step),
116
+ disabled: disabled || valid && current >= upper,
117
+ className: "size-7 grid place-items-center text-sand-500 hover:bg-sand-100 disabled:opacity-30 disabled:hover:bg-transparent transition-colors duration-150",
118
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(lucidePlus, { className: "size-3" })
119
+ })
120
+ ]
121
+ });
122
+ }
123
+ function Toggle({ on, disabled, onChange }) {
124
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
125
+ type: "button",
126
+ role: "switch",
127
+ "aria-checked": on,
128
+ disabled,
129
+ onClick: () => onChange(!on),
130
+ className: `relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 disabled:opacity-50 ${on ? "bg-sage-400" : "bg-sand-300"}`,
131
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: `inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform duration-200 ${on ? "translate-x-4.5" : "translate-x-0.5"}` })
132
+ });
133
+ }
134
+ function SettingRow({ item }) {
135
+ const [draft, setDraft] = (0, import_react.useState)(null);
136
+ const [revealed, setRevealed] = (0, import_react.useState)(false);
137
+ const [error, setError] = (0, import_react.useState)(null);
138
+ const queryClient = useQueryClient();
139
+ const updateMutation = SettingService.useUpdateSettingMutation();
140
+ const resetMutation = SettingService.useResetSettingMutation();
141
+ const pending = updateMutation.isPending || resetMutation.isPending;
142
+ const value = draft ?? item.value;
143
+ const dirty = draft !== null && draft !== item.value;
144
+ const invalidate = () => queryClient.invalidateQueries({ queryKey: ["Setting", "getSettingList"] });
145
+ const commit = async (next) => {
146
+ setError(null);
147
+ try {
148
+ await updateMutation.mutateAsync({
149
+ key: item.key,
150
+ value: next
151
+ });
152
+ await invalidate();
153
+ setDraft(null);
154
+ } catch (e) {
155
+ setError(e.message);
156
+ }
157
+ };
158
+ const reset = async () => {
159
+ setError(null);
160
+ try {
161
+ await resetMutation.mutateAsync({ key: item.key });
162
+ await invalidate();
163
+ setDraft(null);
164
+ } catch (e) {
165
+ setError(e.message);
166
+ }
167
+ };
168
+ const isToggle = item.kind === "boolean";
169
+ const isNumeric = item.kind === "integer" || item.kind === "number";
170
+ const isSecret = item.kind === "secret";
171
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
172
+ className: "px-4 py-3 sm:px-5",
173
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
174
+ className: "flex items-start gap-4",
175
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
176
+ className: "min-w-0 flex-1",
177
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
178
+ className: "flex items-center gap-2",
179
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
180
+ className: "text-[13px] text-sand-800",
181
+ children: item.label
182
+ }), item.applies === "restart" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
183
+ className: "text-[10px] text-caution-500",
184
+ title: "저장해도 다음 재시작부터 반영됩니다",
185
+ children: "재시작 필요"
186
+ })]
187
+ }), item.help && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
188
+ className: "mt-0.5 text-[11px] text-sand-500",
189
+ children: item.help
190
+ })]
191
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
192
+ className: "flex items-center gap-1.5 shrink-0",
193
+ children: [
194
+ isToggle && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Toggle, {
195
+ on: value === "true",
196
+ disabled: pending,
197
+ onChange: (next) => void commit(String(next))
198
+ }),
199
+ isNumeric && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Stepper, {
200
+ value,
201
+ min: item.min,
202
+ max: item.max,
203
+ step: item.kind === "integer" ? 1 : .5,
204
+ disabled: pending,
205
+ onChange: setDraft
206
+ }),
207
+ (item.kind === "string" || isSecret) && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
208
+ className: "relative",
209
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", {
210
+ value: isSecret && !revealed ? maskValue(value) : value,
211
+ onChange: (e) => setDraft(e.target.value),
212
+ onFocus: () => isSecret && setRevealed(true),
213
+ disabled: pending,
214
+ className: `w-48 sm:w-64 rounded-lg border border-sand-200/80 bg-white py-1.5 pl-2.5 text-[12px] text-sand-800 focus:outline-none focus:border-sienna-300 ${isSecret ? "pr-8 font-mono" : "pr-2.5"}`
215
+ }), isSecret && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
216
+ type: "button",
217
+ "aria-label": revealed ? "값 숨기기" : "값 보기",
218
+ onClick: () => setRevealed((r) => !r),
219
+ className: "absolute right-1.5 top-1/2 -translate-y-1/2 size-6 grid place-items-center text-sand-400 hover:text-sand-600 transition-colors duration-150",
220
+ children: revealed ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(lucideEyeOff, { className: "size-3.5" }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(lucideEye, { className: "size-3.5" })
221
+ })]
222
+ }),
223
+ dirty && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
224
+ type: "button",
225
+ onClick: () => void commit(value),
226
+ disabled: pending,
227
+ className: "px-2.5 py-1.5 text-[11px] font-medium rounded-lg bg-sienna-400 text-white hover:bg-sienna-500 disabled:opacity-50 transition-colors duration-150",
228
+ children: updateMutation.isPending ? "저장 중" : "저장"
229
+ }),
230
+ !dirty && item.source === "db" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
231
+ type: "button",
232
+ title: "저장값을 지우고 env 또는 기본값으로 되돌립니다",
233
+ onClick: () => void reset(),
234
+ disabled: pending,
235
+ className: "size-7 grid place-items-center rounded-lg text-sand-400 hover:text-sand-600 hover:bg-sand-100 disabled:opacity-50 transition-colors duration-150",
236
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(lucideRotateCcw, { className: "size-3.5" })
237
+ })
238
+ ]
239
+ })]
240
+ }), error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
241
+ className: "mt-1.5 text-[11px] text-red-500",
242
+ children: error
243
+ })]
244
+ });
245
+ }
246
+ function SourceLegend({ items }) {
247
+ const stored = items.filter((i) => i.source === "db").length;
248
+ if (stored === 0) return null;
249
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
250
+ className: "text-[11px] text-sand-400",
251
+ children: [stored, "개 항목이 저장값을 사용 중"]
252
+ });
253
+ }
254
+ function SettingsPanel() {
255
+ const { data, isLoading } = SettingService.useSettingList();
256
+ if (isLoading) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
257
+ className: "space-y-4",
258
+ children: [0, 1].map((i) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
259
+ className: "panel overflow-hidden",
260
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
261
+ className: "panel-header px-4 py-2.5 sm:px-5",
262
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "h-3 w-24 rounded bg-sand-200 animate-pulse" })
263
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
264
+ className: "divide-y divide-sand-100/80",
265
+ children: [
266
+ 0,
267
+ 1,
268
+ 2
269
+ ].map((j) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
270
+ className: "px-4 py-3 sm:px-5 flex items-center justify-between gap-4",
271
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "h-3 w-40 rounded bg-sand-100 animate-pulse" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "h-7 w-24 rounded-lg bg-sand-100 animate-pulse" })]
272
+ }, j))
273
+ })]
274
+ }, i))
275
+ });
276
+ const settings = data?.settings ?? [];
277
+ const runtime = data?.runtime ?? [];
278
+ const groups = [...new Set(settings.map((s) => s.group))];
279
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
280
+ className: "space-y-4",
281
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
282
+ className: "panel overflow-hidden",
283
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
284
+ className: "panel-header flex items-center justify-between px-4 py-2.5 sm:px-5",
285
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
286
+ className: "text-[13px] font-medium text-sand-700",
287
+ children: "환경"
288
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
289
+ className: "text-[11px] text-sand-400",
290
+ children: "기동 시점 값"
291
+ })]
292
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("dl", {
293
+ className: "px-4 py-3 sm:px-5 grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-1.5",
294
+ children: runtime.map((info) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
295
+ className: "flex items-baseline justify-between gap-3 min-w-0",
296
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("dt", {
297
+ className: "text-[11px] text-sand-500 shrink-0",
298
+ children: info.label
299
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("dd", {
300
+ className: "text-[12px] font-mono text-sand-700 truncate",
301
+ children: info.value
302
+ })]
303
+ }, info.label))
304
+ })]
305
+ }), groups.map((group) => {
306
+ const items = settings.filter((s) => s.group === group);
307
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
308
+ className: "panel overflow-hidden",
309
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
310
+ className: "panel-header flex items-center justify-between px-4 py-2.5 sm:px-5",
311
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
312
+ className: "text-[13px] font-medium text-sand-700",
313
+ children: GROUP_LABELS[group] ?? group
314
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SourceLegend, { items })]
315
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
316
+ className: "divide-y divide-sand-100/80",
317
+ children: items.map((item) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SettingRow, { item }, item.key))
318
+ })]
319
+ }, group);
320
+ })]
321
+ });
322
+ }
323
+ //#endregion
324
+ //#region src/routes/settings.tsx?tsr-split=component
325
+ function SettingsPage() {
326
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
327
+ className: "max-w-4xl mx-auto",
328
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SettingsPanel, {})
329
+ });
330
+ }
331
+ //#endregion
332
+ export { SettingsPage as component };
@@ -1,8 +1,8 @@
1
- import { J as __commonJSMin, Y as __exportAll, Z as __toESM, _ as require_jsx_runtime, v as require_react } from "./sd.generated-B0xqnHEo.js";
2
- import { i as RequestLogStepService, r as RequestLogService } from "./services.generated-DqAKskAS.js";
3
- import { a as Link } from "./lazyRouteComponent-BaZaveNc.js";
1
+ import { K as __commonJSMin, Y as __toESM, g as require_react, h as require_jsx_runtime, q as __exportAll } from "./sd.generated-LO-h1_16.js";
2
+ import { i as RequestLogStepService, r as RequestLogService } from "./services.generated-DADr9OGH.js";
3
+ import { a as Link } from "./lazyRouteComponent-Ws7I6fL-.js";
4
4
  import { t as Route } from "../entry-server.generated.js";
5
- import { n as formatMicroUsd, t as cacheHitRate } from "./cost-CSH_t17I.js";
5
+ import { n as formatMicroUsd, t as cacheHitRate } from "./cost-DzjfVtUr.js";
6
6
  import minpath from "node:path";
7
7
  import minproc from "node:process";
8
8
  import { fileURLToPath as urlToPath } from "node:url";
@@ -1,26 +1,11 @@
1
- import { Z as __toESM, _ as require_jsx_runtime, g as useQueryClient, v as require_react } from "./sd.generated-B0xqnHEo.js";
2
- import { a as TokenService, n as QgridService } from "./services.generated-DqAKskAS.js";
1
+ import { Y as __toESM, g as require_react, h as require_jsx_runtime, m as useQueryClient } from "./sd.generated-LO-h1_16.js";
2
+ import { n as QgridService, o as TokenService } from "./services.generated-DADr9OGH.js";
3
3
  import { i as Q, o as lucideKeyRound } from "../entry-server.generated.js";
4
- import { n as OAuthCodeEntry, r as useUpdateTokenMutation, t as useOAuthLoginFlow } from "./use-oauth-login-flow-YM1nsd-H.js";
5
- //#region ~icons/lucide/plus.jsx
6
- var import_react = /* @__PURE__ */ __toESM(require_react());
7
- var import_jsx_runtime = require_jsx_runtime();
8
- var lucidePlus = (props) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", {
9
- viewBox: "0 0 24 24",
10
- width: "1.2em",
11
- height: "1.2em",
12
- ...props,
13
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", {
14
- fill: "none",
15
- stroke: "currentColor",
16
- strokeLinecap: "round",
17
- strokeLinejoin: "round",
18
- strokeWidth: 2,
19
- d: "M5 12h14m-7-7v14"
20
- })
21
- });
22
- //#endregion
4
+ import { n as OAuthCodeEntry, r as useUpdateTokenMutation, t as useOAuthLoginFlow } from "./use-oauth-login-flow-B6MY65IN.js";
5
+ import { t as lucidePlus } from "./plus-BISXNqRU.js";
23
6
  //#region src/components/qgrid/AddTokenModal.tsx
7
+ var import_react = /* @__PURE__ */ __toESM(require_react(), 1);
8
+ var import_jsx_runtime = require_jsx_runtime();
24
9
  function AddTokenModal() {
25
10
  const [open, setOpen] = (0, import_react.useState)(false);
26
11
  const [name, setName] = (0, import_react.useState)("");
@@ -1,5 +1,5 @@
1
- import { Z as __toESM, _ as require_jsx_runtime, g as useQueryClient, v as require_react } from "./sd.generated-B0xqnHEo.js";
2
- import { n as QgridService, o as useMutation } from "./services.generated-DqAKskAS.js";
1
+ import { Y as __toESM, g as require_react, h as require_jsx_runtime, m as useQueryClient } from "./sd.generated-LO-h1_16.js";
2
+ import { n as QgridService, s as useMutation } from "./services.generated-DADr9OGH.js";
3
3
  import { i as Q } from "../entry-server.generated.js";
4
4
  //#region src/services/token/use-update-token-mutation.ts
5
5
  function useUpdateTokenMutation() {
@@ -1,5 +1,6 @@
1
- import { A as functionalUpdate, B as resolveStaleTime, C as infiniteQueryBehavior, D as notifyManager, E as onlineManager, F as matchQuery, I as noop$1, J as __commonJSMin, K as focusManager, L as partialMatchKey, M as hashQueryKeyByOptions, P as matchMutation, U as skipToken, X as __require, Z as __toESM, _ as require_jsx_runtime, c as _enum, d as string, h as QueryClientProvider, j as hashKey, l as number, m as require_shim, n as SUPPORTED_LOCALES, p as require_react_dom, q as Subscribable, r as setLocale, t as SD, u as object, v as require_react, w as Query, y as Mutation } from "./assets/sd.generated-B0xqnHEo.js";
2
- import { A as decodePath, C as findRouteMatch, D as createLRUCache, E as processRouteTree, F as last, I as replaceEqualDeep, M as findLast, N as functionalUpdate$1, O as invariant, P as isPromise, S as findFlatMatch, T as processRouteMasks, _ as interpolatePath, a as Link$1, b as trimPath, c as usePrevious$1, d as useRouter, f as getRouterContext, g as cleanPath, h as isNotFound, i as createRootRouteWithContext, j as deepEqual$2, k as createControlledPromise, l as matchContext, m as rootRouteId, n as createFileRoute, p as warning, r as createLazyFileRoute, s as useLayoutEffect$3, t as lazyRouteComponent, u as useRouterState, v as joinPaths, w as findSingleMatch, x as trimPathRight, y as resolvePath } from "./assets/lazyRouteComponent-BaZaveNc.js";
1
+ import { A as hashQueryKeyByOptions, F as partialMatchKey, G as Subscribable, J as __require, K as __commonJSMin, M as matchMutation, N as matchQuery, O as functionalUpdate, P as noop$1, R as resolveStaleTime, S as Query, T as notifyManager, V as skipToken, W as focusManager, Y as __toESM, _ as Mutation, c as _enum, d as string, g as require_react, h as require_jsx_runtime, k as hashKey, l as number, n as SUPPORTED_LOCALES, p as QueryClientProvider, r as setLocale, t as SD, u as object, w as onlineManager, x as infiniteQueryBehavior } from "./assets/sd.generated-LO-h1_16.js";
2
+ import { A as decodePath, C as findRouteMatch, D as createLRUCache, E as processRouteTree, F as last, I as replaceEqualDeep, M as findLast, N as functionalUpdate$1, O as invariant, P as isPromise, S as findFlatMatch, T as processRouteMasks, _ as interpolatePath, a as Link$1, b as trimPath, c as usePrevious$1, d as useRouter, f as getRouterContext, g as cleanPath, h as isNotFound, i as createRootRouteWithContext, j as deepEqual$2, k as createControlledPromise, l as matchContext, m as rootRouteId, n as createFileRoute, p as warning, r as createLazyFileRoute, s as useLayoutEffect$3, t as lazyRouteComponent, u as useRouterState, v as joinPaths, w as findSingleMatch, x as trimPathRight, y as resolvePath } from "./assets/lazyRouteComponent-Ws7I6fL-.js";
3
+ import { n as require_shim, t as require_react_dom } from "./assets/react-dom-D7duWOwf.js";
3
4
  //#region ../../node_modules/.pnpm/@tanstack+query-core@5.95.2/node_modules/@tanstack/query-core/build/modern/hydration.js
4
5
  function defaultTransformerFn(data) {
5
6
  return data;
@@ -22659,6 +22660,26 @@ var lucideKeyRound = (props) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg
22659
22660
  })
22660
22661
  });
22661
22662
  //#endregion
22663
+ //#region ~icons/lucide/settings.jsx
22664
+ var lucideSettings = (props) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", {
22665
+ viewBox: "0 0 24 24",
22666
+ width: "1.2em",
22667
+ height: "1.2em",
22668
+ ...props,
22669
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("g", {
22670
+ fill: "none",
22671
+ stroke: "currentColor",
22672
+ strokeLinecap: "round",
22673
+ strokeLinejoin: "round",
22674
+ strokeWidth: 2,
22675
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M9.671 4.136a2.34 2.34 0 0 1 4.659 0a2.34 2.34 0 0 0 3.319 1.915a2.34 2.34 0 0 1 2.33 4.033a2.34 2.34 0 0 0 0 3.831a2.34 2.34 0 0 1-2.33 4.033a2.34 2.34 0 0 0-3.319 1.915a2.34 2.34 0 0 1-4.659 0a2.34 2.34 0 0 0-3.32-1.915a2.34 2.34 0 0 1-2.33-4.033a2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("circle", {
22676
+ cx: 12,
22677
+ cy: 12,
22678
+ r: 3
22679
+ })]
22680
+ })
22681
+ });
22682
+ //#endregion
22662
22683
  //#region ~icons/lucide/terminal.jsx
22663
22684
  var lucideTerminal = (props) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", {
22664
22685
  viewBox: "0 0 24 24",
@@ -22703,6 +22724,11 @@ var NAV_SECTIONS = [{
22703
22724
  shortTitle: "Server",
22704
22725
  path: "/monit",
22705
22726
  icon: lucideTerminal
22727
+ },
22728
+ {
22729
+ title: "Settings",
22730
+ path: "/settings",
22731
+ icon: lucideSettings
22706
22732
  }
22707
22733
  ]
22708
22734
  }];
@@ -22742,7 +22768,7 @@ function BottomNav() {
22742
22768
  }
22743
22769
  //#endregion
22744
22770
  //#region src/components/Sidebar.tsx
22745
- var qgridVersion = "2.6.6";
22771
+ var qgridVersion = "2.6.8";
22746
22772
  function Sidebar({ className }) {
22747
22773
  const pathname = useRouterState({ select: (s) => s.location.pathname });
22748
22774
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("aside", {
@@ -44577,7 +44603,7 @@ function SonamuProvider({ children }) {
44577
44603
  }
44578
44604
  //#endregion
44579
44605
  //#region src/routes/__root.tsx
44580
- var Route$5 = createRootRouteWithContext()({
44606
+ var Route$6 = createRootRouteWithContext()({
44581
44607
  head: () => ({ meta: [
44582
44608
  { charSet: "utf-8" },
44583
44609
  {
@@ -44589,7 +44615,7 @@ var Route$5 = createRootRouteWithContext()({
44589
44615
  component: RootComponent
44590
44616
  });
44591
44617
  function RootComponent() {
44592
- const { queryClient } = Route$5.useRouteContext();
44618
+ const { queryClient } = Route$6.useRouteContext();
44593
44619
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("html", {
44594
44620
  lang: "en",
44595
44621
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("head", { children: [
@@ -44623,11 +44649,15 @@ function RootComponent() {
44623
44649
  }
44624
44650
  //#endregion
44625
44651
  //#region src/routes/tokens.tsx
44626
- var $$splitComponentImporter$4 = () => import("./assets/tokens-Dkd_uXEk.js");
44627
- var Route$4 = createFileRoute("/tokens")({ component: lazyRouteComponent($$splitComponentImporter$4, "component") });
44652
+ var $$splitComponentImporter$5 = () => import("./assets/tokens-DmhIfVct.js");
44653
+ var Route$5 = createFileRoute("/tokens")({ component: lazyRouteComponent($$splitComponentImporter$5, "component") });
44654
+ //#endregion
44655
+ //#region src/routes/settings.tsx
44656
+ var $$splitComponentImporter$4 = () => import("./assets/settings-CMJKayff.js");
44657
+ var Route$4 = createFileRoute("/settings")({ component: lazyRouteComponent($$splitComponentImporter$4, "component") });
44628
44658
  //#endregion
44629
44659
  //#region src/routes/monit.tsx
44630
- var $$splitComponentImporter$3 = () => import("./assets/monit-UmcqHroX.js");
44660
+ var $$splitComponentImporter$3 = () => import("./assets/monit-DyKLJX4s.js");
44631
44661
  var monitSearchSchema = object({ level: _enum([
44632
44662
  "all",
44633
44663
  "scaling",
@@ -44640,7 +44670,7 @@ var Route$3 = createFileRoute("/monit")({
44640
44670
  });
44641
44671
  //#endregion
44642
44672
  //#region src/routes/logs.tsx
44643
- var $$splitComponentImporter$2 = () => import("./assets/logs-DMOw3SjD.js");
44673
+ var $$splitComponentImporter$2 = () => import("./assets/logs-DmkzGxGU.js");
44644
44674
  var logsSearchSchema = object({
44645
44675
  token: string().optional(),
44646
44676
  project: string().optional(),
@@ -44654,11 +44684,11 @@ var Route$2 = createFileRoute("/logs")({
44654
44684
  });
44655
44685
  //#endregion
44656
44686
  //#region src/routes/index.tsx
44657
- var $$splitComponentImporter$1 = () => import("./assets/routes-BfoQmYud.js");
44687
+ var $$splitComponentImporter$1 = () => import("./assets/routes-g62E9xmC.js");
44658
44688
  var Route$1 = createFileRoute("/")({ component: lazyRouteComponent($$splitComponentImporter$1, "component") });
44659
44689
  //#endregion
44660
44690
  //#region src/routes/requests/show.tsx
44661
- var $$splitComponentImporter = () => import("./assets/show-C-oSdzHX.js");
44691
+ var $$splitComponentImporter = () => import("./assets/show-BGAiCPGq.js");
44662
44692
  var showSearchSchema = object({ id: number() });
44663
44693
  var Route = createFileRoute("/requests/show")({
44664
44694
  validateSearch: showSearchSchema,
@@ -44666,37 +44696,43 @@ var Route = createFileRoute("/requests/show")({
44666
44696
  });
44667
44697
  //#endregion
44668
44698
  //#region src/routeTree.gen.ts
44669
- var TokensRoute = Route$4.update({
44699
+ var TokensRoute = Route$5.update({
44670
44700
  id: "/tokens",
44671
44701
  path: "/tokens",
44672
- getParentRoute: () => Route$5
44702
+ getParentRoute: () => Route$6
44703
+ });
44704
+ var SettingsRoute = Route$4.update({
44705
+ id: "/settings",
44706
+ path: "/settings",
44707
+ getParentRoute: () => Route$6
44673
44708
  });
44674
44709
  var MonitRoute = Route$3.update({
44675
44710
  id: "/monit",
44676
44711
  path: "/monit",
44677
- getParentRoute: () => Route$5
44712
+ getParentRoute: () => Route$6
44678
44713
  });
44679
44714
  var LogsRoute = Route$2.update({
44680
44715
  id: "/logs",
44681
44716
  path: "/logs",
44682
- getParentRoute: () => Route$5
44717
+ getParentRoute: () => Route$6
44683
44718
  });
44684
44719
  var rootRouteChildren = {
44685
44720
  IndexRoute: Route$1.update({
44686
44721
  id: "/",
44687
44722
  path: "/",
44688
- getParentRoute: () => Route$5
44723
+ getParentRoute: () => Route$6
44689
44724
  }),
44690
44725
  LogsRoute,
44691
44726
  MonitRoute,
44727
+ SettingsRoute,
44692
44728
  TokensRoute,
44693
44729
  RequestsShowRoute: Route.update({
44694
44730
  id: "/requests/show",
44695
44731
  path: "/requests/show",
44696
- getParentRoute: () => Route$5
44732
+ getParentRoute: () => Route$6
44697
44733
  })
44698
44734
  };
44699
- var routeTree = Route$5._addFileChildren(rootRouteChildren)._addFileTypes();
44735
+ var routeTree = Route$6._addFileChildren(rootRouteChildren)._addFileTypes();
44700
44736
  //#endregion
44701
44737
  //#region src/entry-server.generated.tsx
44702
44738
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartanova/qgrid-cli",
3
- "version": "2.6.6",
3
+ "version": "2.6.8",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/cartanova-ai/qgrid"
@@ -69,8 +69,8 @@ When helping a user set up qgrid locally, check for `QGRID_PROJECT_NAME` or conf
69
69
  | `QGRID_OPENAI_MAX_WORKERS_PER_TOKEN` | `3` when autoscaling | Maximum workers per active OpenAI token. It cannot be lower than the resolved minimum and is hard-capped at 20. When autoscaling is disabled, the maximum equals the minimum. |
70
70
  | `QGRID_OPENAI_SCALE_INTERVAL_MS` | `5000` | OpenAI pool health, idle-thread cleanup, and demand autoscaling evaluation interval. Clamped to 250..300000 ms. |
71
71
  | `QGRID_OPENAI_SCALE_DOWN_IDLE_MS` | `600000` | Idle time before an excess worker becomes eligible for scale-down. Clamped to 1000 ms..24 hours. |
72
- | `QGRID_OPENAI_MAX_ESTIMATED_RSS_GIB` | `16` | Refuse scale-up when estimated qgrid worker RSS would exceed this value. Estimate: `0.71 + 0.157 * totalWorkerCount` GiB. |
73
- | `QGRID_OPENAI_MIN_HOST_AVAILABLE_GIB` | `20` | Refuse scale-up when current host available memory is below this value. |
72
+ | `QGRID_OPENAI_MAX_ESTIMATED_RSS_GIB` | `16` | Refuse scale-up when estimated qgrid worker RSS would exceed this value. Estimate: `0.71 + 0.157 * totalWorkerCount` GiB. Clamped to 1..32 GiB; the upper bound is an operator-typo guard, not an OOM-safety guarantee. |
73
+ | `QGRID_OPENAI_MIN_HOST_AVAILABLE_GIB` | `20` | Refuse scale-up when current host available memory is below this value. Clamped to 0..64 GiB. |
74
74
  | `QGRID_OPENAI_THREAD_REUSE` | enabled | Set to `"false"` to disable OpenAI thread reuse and force cold thread behavior. |
75
75
  | `MAX_STRUCTURED_OUTPUT_RETRIES` | `1` for structured Anthropic streaming calls | Claude Code structured-output retry count for streaming only, clamped to at least 1 by qgrid. Non-streaming `generate` leaves the variable unset and uses Claude Code's default retry budget. |
76
76
 
@@ -88,6 +88,18 @@ When helping a user set up qgrid locally, check for `QGRID_PROJECT_NAME` or conf
88
88
 
89
89
  On shutdown, it stops provider dispatchers and the token subscriber.
90
90
 
91
+ ### Runtime settings
92
+
93
+ Some env values are editable at runtime through the dashboard's Settings page, backed by the `settings` table (key-value). Resolution order is DB → env → code default: env stays as a fallback so a deploy that has not written any setting yet keeps working exactly as before. `loadSettings()` runs in `onStart` before the dispatchers are constructed, since the OpenAI pool reads its worker counts in its constructor.
94
+
95
+ - Editable: OpenAI autoscale and min/max workers per token, the worker memory guards, the Slack expiry-reminder interval, and the Slack channel/user-map/bot-token. `SETTING_DEFS` in `setting.schema.ts` is the single definition — key, env name, type, bounds, and whether the change applies immediately or needs a restart.
96
+ - Not editable: anything needed before the server can read its own database (`QGRID_DB_*`, `HOST`, `PORT`, `NODE_ENV`). These are exposed read-only on the same page so an operator can confirm which environment they are looking at; the DB password is masked.
97
+ - Worker settings are marked `restart` because `resolveOpenAIWorkerPoolConfig` is called once in the dispatcher constructor. Changing them writes to the DB but does not resize a running pool.
98
+ - Slack channel, bot-token, and user-map changes are read on the next notification. Changing the expiry-reminder interval replaces the current timer immediately without sending a notification merely because the setting changed; process boot still performs the intended immediate reminder run.
99
+ - `immediate` means the API process that handled the update. The in-memory settings cache is not propagated to sibling processes; multi-instance deployments need a separate settings-change transport before treating the label as cluster-wide.
100
+ - The public settings API exposes the curated list/set/reset methods only. Keep generic Sonamu `save`/`del` methods internal so callers cannot bypass key validation, cache updates, or runtime change hooks.
101
+ - The settings response returns the Slack bot token in full so the authenticated dashboard can support its reveal toggle. Treat that endpoint as an operator-only surface at the deployment boundary; dev0 relies on Caddy authentication for the dashboard. The database password remains masked and is never returned in full.
102
+
91
103
  ### Readiness during startup
92
104
 
93
105
  HTTP listening opens before the dispatchers finish starting, so requests can arrive while a provider is still unavailable. On dev0 that window is 1–2 minutes: 25 OpenAI workers spawn at `SPAWN_INTERVAL_MS` apart, plus codex process init.
@@ -34,7 +34,7 @@ Autoscaling is enabled unless `QGRID_OPENAI_AUTOSCALE` is `"false"` or `"0"`. It
34
34
  - estimated qgrid worker RSS: `0.71 + 0.157 * totalWorkerCount` GiB, limited to 16 GiB by default;
35
35
  - current host available memory at scale-up evaluation time, required to be at least 20 GiB by default.
36
36
 
37
- All sizing and memory limits can be overridden with the environment variables listed in `cli-env-and-server-boot.md`.
37
+ All sizing and memory limits can be configured within the bounds listed in `cli-env-and-server-boot.md`.
38
38
 
39
39
  Pool health maintenance is separate from demand autoscaling:
40
40
 
@@ -20,6 +20,8 @@ qgrid dashboard work is usually a Sonamu API/model/generated-client/web change,
20
20
  - i18n generated files may change when entity labels or static data change.
21
21
  - Web components under `packages/web/src/components/qgrid` consume generated API shapes.
22
22
  - The main dashboard sidebar version should stay synced to `packages/cli/package.json`. `packages/web/vite.config.ts` reads that package version and exposes it as `__QGRID_CLI_VERSION__`; `Sidebar.tsx` displays it.
23
+ - Runtime settings use the curated `listSettings`, `setSetting`, and `resetSetting` API methods. Do not expose the model's generic `save` or `del` methods: they bypass the settings schema validation, process-local cache update, and runtime change handler.
24
+ - The Slack bot token is returned to the authenticated settings dashboard for its reveal toggle. Preserve the deployment authentication boundary when changing this response; do not describe the value as masked unless the API contract is changed to match.
23
25
 
24
26
  ## Migration workflow
25
27
 
@@ -210,8 +210,9 @@ When a refresh fails in a way that only re-login can fix, qgrid removes the toke
210
210
  - Deactivation is one conditional `active=true → false` update. The process whose update affects a row is the only one that sends the Slack death notification, so a shared database produces exactly one alert per event.
211
211
  - The provider's last active token is never auto-deactivated. A systemic failure (client_id revocation, OAuth contract change) would otherwise empty the pool; qgrid logs an error, keeps the token, and sends its own Slack alert. That path leaves no state change behind, so every following request would re-alert — a per-provider 30-minute cooldown holds it to one. The cooldown is in-process, so N instances can each emit once per window.
212
212
  - The other notification is registration: every completed login sends one alert, whether the account is new or replacing an existing row through the account dedup. Manually toggling a token back to active is not a registration and sends nothing.
213
+ - Quiet hours suppress non-urgent Slack notifications: weekends, plus 20:00–08:00 on weekdays, in `Asia/Seoul`. The hour is computed through `Intl` with an explicit timezone rather than `getHours()` — dev0 runs in UTC, where "20:00" would land at 05:00 Korean time. Expiry alerts repeat, so one dropped in quiet hours returns on the first cycle of the next working window. The last-active-token death alert sets `urgent: true` and ignores quiet hours: it means the provider pool is empty, which cannot wait for the next working day.
213
214
  - Notifications are fail-open: unset `SLACK_BOT_TOKEN`/`SLACK_CHANNEL_ID` is a silent no-op, and send failures (including HTTP 200 with `ok:false`) only warn. Payloads carry an internal reason code such as `anthropic:400`, never the raw provider response body.
214
- - `expired-token-reminder.ts` repeats the session-expiry alert for tokens that are still inactive, because the one-shot alert is lost on anyone not watching the channel at that moment and re-login only happens when a person does it. It reuses the same `세션 만료` title rather than inventing a second name — there is one event, so a differently-named alert would read as a separate problem. It runs every `SLACK_EXPIRY_REMINDER_INTERVAL_MINUTES` (unset or 0 disables), batches every inactive token into one message, and sends nothing when none are inactive — a periodic "all clear" trains people to ignore the channel.
215
+ - `expired-token-reminder.ts` repeats the session-expiry alert for tokens that are still inactive, because the one-shot alert is lost on anyone not watching the channel at that moment and re-login only happens when a person does it. It reuses the same `세션 만료` title rather than inventing a second name — there is one event, so a differently-named alert would read as a separate problem. It runs once at startup and then every `SLACK_EXPIRY_REMINDER_INTERVAL_MINUTES` (unset or 0 disables), batches every inactive token into one message, and sends nothing when none are inactive. The startup run matters because a restart is exactly when someone is watching, and waiting a full interval to learn a token is already dead is backwards; the cost is one extra alert per restart — a periodic "all clear" trains people to ignore the channel.
215
216
  - Owners are mentioned through `SLACK_USER_MAP` (`tokenName:SlackUserId`) on every token notification, not just the reminder. The map is explicit rather than resolved through `users.list`: Slack handles diverge from token names often enough (`haze`→`haze.lee`, `byeongjun`→`potados`) that auto-matching drops the mention silently, and an alert nobody is tagged in has no one to act on it. Shared accounts are left out of the map on purpose and appear by name only. Each instance sends its own reminder — unlike deactivation there is no state change to gate on, and a few duplicates a day cost less than a locking scheme.
216
217
 
217
218
  ## Quota Threshold Semantics