@cartanova/qgrid-cli 2.6.9 → 2.6.11

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 (58) hide show
  1. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +137 -60
  2. package/bundle/dist/application/qgrid/qgrid.frame.js +5 -2
  3. package/bundle/dist/application/setting/setting.model.js +65 -3
  4. package/bundle/dist/application/setting/setting.types.js +13 -3
  5. package/bundle/dist/i18n/en.js +3 -1
  6. package/bundle/dist/i18n/ko.js +3 -1
  7. package/bundle/dist/utils/process-supervisor.js +22 -0
  8. package/bundle/dist/utils/server-restart.js +61 -0
  9. package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +7 -0
  10. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +113 -0
  11. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +172 -75
  12. package/bundle/src/application/qgrid/qgrid.frame.test.ts +45 -0
  13. package/bundle/src/application/qgrid/qgrid.frame.ts +4 -0
  14. package/bundle/src/application/setting/setting.model.test.ts +104 -0
  15. package/bundle/src/application/setting/setting.model.ts +75 -3
  16. package/bundle/src/application/setting/setting.types.ts +12 -0
  17. package/bundle/src/application/sonamu.generated.http +7 -0
  18. package/bundle/src/i18n/en.ts +3 -0
  19. package/bundle/src/i18n/ko.ts +3 -0
  20. package/bundle/src/server-error-handler.test.ts +15 -0
  21. package/bundle/src/utils/process-supervisor.test.ts +24 -0
  22. package/bundle/src/utils/process-supervisor.ts +17 -0
  23. package/bundle/src/utils/server-restart.test.ts +76 -0
  24. package/bundle/src/utils/server-restart.ts +68 -0
  25. package/bundle/web-dist/client/assets/index-CFvFRDk_.js +78 -0
  26. package/bundle/web-dist/client/assets/index-CJ9iTI_V.css +1 -0
  27. package/bundle/web-dist/client/assets/{logs-5gACO5tJ.js → logs-DeAK5Uyu.js} +1 -1
  28. package/bundle/web-dist/client/assets/{monit-DRCc50GU.js → monit-Rj94Sz3_.js} +1 -1
  29. package/bundle/web-dist/client/assets/{routes-DBdI9Ba1.js → routes-DVseTo6T.js} +1 -1
  30. package/bundle/web-dist/client/assets/{sd.generated-Bwa7rses.js → sd.generated-W6XqgWK6.js} +8 -8
  31. package/bundle/web-dist/client/assets/services.generated-Dz5pA5Fb.js +1 -0
  32. package/bundle/web-dist/client/assets/settings-DvuRDFfS.js +1 -0
  33. package/bundle/web-dist/client/assets/{show-BCRrSc8-.js → show-C7etBj89.js} +1 -1
  34. package/bundle/web-dist/client/assets/{tokens-CDiXKYGf.js → tokens-BroPe7Tn.js} +1 -1
  35. package/bundle/web-dist/client/assets/{use-oauth-login-flow-Qqg4koSg.js → use-oauth-login-flow-CuLvs_CW.js} +1 -1
  36. package/bundle/web-dist/client/index.html +3 -3
  37. package/bundle/web-dist/server/assets/{lazyRouteComponent-Ws7I6fL-.js → lazyRouteComponent-C-N6sd5N.js} +2 -2
  38. package/bundle/web-dist/server/assets/{logs-C1rxLh8H.js → logs-CVkeSr1S.js} +3 -3
  39. package/bundle/web-dist/server/assets/{monit-C8gx9Sar.js → monit-Bb6QfT9V.js} +3 -3
  40. package/bundle/web-dist/server/assets/{plus-BISXNqRU.js → plus-B8JRuCb9.js} +1 -1
  41. package/bundle/web-dist/server/assets/{react-dom-D7duWOwf.js → react-dom-ByF9tqTB.js} +1 -1
  42. package/bundle/web-dist/server/assets/{routes-ZW_Ih5Em.js → routes-Dnm-Eti6.js} +4 -4
  43. package/bundle/web-dist/server/assets/{sd.generated-LO-h1_16.js → sd.generated-CuyajYtc.js} +126 -116
  44. package/bundle/web-dist/server/assets/{services.generated-CZIChgHV.js → services.generated-BAGLi2r8.js} +9 -1
  45. package/bundle/web-dist/server/assets/{settings--aoatERH.js → settings-4T3nBfY8.js} +271 -39
  46. package/bundle/web-dist/server/assets/{show-BY27w9oi.js → show-CQw03iTP.js} +3 -3
  47. package/bundle/web-dist/server/assets/{tokens-DIObtTuZ.js → tokens-Di_SGPYj.js} +4 -4
  48. package/bundle/web-dist/server/assets/{use-oauth-login-flow-V3k8PSqQ.js → use-oauth-login-flow-DiYCMNAQ.js} +2 -2
  49. package/bundle/web-dist/server/entry-server.generated.js +10 -10
  50. package/dist/cli.js +93 -38
  51. package/package.json +1 -1
  52. package/skills/qgrid/references/cli-env-and-server-boot.md +34 -3
  53. package/skills/qgrid/references/decision-rationale.md +7 -0
  54. package/skills/qgrid/references/tool-calling-and-multiturn.md +21 -0
  55. package/bundle/web-dist/client/assets/index-DnnliAzf.js +0 -78
  56. package/bundle/web-dist/client/assets/index-rdbxJinT.css +0 -1
  57. package/bundle/web-dist/client/assets/services.generated-BzwTuZRP.js +0 -1
  58. package/bundle/web-dist/client/assets/settings-B62_r1i4.js +0 -1
@@ -18,6 +18,7 @@ const {
18
18
  dispatcherQueryStreamMock,
19
19
  beforeQueryMock,
20
20
  afterQueryMock,
21
+ assertNativeRunAdmissionMock,
21
22
  finishRunWithErrorMock,
22
23
  finishRunAbortedMock,
23
24
  getRateLimitsByTokenIdMock,
@@ -33,6 +34,7 @@ const {
33
34
  dispatcherQueryStreamMock: vi.fn(),
34
35
  beforeQueryMock: vi.fn(),
35
36
  afterQueryMock: vi.fn(),
37
+ assertNativeRunAdmissionMock: vi.fn(),
36
38
  finishRunWithErrorMock: vi.fn(),
37
39
  finishRunAbortedMock: vi.fn(),
38
40
  getRateLimitsByTokenIdMock: vi.fn(),
@@ -56,6 +58,13 @@ vi.mock("../token/token.model", () => ({
56
58
  },
57
59
  }));
58
60
 
61
+ // QgridFrame의 token-death 연결은 이 스위트의 관심사가 아니다. isolate:false 워커에서
62
+ // 부분 TokenModel mock으로 token-death 실제 모듈을 먼저 캐시하지 않도록 경계를 끊는다.
63
+ vi.mock("./token-death", () => ({
64
+ deactivateAuthDeadToken: vi.fn(),
65
+ notifyTokenAdded: vi.fn(),
66
+ }));
67
+
59
68
  vi.mock("./qgrid.dispatcher", async (importOriginal) => {
60
69
  const original = await importOriginal<typeof import("./qgrid.dispatcher")>();
61
70
  return {
@@ -71,6 +80,7 @@ vi.mock("./qgrid.dispatcher", async (importOriginal) => {
71
80
  });
72
81
 
73
82
  vi.mock("./qgrid-run-lifecycle", () => ({
83
+ assertNativeRunAdmission: assertNativeRunAdmissionMock,
74
84
  beforeQuery: beforeQueryMock,
75
85
  afterQuery: afterQueryMock,
76
86
  finishRunWithError: finishRunWithErrorMock,
@@ -161,6 +171,7 @@ describe("QgridFrame.query request logging", () => {
161
171
  beforeEach(() => {
162
172
  beforeQueryMock.mockReset().mockResolvedValue({ requestLogId: 41, stepIndex: 0 });
163
173
  afterQueryMock.mockReset().mockResolvedValue({});
174
+ assertNativeRunAdmissionMock.mockReset();
164
175
  finishRunWithErrorMock.mockReset();
165
176
  dispatcherQueryMock.mockReset();
166
177
  });
@@ -258,6 +269,18 @@ describe("QgridFrame.query request logging", () => {
258
269
  expect(finishRunWithErrorMock).not.toHaveBeenCalled();
259
270
  });
260
271
 
272
+ it("rejects logger-disabled native dispatch while restart admission is closed", async () => {
273
+ assertNativeRunAdmissionMock.mockImplementationOnce(() => {
274
+ throw Object.assign(new Error("server restarting"), { statusCode: 503 });
275
+ });
276
+
277
+ await expect(
278
+ QgridFrame.query({ prompt: "hi", model: "openai/gpt-5-codex", logger: false }),
279
+ ).rejects.toMatchObject({ statusCode: 503 });
280
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
281
+ expect(beforeQueryMock).not.toHaveBeenCalled();
282
+ });
283
+
261
284
  it("merges a tool run id with the provider thread coordinate", async () => {
262
285
  dispatcherQueryMock.mockResolvedValueOnce(queryOutput());
263
286
  afterQueryMock.mockResolvedValueOnce({ runContext: { requestLogId: 41 } });
@@ -843,6 +866,7 @@ describe("QgridFrame.queryStream request logging", () => {
843
866
  beforeEach(() => {
844
867
  beforeQueryMock.mockReset().mockResolvedValue({ requestLogId: 52, stepIndex: 0 });
845
868
  afterQueryMock.mockReset().mockResolvedValue({});
869
+ assertNativeRunAdmissionMock.mockReset();
846
870
  finishRunWithErrorMock.mockReset();
847
871
  finishRunAbortedMock.mockReset();
848
872
  dispatcherQueryStreamMock.mockReset();
@@ -876,6 +900,27 @@ describe("QgridFrame.queryStream request logging", () => {
876
900
  };
877
901
  }
878
902
 
903
+ it("rejects stream preparation while restart admission is closed", async () => {
904
+ assertNativeRunAdmissionMock.mockImplementationOnce(() => {
905
+ throw Object.assign(new Error("server restarting"), { statusCode: 503 });
906
+ });
907
+
908
+ await expect(QgridFrame.prepareStream({ prompt: "hi" })).rejects.toMatchObject({
909
+ statusCode: 503,
910
+ });
911
+ expect(dispatcherQueryStreamMock).not.toHaveBeenCalled();
912
+ });
913
+
914
+ it("rechecks admission before dispatching a previously prepared stream", async () => {
915
+ const { streamId } = await QgridFrame.prepareStream({ prompt: "hi", logger: false });
916
+ assertNativeRunAdmissionMock.mockImplementationOnce(() => {
917
+ throw Object.assign(new Error("server restarting"), { statusCode: 503 });
918
+ });
919
+
920
+ await expect(QgridFrame.queryStream(streamId)).rejects.toMatchObject({ statusCode: 503 });
921
+ expect(dispatcherQueryStreamMock).not.toHaveBeenCalled();
922
+ });
923
+
879
924
  function installSseContext() {
880
925
  let closeHandler: (() => void) | undefined;
881
926
  const sse = {
@@ -28,6 +28,7 @@ import {
28
28
  RefreshFailedError,
29
29
  } from "./oauth";
30
30
  import {
31
+ assertNativeRunAdmission,
31
32
  afterQuery,
32
33
  beforeQuery,
33
34
  finishRunAborted,
@@ -210,6 +211,7 @@ class QgridFrameClass extends BaseFrameClass {
210
211
 
211
212
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
212
213
  async query(args: QueryInput): Promise<QueryOutput> {
214
+ assertNativeRunAdmission();
213
215
  rejectInvalidCallerSchemas(args);
214
216
  const disconnect = createHttpDisconnectHandle();
215
217
  try {
@@ -259,6 +261,7 @@ class QgridFrameClass extends BaseFrameClass {
259
261
 
260
262
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
261
263
  async prepareStream(args: QueryInput): Promise<{ streamId: string }> {
264
+ assertNativeRunAdmission();
262
265
  rejectImageGenerationStream(args);
263
266
  rejectInvalidCallerSchemas(args);
264
267
  const streamId = crypto.randomUUID();
@@ -273,6 +276,7 @@ class QgridFrameClass extends BaseFrameClass {
273
276
  const args = pendingStreams.get(streamId);
274
277
  pendingStreams.delete(streamId);
275
278
  if (!args) throw new Error("invalid or expired streamId");
279
+ assertNativeRunAdmission();
276
280
  rejectImageGenerationStream(args);
277
281
 
278
282
  const ctx = Sonamu.getContext();
@@ -0,0 +1,104 @@
1
+ import { EventEmitter } from "node:events";
2
+
3
+ import { Sonamu } from "sonamu";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import { resetNativeRunRegistryForTests } from "../qgrid/qgrid-run-lifecycle";
7
+ import { resetServerRestartForTests } from "../../utils/server-restart";
8
+ import { SettingModel } from "./setting.model";
9
+
10
+ function installContext(headers: Record<string, string>) {
11
+ const raw = Object.assign(new EventEmitter(), {
12
+ destroyed: false,
13
+ writableEnded: false,
14
+ });
15
+ vi.spyOn(Sonamu, "getContext").mockReturnValue({
16
+ transport: "http",
17
+ headers,
18
+ request: { protocol: "http" },
19
+ reply: { raw },
20
+ } as never);
21
+ return raw;
22
+ }
23
+
24
+ describe("SettingModel.restartServer", () => {
25
+ beforeEach(() => {
26
+ vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
27
+ });
28
+
29
+ afterEach(() => {
30
+ resetServerRestartForTests();
31
+ resetNativeRunRegistryForTests();
32
+ vi.unstubAllEnvs();
33
+ vi.restoreAllMocks();
34
+ });
35
+
36
+ it("rejects restart when pm2 will not respawn the process", async () => {
37
+ const raw = installContext({ host: "localhost:44900" });
38
+ vi.stubEnv("pm_id", undefined);
39
+
40
+ await expect(SettingModel.restartServer()).rejects.toMatchObject({ statusCode: 400 });
41
+ expect(raw.listenerCount("finish")).toBe(0);
42
+ expect(process.exit).not.toHaveBeenCalled();
43
+ });
44
+
45
+ it("rejects a browser request whose Origin host does not match Host", async () => {
46
+ installContext({ origin: "https://evil.example", host: "qgrid.example.com" });
47
+ vi.stubEnv("pm_id", "0");
48
+
49
+ await expect(SettingModel.restartServer()).rejects.toMatchObject({ statusCode: 403 });
50
+ expect(process.exit).not.toHaveBeenCalled();
51
+ });
52
+
53
+ it("rejects a malformed present Origin instead of treating it as an operational client", async () => {
54
+ installContext({ origin: "null", host: "qgrid.example.com" });
55
+ vi.stubEnv("pm_id", "0");
56
+
57
+ await expect(SettingModel.restartServer()).rejects.toMatchObject({ statusCode: 403 });
58
+ expect(process.exit).not.toHaveBeenCalled();
59
+ });
60
+
61
+ it("accepts a matching Origin using the Caddy-forwarded host", async () => {
62
+ const raw = installContext({
63
+ origin: "https://qgrid.example.com",
64
+ host: "127.0.0.1:44900",
65
+ "x-forwarded-host": "qgrid.example.com",
66
+ });
67
+ vi.stubEnv("pm_id", "0");
68
+
69
+ await expect(SettingModel.restartServer()).resolves.toEqual({ supervisor: "pm2" });
70
+ expect(process.exit).not.toHaveBeenCalled();
71
+
72
+ raw.emit("finish");
73
+ expect(process.exit).toHaveBeenCalledOnce();
74
+ expect(process.exit).toHaveBeenCalledWith(0);
75
+ });
76
+
77
+ it("accepts an Origin-less operational request", async () => {
78
+ const raw = installContext({ host: "qgrid.example.com" });
79
+ vi.stubEnv("pm_id", "0");
80
+
81
+ await expect(SettingModel.restartServer()).resolves.toEqual({ supervisor: "pm2" });
82
+ expect(process.exit).not.toHaveBeenCalled();
83
+
84
+ raw.emit("finish");
85
+ expect(process.exit).toHaveBeenCalledWith(0);
86
+ });
87
+
88
+ it("coordinates simultaneous calls through one process exit", async () => {
89
+ const firstRaw = installContext({ host: "qgrid.example.com" });
90
+ vi.stubEnv("pm_id", "0");
91
+
92
+ const first = SettingModel.restartServer();
93
+ const second = SettingModel.restartServer();
94
+ await expect(Promise.all([first, second])).resolves.toEqual([
95
+ { supervisor: "pm2" },
96
+ { supervisor: "pm2" },
97
+ ]);
98
+
99
+ firstRaw.emit("finish");
100
+ firstRaw.emit("close");
101
+ expect(process.exit).toHaveBeenCalledOnce();
102
+ expect(process.exit).toHaveBeenCalledWith(0);
103
+ });
104
+ });
@@ -1,14 +1,19 @@
1
+ import { getLogger } from "@logtape/logtape";
1
2
  import {
2
3
  BaseModelClass,
3
4
  type ListResult,
4
5
  asArray,
5
6
  NotFoundException,
6
7
  BadRequestException,
8
+ SoException,
9
+ Sonamu,
7
10
  api,
8
11
  exhaustive,
9
12
  } from "sonamu";
10
13
 
11
14
  import { SD } from "../../i18n/sd.generated";
15
+ import { detectSupervisor } from "../../utils/process-supervisor";
16
+ import { armServerRestartExit, beginServerRestart } from "../../utils/server-restart";
12
17
  import { type SettingSubsetKey, type SettingSubsetMapping } from "../sonamu.generated";
13
18
  import { settingSubsetQueries, settingLoaderQueries } from "../sonamu.generated.sso";
14
19
  import { findSettingDef, maskSecret, SETTING_DEFS, validateSettingValue } from "./setting.constant";
@@ -25,8 +30,37 @@ import {
25
30
  type SettingListParams,
26
31
  type SettingSaveParams,
27
32
  type SettingsResponse,
33
+ type SupervisorKind,
28
34
  } from "./setting.types";
29
35
 
36
+ const logger = getLogger(["qgrid", "setting"]);
37
+
38
+ class ForbiddenException extends SoException {
39
+ constructor() {
40
+ super(403, SD("error.forbidden"));
41
+ }
42
+ }
43
+
44
+ function firstHeaderValue(value: string | string[] | undefined): string | undefined {
45
+ return (Array.isArray(value) ? value[0] : value)?.split(",")[0]?.trim();
46
+ }
47
+
48
+ /** Origin 없는 운영 CLI 요청은 통과시키되, 브라우저 교차 출처 POST 는 막는다. */
49
+ function assertRestartOrigin(): void {
50
+ const { headers } = Sonamu.getContext();
51
+ const origin = firstHeaderValue(headers.origin);
52
+ if (headers.origin === undefined) return;
53
+ if (!origin) throw new ForbiddenException();
54
+
55
+ const host = firstHeaderValue(headers["x-forwarded-host"]) ?? firstHeaderValue(headers.host);
56
+ try {
57
+ if (!host || new URL(origin).host !== host) throw new ForbiddenException();
58
+ } catch (error) {
59
+ if (error instanceof ForbiddenException) throw error;
60
+ throw new ForbiddenException();
61
+ }
62
+ }
63
+
30
64
  /*
31
65
  Setting Model
32
66
  */
@@ -207,8 +241,8 @@ class SettingModelClass extends BaseModelClass<
207
241
  label: def.label,
208
242
  kind: def.kind,
209
243
  applies: def.applies,
210
- // 눈 토글을 위해 secret 도 원본을 내려보낸다. 이 엔드포인트는 배포 경계에서 인증해야
211
- // 하며, dev0 는 Caddy 인증을 통과한 운영자만 접근할 수 있다.
244
+ // 눈 토글을 위해 secret 도 원본을 내려보낸다. 이 엔드포인트는 배포 경계에서
245
+ // 반드시 인증해야 한다.
212
246
  value: effective,
213
247
  source,
214
248
  min: def.min ?? null,
@@ -218,7 +252,7 @@ class SettingModelClass extends BaseModelClass<
218
252
  };
219
253
  });
220
254
 
221
- return { settings, runtime: this.runtimeInfo() };
255
+ return { settings, runtime: this.runtimeInfo(), supervisor: detectSupervisor() };
222
256
  }
223
257
 
224
258
  private runtimeInfo(): RuntimeInfoItem[] {
@@ -272,6 +306,44 @@ class SettingModelClass extends BaseModelClass<
272
306
  const { sendExpiredTokenReminderNow } = await import("../qgrid/expired-token-reminder");
273
307
  return { sent: await sendExpiredTokenReminderNow() };
274
308
  }
309
+
310
+ /**
311
+ * 서버를 재시작한다 — 정확히는 스스로 종료하고, pm2 가 다시 띄우는 것에 맡긴다.
312
+ *
313
+ * `restart` 로 표시된 워커 설정은 dispatcher 생성자에서 한 번만 읽히므로 이 경로 말고는
314
+ * 반영할 방법이 없었다. 지금까지는 SSH 로 `pm2 restart` 를 쳐야 했다.
315
+ *
316
+ * 부수 효과가 하나 더 있다: pm2 는 `ecosystem.config.cjs` 의 원래 명령으로 다시 띄우고,
317
+ * 그 명령에 `--skip-update` 가 없으면 CLI 가 npm 최신 버전을 받아 올린다. 즉 재시작은
318
+ * 배포이기도 하다 — 화면 문구가 이 사실을 함께 알린다.
319
+ */
320
+ @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
321
+ async restartServer(): Promise<{ supervisor: SupervisorKind }> {
322
+ assertRestartOrigin();
323
+ const supervisor = detectSupervisor();
324
+ if (!supervisor) {
325
+ // 다시 띄워줄 주체가 없으면 종료가 곧 정지다. 살릴 수 없는데 죽이지 않는다.
326
+ throw new BadRequestException(SD("setting.noSupervisor")());
327
+ }
328
+
329
+ const { reply } = Sonamu.getContext();
330
+ const started = beginServerRestart();
331
+ if (started) {
332
+ logger.warn(`restart requested via dashboard, exiting for ${supervisor} to respawn`);
333
+
334
+ try {
335
+ // 정적 import로 qgrid.frame/token 그래프를 당겨오면 모듈 목 테스트가 오염된다.
336
+ // 재시작이 실제로 확정된 시점에 active native run 정합성 처리만 로드한다.
337
+ const { finishActiveNativeRunsForRestart } = await import("../qgrid/qgrid-run-lifecycle");
338
+ await finishActiveNativeRunsForRestart();
339
+ } finally {
340
+ // 정합성 처리가 실패해도 응답이 flush 된 뒤에는 재시작을 끝까지 진행한다.
341
+ armServerRestartExit(reply.raw);
342
+ }
343
+ }
344
+
345
+ return { supervisor };
346
+ }
275
347
  }
276
348
 
277
349
  export const SettingModel = new SettingModelClass();
@@ -21,6 +21,16 @@ export type SettingApplies = z.infer<typeof SettingApplies>;
21
21
  export const SettingSource = z.enum(["db", "env", "default"]);
22
22
  export type SettingSource = z.infer<typeof SettingSource>;
23
23
 
24
+ /**
25
+ * 프로세스를 다시 띄워주는 도구. 재시작은 스스로 종료하고 이 도구에 맡기므로,
26
+ * 없으면(null) 화면에서 재시작을 시도조차 하지 않는다.
27
+ *
28
+ * API 반환 타입에 쓰이므로 zod 로 둔다 — sonamu syncer 가 클라이언트 코드를 만들 때
29
+ * 타입만 있는 선언은 모듈을 찾지 못한다.
30
+ */
31
+ export const SupervisorKind = z.enum(["pm2"]);
32
+ export type SupervisorKind = z.infer<typeof SupervisorKind>;
33
+
24
34
  export const SettingItem = z.object({
25
35
  key: z.string(),
26
36
  group: z.string(),
@@ -49,5 +59,7 @@ export type RuntimeInfoItem = z.infer<typeof RuntimeInfoItem>;
49
59
  export const SettingsResponse = z.object({
50
60
  settings: SettingItem.array(),
51
61
  runtime: RuntimeInfoItem.array(),
62
+ /** null 이면 재시작 버튼을 쓸 수 없다 — 종료해도 되살릴 주체가 없다. */
63
+ supervisor: SupervisorKind.nullable(),
52
64
  });
53
65
  export type SettingsResponse = z.infer<typeof SettingsResponse>;
@@ -124,6 +124,13 @@ Content-Type: application/json
124
124
 
125
125
  ###
126
126
 
127
+ POST {{baseUrl}}/api/setting/restartServer
128
+ Content-Type: application/json
129
+
130
+ {}
131
+
132
+ ###
133
+
127
134
  GET {{baseUrl}}/api/requestLogStep/findById
128
135
  ?subset=A
129
136
  &id=0
@@ -49,9 +49,12 @@ export default {
49
49
  "error.serviceUnavailable": "Service unavailable",
50
50
  "setting.invalidValue": (reason: string) => `Invalid setting value: ${reason}`,
51
51
  "setting.unknownKey": (key: string) => `Unknown setting key ${key}`,
52
+ "setting.noSupervisor": () =>
53
+ "Restart is unavailable when running locally — nothing would bring the server back up",
52
54
  "qgrid.dispatcherFailed": (provider: string) => `${provider} dispatcher failed to initialize`,
53
55
  "qgrid.dispatcherStarting": (provider: string) =>
54
56
  `${provider} dispatcher is starting up. Please retry shortly`,
57
+ "qgrid.restartPending": () => "Server restart is in progress. Please retry shortly",
55
58
  "error.unauthorized": "Unauthorized",
56
59
  notFound: (name: string, id: number) => `${name} ID ${id} not found`,
57
60
  "validation.email": "Invalid email format",
@@ -48,9 +48,12 @@ export default {
48
48
  "error.serviceUnavailable": "서비스를 사용할 수 없습니다",
49
49
  "setting.invalidValue": (reason: string) => `설정 값이 올바르지 않습니다: ${reason}`,
50
50
  "setting.unknownKey": (key: string) => `알 수 없는 설정 키 ${key}`,
51
+ "setting.noSupervisor": () =>
52
+ "로컬 실행 환경에서는 재시작할 수 없습니다 — 종료하면 다시 켜줄 모니터링 도구가 없습니다",
51
53
  "qgrid.dispatcherFailed": (provider: string) => `${provider} dispatcher 초기화에 실패했습니다`,
52
54
  "qgrid.dispatcherStarting": (provider: string) =>
53
55
  `${provider} dispatcher 기동 중입니다. 잠시 후 다시 시도해주세요`,
56
+ "qgrid.restartPending": () => "서버 재시작 중입니다. 잠시 후 다시 시도해주세요",
54
57
  "error.unauthorized": "인증이 필요합니다",
55
58
  "error.unknownSearchField": (field: string) => `구현되지 않은 검색 필드 ${field}`,
56
59
  notFound: (name: string, id: number) => `존재하지 않는 ${name} ID ${id}`,
@@ -3,11 +3,18 @@ import {
3
3
  BadRequestException,
4
4
  InternalServerErrorException,
5
5
  ServiceUnavailableException,
6
+ SoException,
6
7
  } from "sonamu";
7
8
  import { describe, expect, it, vi } from "vitest";
8
9
 
9
10
  import { handleServerError } from "./server-error-handler";
10
11
 
12
+ class ForbiddenException extends SoException {
13
+ constructor() {
14
+ super(403, "forbidden" as never);
15
+ }
16
+ }
17
+
11
18
  function fakeReply() {
12
19
  const reply = {
13
20
  header: vi.fn(),
@@ -27,6 +34,14 @@ describe("handleServerError", () => {
27
34
  expect(reply.status).toHaveBeenCalledWith(400);
28
35
  });
29
36
 
37
+ it("local Sonamu 403 예외도 예외 경계에서 보존한다", () => {
38
+ const reply = fakeReply();
39
+
40
+ handleServerError(new ForbiddenException(), reply);
41
+
42
+ expect(reply.status).toHaveBeenCalledWith(403);
43
+ });
44
+
30
45
  it("Sonamu 예외가 아니면 statusCode 를 신뢰하지 않고 500 으로 내린다", () => {
31
46
  const reply = fakeReply();
32
47
  // upstream fetch 오류처럼 statusCode 를 달고 오는 예외 — 그대로 에코하면 upstream 의
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { detectSupervisor } from "./process-supervisor";
4
+
5
+ describe("detectSupervisor", () => {
6
+ it("pm2 아래에서는 pm2 로 판별한다", () => {
7
+ // pm2 는 자식에게 pm_id 를 넣는다. "0" 이 흔한 값이라 falsy 검사로는 놓친다.
8
+ expect(detectSupervisor({ pm_id: "0" })).toBe("pm2");
9
+ });
10
+
11
+ it("systemd 환경변수만으로는 재시작 보장을 가정하지 않는다", () => {
12
+ expect(detectSupervisor({ INVOCATION_ID: "abc123" })).toBeNull();
13
+ });
14
+
15
+ it("로컬 실행에서는 null 이다", () => {
16
+ // 종료해도 다시 띄워줄 주체가 없어 재시작을 막아야 한다.
17
+ expect(detectSupervisor({})).toBeNull();
18
+ });
19
+
20
+ it("빈 문자열도 pm2 로 인정한다", () => {
21
+ // 값이 아니라 존재 여부가 신호다. pm_id 가 빈 문자열로 오는 환경이 있어도 pm2 다.
22
+ expect(detectSupervisor({ pm_id: "" })).toBe("pm2");
23
+ });
24
+ });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * 프로세스 모니터링 도구 감지.
3
+ *
4
+ * 화면의 "재시작"은 스스로를 종료하는 것이다 — pm2 같은 도구가 죽은 프로세스를 다시
5
+ * 띄워주는 것이 곧 재시작이다. 그래서 그런 도구 없이 종료하면 그냥 서버가 내려간다.
6
+ * 로컬 개발에서 버튼을 누르면 아무도 살려주지 않으므로, 종료 전에 반드시 확인한다.
7
+ *
8
+ * 현재 배포 계약은 pm2 만 재시작을 보장한다. systemd 의 `INVOCATION_ID` 는 해당
9
+ * unit 이 `Restart=` 를 쓴다는 뜻이 아니므로 안전한 감독자 신호로 사용할 수 없다.
10
+ *
11
+ * 값이 아니라 존재 여부가 신호다. pm2 는 첫 프로세스에 `pm_id=0` 을 주므로 truthy 검사로
12
+ * 판별하면 놓친다.
13
+ */
14
+ export function detectSupervisor(env: NodeJS.ProcessEnv = process.env): "pm2" | null {
15
+ if (env.pm_id !== undefined) return "pm2";
16
+ return null;
17
+ }
@@ -0,0 +1,76 @@
1
+ import { EventEmitter } from "node:events";
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import {
6
+ armServerRestartExit,
7
+ beginServerRestart,
8
+ isRestartPending,
9
+ resetServerRestartForTests,
10
+ } from "./server-restart";
11
+
12
+ describe("server restart coordination", () => {
13
+ afterEach(() => {
14
+ resetServerRestartForTests();
15
+ vi.restoreAllMocks();
16
+ vi.useRealTimers();
17
+ });
18
+
19
+ it("exits exactly once after the first response finishes", () => {
20
+ const raw = new EventEmitter();
21
+ const exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
22
+
23
+ expect(beginServerRestart()).toBe(true);
24
+ expect(beginServerRestart()).toBe(false);
25
+ expect(isRestartPending()).toBe(true);
26
+ expect(exit).not.toHaveBeenCalled();
27
+
28
+ armServerRestartExit(raw as never);
29
+ raw.emit("finish");
30
+ raw.emit("finish");
31
+
32
+ expect(exit).toHaveBeenCalledTimes(1);
33
+ expect(exit).toHaveBeenCalledWith(0);
34
+ });
35
+
36
+ it("uses a finite fallback when the response never finishes", () => {
37
+ vi.useFakeTimers();
38
+ const exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
39
+
40
+ beginServerRestart();
41
+ armServerRestartExit(new EventEmitter() as never);
42
+ vi.advanceTimersByTime(4_999);
43
+ expect(exit).not.toHaveBeenCalled();
44
+
45
+ vi.advanceTimersByTime(1);
46
+ expect(exit).toHaveBeenCalledOnce();
47
+ expect(exit).toHaveBeenCalledWith(0);
48
+ });
49
+
50
+ it("exits once when the client disconnects before the response finishes", () => {
51
+ const raw = Object.assign(new EventEmitter(), { destroyed: false, writableEnded: false });
52
+ const exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
53
+
54
+ beginServerRestart();
55
+ armServerRestartExit(raw as never);
56
+ raw.emit("close");
57
+ raw.emit("finish");
58
+
59
+ expect(exit).toHaveBeenCalledOnce();
60
+ expect(exit).toHaveBeenCalledWith(0);
61
+ });
62
+
63
+ it("does not exit before reconciliation arms the response lifecycle", () => {
64
+ vi.useFakeTimers();
65
+ const raw = Object.assign(new EventEmitter(), { destroyed: true, writableEnded: false });
66
+ const exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
67
+
68
+ beginServerRestart();
69
+ raw.emit("close");
70
+ vi.advanceTimersByTime(5_000);
71
+ expect(exit).not.toHaveBeenCalled();
72
+
73
+ armServerRestartExit(raw as never);
74
+ expect(exit).toHaveBeenCalledOnce();
75
+ });
76
+ });
@@ -0,0 +1,68 @@
1
+ import { type ServerResponse } from "node:http";
2
+
3
+ const RESTART_EXIT_FALLBACK_MS = 5_000;
4
+
5
+ let restartPending = false;
6
+ let exitTriggered = false;
7
+ let exitArmed = false;
8
+ let fallbackTimer: NodeJS.Timeout | undefined;
9
+ let armedReplyRaw: ReplyRaw | undefined;
10
+ let armedExit: (() => void) | undefined;
11
+
12
+ type ReplyRaw = Pick<ServerResponse, "once" | "removeListener" | "destroyed" | "writableEnded">;
13
+
14
+ /** 재시작 요청 뒤에는 새 native provider 실행을 받지 않는다. */
15
+ export function isRestartPending(): boolean {
16
+ return restartPending;
17
+ }
18
+
19
+ /**
20
+ * 프로세스 전체에서 최초 요청만 재시작을 시작한다. 종료 신호는 active run 정합성 처리가
21
+ * 끝난 뒤 별도로 arm 한다. 그래야 이미 닫힌 연결이나 fallback 타이머가 DB 정리보다 앞서
22
+ * 프로세스를 끝내지 않는다.
23
+ */
24
+ export function beginServerRestart(): boolean {
25
+ if (restartPending) return false;
26
+ restartPending = true;
27
+ return true;
28
+ }
29
+
30
+ function clearExitArm(): void {
31
+ if (fallbackTimer) clearTimeout(fallbackTimer);
32
+ fallbackTimer = undefined;
33
+ if (armedReplyRaw && armedExit) {
34
+ armedReplyRaw.removeListener("finish", armedExit);
35
+ armedReplyRaw.removeListener("close", armedExit);
36
+ }
37
+ armedReplyRaw = undefined;
38
+ armedExit = undefined;
39
+ }
40
+
41
+ /** 정합성 처리가 끝난 뒤 HTTP 응답 flush/연결 종료/유한 fallback 중 먼저 온 신호로 종료한다. */
42
+ export function armServerRestartExit(replyRaw: ReplyRaw): void {
43
+ if (!restartPending || exitArmed) return;
44
+ exitArmed = true;
45
+
46
+ const exitOnce = () => {
47
+ if (exitTriggered) return;
48
+ exitTriggered = true;
49
+ clearExitArm();
50
+ process.exit(0);
51
+ };
52
+
53
+ armedReplyRaw = replyRaw;
54
+ armedExit = exitOnce;
55
+ replyRaw.once("finish", exitOnce);
56
+ replyRaw.once("close", exitOnce);
57
+ fallbackTimer = setTimeout(exitOnce, RESTART_EXIT_FALLBACK_MS);
58
+ fallbackTimer.unref();
59
+ if (replyRaw.destroyed && !replyRaw.writableEnded) exitOnce();
60
+ }
61
+
62
+ /** 모듈 전역 상태를 격리해야 하는 단위 테스트 전용. */
63
+ export function resetServerRestartForTests(): void {
64
+ clearExitArm();
65
+ restartPending = false;
66
+ exitTriggered = false;
67
+ exitArmed = false;
68
+ }