@gaonjs/cli 0.41.6 → 0.42.0

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.
@@ -0,0 +1,38 @@
1
+ export interface ReforkPolicy {
2
+ /** 이 시간 이상 산 워커의 죽음은 '정상 교체'로 보고 즉시 refork·streak 리셋. */
3
+ readonly minHealthyMs: number;
4
+ /** 첫 백오프(ms). 이후 연속 부팅 크래시마다 2배. */
5
+ readonly baseMs: number;
6
+ /** 백오프 상한(ms). */
7
+ readonly maxMs: number;
8
+ }
9
+ /** 연속 부팅 크래시 streak 에 대한 백오프 지연(ms). streak≤0 이면 0(즉시). */
10
+ export declare function reforkDelayMs(streak: number, p: Pick<ReforkPolicy, 'baseMs' | 'maxMs'>): number;
11
+ export interface ReforkDeps {
12
+ /** 새 워커를 fork 한다. */
13
+ fork(): void;
14
+ /** delayMs 후 fn 을 실행할 타이머를 건다. 취소 함수를 반환한다. */
15
+ schedule(fn: () => void, delayMs: number): () => void;
16
+ /** 현재 시각(ms). */
17
+ now(): number;
18
+ /** 백오프 결정 통지(로깅용). delayMs=0 은 즉시 refork. */
19
+ onBackoff?(info: {
20
+ delayMs: number;
21
+ streak: number;
22
+ }): void;
23
+ }
24
+ export interface ReforkSupervisor {
25
+ /** 워커가 예기치 않게 죽었을 때 호출. forkedAt = 그 워커를 fork 한 시각. */
26
+ onCrash(forkedAt: number): void;
27
+ /** 대기 중인 백오프 refork 타이머를 모두 취소한다(종료 시). */
28
+ cancelAll(): void;
29
+ /** 현재 연속 부팅 크래시 streak(테스트·진단). */
30
+ readonly streak: number;
31
+ }
32
+ /**
33
+ * 재fork 감독자를 만든다. cluster 를 직접 만지지 않아 결정적으로 테스트할 수 있다
34
+ * (fork·schedule·now 를 주입). runClusterPrimary 가 실제 cluster 이벤트를 여기 연결한다.
35
+ */
36
+ export declare function createReforkSupervisor(policy: ReforkPolicy, deps: ReforkDeps): ReforkSupervisor;
37
+ /** 환경변수에서 재fork 정책을 읽는다(비수치·비양수는 기본값으로 폴백). */
38
+ export declare function reforkPolicyFromEnv(env?: NodeJS.ProcessEnv): ReforkPolicy;
@@ -0,0 +1,68 @@
1
+ // @gaonjs/cli · 클러스터 워커 재fork 백오프 (A5 · FINAL-AUDIT-2026-08-04 · 결정 261)
2
+ //
3
+ // `cluster.on('exit')` 가 무조건 즉시 refork 하면, 워커가 부팅 자체를 못 할 때
4
+ // (무효 설정 → 결정 252 로 워커가 부팅 직후 exit1) 무한 고속 crash-loop 폭주가
5
+ // 난다(실측: --workers 2 · 무효 PORT 로 5초에 refork 16회 · 평균 간격 236ms).
6
+ // 정책: 워커가 **짧게 살고 죽으면**(부팅 크래시) 지수 백오프로 refork 를 늦추고,
7
+ // **충분히 오래 산 뒤 죽으면**(정상 교체) 즉시 refork 한다 — 정상 워커 교체 지연 0.
8
+ // 백오프는 상한(maxMs)에서 멈춘다. 253/252 의 단일 프로세스 확정 종료가 클러스터
9
+ // 감독까지 미치지 못하던 공백을 메운다.
10
+ /** 연속 부팅 크래시 streak 에 대한 백오프 지연(ms). streak≤0 이면 0(즉시). */
11
+ export function reforkDelayMs(streak, p) {
12
+ if (streak <= 0)
13
+ return 0;
14
+ return Math.min(p.baseMs * 2 ** (streak - 1), p.maxMs);
15
+ }
16
+ /**
17
+ * 재fork 감독자를 만든다. cluster 를 직접 만지지 않아 결정적으로 테스트할 수 있다
18
+ * (fork·schedule·now 를 주입). runClusterPrimary 가 실제 cluster 이벤트를 여기 연결한다.
19
+ */
20
+ export function createReforkSupervisor(policy, deps) {
21
+ let streak = 0;
22
+ const cancels = new Set();
23
+ return {
24
+ onCrash(forkedAt) {
25
+ const uptime = deps.now() - forkedAt;
26
+ let delayMs;
27
+ if (uptime >= policy.minHealthyMs) {
28
+ streak = 0; // 정상 교체 — 페널티 없음
29
+ delayMs = 0;
30
+ }
31
+ else {
32
+ streak += 1;
33
+ delayMs = reforkDelayMs(streak, policy);
34
+ }
35
+ deps.onBackoff?.({ delayMs, streak });
36
+ if (delayMs <= 0) {
37
+ deps.fork();
38
+ return;
39
+ }
40
+ let cancel = () => { };
41
+ cancel = deps.schedule(() => {
42
+ cancels.delete(cancel);
43
+ deps.fork();
44
+ }, delayMs);
45
+ cancels.add(cancel);
46
+ },
47
+ cancelAll() {
48
+ for (const c of cancels)
49
+ c();
50
+ cancels.clear();
51
+ },
52
+ get streak() {
53
+ return streak;
54
+ },
55
+ };
56
+ }
57
+ /** 환경변수에서 재fork 정책을 읽는다(비수치·비양수는 기본값으로 폴백). */
58
+ export function reforkPolicyFromEnv(env = process.env) {
59
+ const num = (v, d) => {
60
+ const n = Number(v);
61
+ return Number.isFinite(n) && n > 0 ? n : d;
62
+ };
63
+ return {
64
+ minHealthyMs: num(env.GAON_WORKER_MIN_HEALTHY_MS, 10_000),
65
+ baseMs: num(env.GAON_WORKER_REFORK_BASE_MS, 1_000),
66
+ maxMs: num(env.GAON_WORKER_REFORK_MAX_MS, 30_000),
67
+ };
68
+ }
@@ -30,7 +30,7 @@ import { existsSync, readFileSync } from 'node:fs';
30
30
  import { join } from 'node:path';
31
31
  import { generateTablesDts } from '@gaonjs/data';
32
32
  import { generateRoutesDts } from '@gaonjs/web';
33
- import { runDoctorCommand } from '../doctor.js';
33
+ import { computeDoctorResult } from '../doctor.js';
34
34
  import { regenerateGaonOnce, resolveDevLayout } from '../dev.js';
35
35
  import { registerTsResolve } from '../tsResolve.js';
36
36
  import { listFrontendApps, verifyAppDist } from '../dev/build.js';
@@ -209,9 +209,11 @@ async function verifyBuildOutput(cwd) {
209
209
  /** doctor 는 이미 있는 명령을 재사용 — cwd 만 넘긴다. json 은 상위에서. */
210
210
  async function runDoctorStep(cwd) {
211
211
  try {
212
- // runDoctorCommand 는 stdout 에 리포트를 그대로 찍는다. 결과 객체로
213
- // 성공/실패를 판단한다.
214
- const result = await runDoctorCommand({ cwd, json: false });
212
+ // computeDoctorResult 는 stdout 에 아무것도 쓰지 않고 결과만 돌려준다.
213
+ // runDoctorCommand 는 사람용 리포트를 stdout 에 직접 찍어 `gaon check --json`
214
+ // 순수 JSON 출력을 오염시켰다(mcp parseJsonTail 우회가 방증). check 는
215
+ // 결과 객체만 필요하므로 무출력 경로를 쓴다(결정 269 · "--json = 파싱 안전").
216
+ const result = await computeDoctorResult({ cwd });
215
217
  const failed = !!result.fatal || result.errors.length > 0;
216
218
  return {
217
219
  step: 'doctor',
@@ -3,6 +3,14 @@ export interface TestCommandOptions {
3
3
  readonly cwd?: string;
4
4
  readonly json?: boolean;
5
5
  readonly scope?: TestScope;
6
+ /**
7
+ * 프로그램 호출(MCP run_tests)용 출력 싱크. 지정되면 자식(vitest·test 스크립트)
8
+ * stdout/stderr 를 상속(inherit) 대신 파이프로 이 콜백에 흘린다 — 결과를 도구
9
+ * 응답에 담기 위함. CLI 경로(미지정)는 종전대로 `stdio:'inherit'`. 이 옵션으로
10
+ * MCP 가 `gaon test` 하네스(테스트 DB 프로비저닝·GAON_STREAM_PREFIX 격리·사용자
11
+ * test 스크립트 우선)를 그대로 거치게 한다(결정 270 · vitest 직접 spawn 우회 제거).
12
+ */
13
+ readonly onOutput?: (chunk: string) => void;
6
14
  }
7
15
  /**
8
16
  * `gaon test` 진입점. args 는 사용자가 넘긴 잔여 인자(필터 문자열 등).
@@ -67,7 +67,21 @@ function scopeArgs(scope) {
67
67
  * scope='unit' 은 실 인프라가 필요 없으므로 건너뛴다. config 가 없거나 db 커넥션이
68
68
  * 없으면 조용히 스킵(순수 vitest 위임). DB 접속 실패는 수리 안내와 함께 실패한다.
69
69
  */
70
- async function provisionTestDatabases(cwd, json) {
70
+ async function provisionTestDatabases(cwd, json, onOutput) {
71
+ // capture(MCP) 모드는 stdio JSON-RPC 를 오염시키지 않도록 모든 출력을 싱크로
72
+ // 돌린다 — process.stdout 직접 쓰기 0(결정 270).
73
+ const writeOut = (s) => {
74
+ if (onOutput)
75
+ onOutput(s);
76
+ else
77
+ process.stdout.write(s);
78
+ };
79
+ const writeErr = (s) => {
80
+ if (onOutput)
81
+ onOutput(s);
82
+ else
83
+ process.stderr.write(s);
84
+ };
71
85
  registerTsResolve();
72
86
  let config;
73
87
  try {
@@ -93,7 +107,7 @@ async function provisionTestDatabases(cwd, json) {
93
107
  });
94
108
  if (res.exitCode !== 0) {
95
109
  if (!json)
96
- process.stderr.write(` ✗ 테스트 DB '${key}' 마이그레이션 실패\n${res.text}\n`);
110
+ writeErr(` ✗ 테스트 DB '${key}' 마이그레이션 실패\n${res.text}\n`);
97
111
  return false;
98
112
  }
99
113
  prepared.push(key);
@@ -104,18 +118,18 @@ async function provisionTestDatabases(cwd, json) {
104
118
  const hint = ` ✗ 테스트 DB 준비 실패: ${msg}\n` +
105
119
  ` → DB 가 떠 있는지 확인하세요(docker compose up -d db). 테스트는 실 인프라가 필요합니다(§9).\n`;
106
120
  if (json)
107
- process.stdout.write(JSON.stringify({ ok: false, kind: 'provision', error: msg }) + '\n');
121
+ writeOut(JSON.stringify({ ok: false, kind: 'provision', error: msg }) + '\n');
108
122
  else
109
- process.stderr.write(hint);
123
+ writeErr(hint);
110
124
  return false;
111
125
  }
112
126
  finally {
113
127
  await destroyAllConnections();
114
128
  }
115
129
  if (json)
116
- process.stdout.write(JSON.stringify({ kind: 'provisioned', dbs: prepared }) + '\n');
130
+ writeOut(JSON.stringify({ kind: 'provisioned', dbs: prepared }) + '\n');
117
131
  else
118
- process.stdout.write(` gaon test · 테스트 DB 준비 완료 (${prepared.join(', ')}) — <db>_test\n`);
132
+ writeOut(` gaon test · 테스트 DB 준비 완료 (${prepared.join(', ')}) — <db>_test\n`);
119
133
  return true;
120
134
  }
121
135
  /**
@@ -127,9 +141,23 @@ export async function runTestCommand(args = [], opts = {}) {
127
141
  const cwd = opts.cwd ?? process.cwd();
128
142
  const scope = opts.scope ?? 'all';
129
143
  const json = opts.json ?? false;
144
+ const capture = typeof opts.onOutput === 'function';
145
+ // capture(MCP) 모드: 모든 출력을 싱크로. 미지정: 종전대로 process.std*.
146
+ const writeOut = (s) => {
147
+ if (opts.onOutput)
148
+ opts.onOutput(s);
149
+ else
150
+ process.stdout.write(s);
151
+ };
152
+ const writeErr = (s) => {
153
+ if (opts.onOutput)
154
+ opts.onOutput(s);
155
+ else
156
+ process.stderr.write(s);
157
+ };
130
158
  // 결정 111: unit 이 아니면 실행 전 테스트 DB 를 준비한다(생성 + 마이그레이션).
131
159
  if (scope !== 'unit') {
132
- const ok = await provisionTestDatabases(cwd, json);
160
+ const ok = await provisionTestDatabases(cwd, json, opts.onOutput);
133
161
  if (!ok)
134
162
  return 1;
135
163
  }
@@ -151,10 +179,10 @@ export async function runTestCommand(args = [], opts = {}) {
151
179
  const msg = `vitest 가 설치돼 있지 않고 package.json 에 "test" 스크립트도 없습니다.\n` +
152
180
  `→ pnpm add -D vitest 후 다시 실행하거나, package.json 에 "test": "vitest run" 을 추가하세요.`;
153
181
  if (json) {
154
- process.stdout.write(JSON.stringify({ ok: false, error: msg }) + '\n');
182
+ writeOut(JSON.stringify({ ok: false, error: msg }) + '\n');
155
183
  }
156
184
  else {
157
- process.stderr.write(` ✗ ${msg}\n`);
185
+ writeErr(` ✗ ${msg}\n`);
158
186
  }
159
187
  return 127;
160
188
  }
@@ -168,20 +196,25 @@ export async function runTestCommand(args = [], opts = {}) {
168
196
  // 값을 세팅했으면 존중한다(고급 · 다중 테스트 컨텍스트 분리).
169
197
  const testEnv = { ...process.env, GAON_STREAM_PREFIX: process.env.GAON_STREAM_PREFIX ?? 'test' };
170
198
  if (json) {
171
- process.stdout.write(JSON.stringify({ kind: 'starting', cmd, args: spawnArgs, scope, streamPrefix: testEnv.GAON_STREAM_PREFIX }) + '\n');
199
+ writeOut(JSON.stringify({ kind: 'starting', cmd, args: spawnArgs, scope, streamPrefix: testEnv.GAON_STREAM_PREFIX }) + '\n');
172
200
  }
173
201
  else {
174
- process.stdout.write(` gaon test · ${cmd} ${spawnArgs.join(' ')} (scope=${scope})\n`);
202
+ writeOut(` gaon test · ${cmd} ${spawnArgs.join(' ')} (scope=${scope})\n`);
175
203
  }
176
204
  const exitCode = await new Promise((resolvePromise) => {
205
+ // capture(MCP) 모드는 자식 출력을 파이프로 모아 싱크로 흘린다(도구 응답에 담기).
206
+ // CLI 경로(미지정)는 vitest 컬러 출력·리포터를 그대로 보이게 stdio 를 상속한다.
177
207
  const child = spawn(cmd, spawnArgs, {
178
208
  cwd,
179
209
  env: testEnv,
180
- // vitest 컬러 출력·리포터를 그대로 보여주기 위해 stdio 를 상속한다.
181
- stdio: 'inherit',
210
+ stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
182
211
  });
212
+ if (capture) {
213
+ child.stdout?.on('data', (d) => opts.onOutput(d.toString('utf8')));
214
+ child.stderr?.on('data', (d) => opts.onOutput(d.toString('utf8')));
215
+ }
183
216
  child.on('error', (err) => {
184
- process.stderr.write(` ✗ gaon test spawn 실패: ${String(err)}\n`);
217
+ writeErr(` ✗ gaon test spawn 실패: ${String(err)}\n`);
185
218
  resolvePromise(127);
186
219
  });
187
220
  child.on('close', (code, signal) => {
@@ -195,7 +228,7 @@ export async function runTestCommand(args = [], opts = {}) {
195
228
  });
196
229
  });
197
230
  if (json) {
198
- process.stdout.write(JSON.stringify({ kind: 'exited', exitCode }) + '\n');
231
+ writeOut(JSON.stringify({ kind: 'exited', exitCode }) + '\n');
199
232
  }
200
233
  return exitCode;
201
234
  }
@@ -9,7 +9,14 @@ export interface JournalEntry {
9
9
  readonly down_sql: string | null;
10
10
  readonly applied_at: Date;
11
11
  }
12
- /** _gaon_migrations 존재 여부. information_schema 조회로 방언 무관. */
12
+ /**
13
+ * _gaon_migrations 존재 여부. information_schema 조회로 방언 무관.
14
+ *
15
+ * count 는 캐스트 없이 뽑는다 — 이전 `count(*)::text` 는 postgres 전용 문법이라
16
+ * mysql/mariadb 에서 문법 에러를 내며 `gaon db migrate/status/down/reset` 을 전멸시켰다.
17
+ * pg 는 count 를 bigint(문자열)로, mysql 은 number 로 돌려주므로 `Number()` 로 통일한다
18
+ * (양 방언 공통 · 결정 268).
19
+ */
13
20
  export declare function journalExists(db: Kysely<any>): Promise<boolean>;
14
21
  /**
15
22
  * _gaon_migrations 를 만든다(존재하면 no-op). id/kind/applied_at/db_key/
@@ -9,10 +9,17 @@
9
9
  // 마이그레이션은 커넥션별로 돈다(§4.5) — 원장도 커넥션 DB 마다 따로 존재한다.
10
10
  import { sql } from 'kysely';
11
11
  import { MIGRATIONS_TABLE } from '@gaonjs/data';
12
- /** _gaon_migrations 존재 여부. information_schema 조회로 방언 무관. */
12
+ /**
13
+ * _gaon_migrations 존재 여부. information_schema 조회로 방언 무관.
14
+ *
15
+ * count 는 캐스트 없이 뽑는다 — 이전 `count(*)::text` 는 postgres 전용 문법이라
16
+ * mysql/mariadb 에서 문법 에러를 내며 `gaon db migrate/status/down/reset` 을 전멸시켰다.
17
+ * pg 는 count 를 bigint(문자열)로, mysql 은 number 로 돌려주므로 `Number()` 로 통일한다
18
+ * (양 방언 공통 · 결정 268).
19
+ */
13
20
  export async function journalExists(db) {
14
21
  const rows = await sql `
15
- select count(*)::text as n
22
+ select count(*) as n
16
23
  from information_schema.tables
17
24
  where table_name = ${MIGRATIONS_TABLE}
18
25
  `.execute(db);
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type DevCommandOptions } from "./commands/dev.js";
2
2
  import { type ServeCommandOptions } from "./serve.js";
3
+ import { type DbSubcommand, type DbCommandOptions } from "./commands/db.js";
3
4
  import { type DoctorRule } from "./doctor.js";
4
5
  export { startDev, resolveDevLayout, regenerateGaonOnce, type DevDeps, type DevLayout, type DevApp, type DevEvent, type DevHandle, type RegenDeps, type RegenResult, } from "./dev.js";
5
6
  export { runDevCommand, type DevCommandOptions } from "./commands/dev.js";
@@ -72,6 +73,16 @@ export interface ParsedNewArgs {
72
73
  * npm 이 프로젝트 이름으로 오인되지 않도록(결정 167 · O-1 근본 fix).
73
74
  */
74
75
  export declare function parseNewArgs(rest: readonly string[]): ParsedNewArgs;
76
+ /**
77
+ * `gaon db <sub>` 의 인자를 파싱한다(argv = 전체 · argv[0]='db', argv[1]=sub).
78
+ *
79
+ * 방향 토큰 `down` 은 **위치 인자**다 — 값 플래그(`--db`/`--config <값>`)와 불리언
80
+ * 플래그를 건너뛰고 판별한다. 이전엔 `argv[2] === 'down'` 고정 위치로만 봐서
81
+ * `gaon db migrate --db X down` 처럼 플래그가 앞서면 down 을 놓치고 정방향 migrate 로
82
+ * 조용히 반전됐다(파괴 방향 오동작 · 결정 267). 위치로 훑어 순서와 무관하게
83
+ * 정확히 롤백으로 인식한다.
84
+ */
85
+ export declare function parseDbArgs(sub: DbSubcommand, argv: readonly string[]): DbCommandOptions;
75
86
  /**
76
87
  * `--port <값>` 플래그를 읽어 검증한다(serve·dev 공용). 플래그가 없으면
77
88
  * undefined(기본 포트 폴백). 플래그는 있는데 값이 없거나(마지막 토큰) 다른
package/dist/index.js CHANGED
@@ -197,6 +197,42 @@ export function parseNewArgs(rest) {
197
197
  }
198
198
  return { name, unknownPm: pmRaw === undefined || pmRaw === "" ? undefined : pmRaw };
199
199
  }
200
+ /**
201
+ * `gaon db <sub>` 의 인자를 파싱한다(argv = 전체 · argv[0]='db', argv[1]=sub).
202
+ *
203
+ * 방향 토큰 `down` 은 **위치 인자**다 — 값 플래그(`--db`/`--config <값>`)와 불리언
204
+ * 플래그를 건너뛰고 판별한다. 이전엔 `argv[2] === 'down'` 고정 위치로만 봐서
205
+ * `gaon db migrate --db X down` 처럼 플래그가 앞서면 down 을 놓치고 정방향 migrate 로
206
+ * 조용히 반전됐다(파괴 방향 오동작 · 결정 267). 위치로 훑어 순서와 무관하게
207
+ * 정확히 롤백으로 인식한다.
208
+ */
209
+ export function parseDbArgs(sub, argv) {
210
+ const dbIdx = argv.indexOf("--db");
211
+ const cfgIdx = argv.indexOf("--config");
212
+ const valueFlags = new Set(["--db", "--config"]);
213
+ const positionals = [];
214
+ for (let i = 2; i < argv.length; i++) {
215
+ const a = argv[i];
216
+ if (a === undefined)
217
+ continue;
218
+ if (valueFlags.has(a)) {
219
+ i++; // 플래그 값 스킵
220
+ continue;
221
+ }
222
+ if (a.startsWith("--"))
223
+ continue; // 불리언 플래그
224
+ positionals.push(a);
225
+ }
226
+ return {
227
+ json: argv.includes("--json"),
228
+ db: dbIdx >= 0 ? argv[dbIdx + 1] : undefined,
229
+ config: cfgIdx >= 0 ? argv[cfgIdx + 1] : undefined,
230
+ yes: argv.includes("--yes"),
231
+ dryRun: argv.includes("--dry-run"),
232
+ // `gaon db migrate down` — 위치 인자로 롤백 지시(순서 무관).
233
+ down: sub === "migrate" && positionals.includes("down"),
234
+ };
235
+ }
200
236
  /**
201
237
  * `--port <값>` 플래그를 읽어 검증한다(serve·dev 공용). 플래그가 없으면
202
238
  * undefined(기본 포트 폴백). 플래그는 있는데 값이 없거나(마지막 토큰) 다른
@@ -447,17 +483,7 @@ export function runCli(argv, opts = {}) {
447
483
  process.exitCode = 1;
448
484
  return;
449
485
  }
450
- const dbIdx = argv.indexOf("--db");
451
- const cfgIdx = argv.indexOf("--config");
452
- const dbOpts = {
453
- json: argv.includes("--json"),
454
- db: dbIdx >= 0 ? argv[dbIdx + 1] : undefined,
455
- config: cfgIdx >= 0 ? argv[cfgIdx + 1] : undefined,
456
- yes: argv.includes("--yes"),
457
- dryRun: argv.includes("--dry-run"),
458
- // `gaon db migrate down` — 위치 인자로 롤백 지시.
459
- down: sub === "migrate" && argv[2] === "down",
460
- };
486
+ const dbOpts = parseDbArgs(sub, argv);
461
487
  void runDbCommand(sub, dbOpts)
462
488
  .then((code) => {
463
489
  process.exitCode = code;
@@ -616,6 +642,18 @@ export function runCli(argv, opts = {}) {
616
642
  });
617
643
  return;
618
644
  }
645
+ // 결정 266: 미지 명령은 로드맵 배너로 조용히 성공(exit 0)하지 않는다 — `gaon serv`·
646
+ // `gaon migrate` 같은 오타가 성공 종료로 오판되면 CI·AI 가 실패를 못 본다.
647
+ // argv[0] 가 있으면서 어떤 명령·플래그와도 안 맞으면(플래그는 `-` 접두라
648
+ // help/version/json 폴백이 처리) 여기서 fail-loud(§7.5.3). 인자 없는 `gaon`
649
+ // (argv[0] 미존재)은 종전대로 로드맵 배너를 낸다.
650
+ if (argv[0] !== undefined && !argv[0].startsWith("-")) {
651
+ process.stderr.write(` ✗ 알 수 없는 명령: ${argv[0]}\n` +
652
+ ` → 지원 명령: dev · serve · check · gen · build · doctor · mcp · hub · work · jobs · db · g · new · console · test\n` +
653
+ ` → 전체 사용법: gaon --help\n`);
654
+ process.exitCode = 1;
655
+ return;
656
+ }
619
657
  if (argv.includes("--help") || argv.includes("-h")) {
620
658
  process.stdout.write(renderHelp(version) + "\n");
621
659
  return;
@@ -54,10 +54,14 @@ export declare function getSchemaTool(args: ToolArgs, cwd: string): Promise<Tool
54
54
  */
55
55
  export declare function runMigrationTool(args: ToolArgs, cwd: string): Promise<ToolResult>;
56
56
  /**
57
- * `run_tests` — 프로젝트 테스트를 실행한다(§9 · vitest spawn).
57
+ * `run_tests` — 프로젝트 테스트를 정본 `gaon test` 하네스로 실행한다(§9).
58
58
  *
59
- * `gaon test` 같은 경로를 쓰되, MCP 컨텍스트에서는 stdout 파이프로
60
- * 캡처해서 결과를 도구 응답에 담아 돌려준다(자식이 종료할 때까지 대기).
59
+ * 결정 270: 이전엔 여기서 `vitest run` **직접 spawn** `gaon test`(runTestCommand)의
60
+ * 가지 필수 거동을 전부 우회했다 — ① `<db>_test` 프로비저닝(결정 111) NATS
61
+ * `GAON_STREAM_PREFIX` 격리(결정 130) ③ 사용자 `test` 스크립트 우선(결정 170). 주석은
62
+ * "같은 경로" 라 주장했으나 거짓이었다. 이제 runTestCommand 에 위임하고, MCP(stdio
63
+ * JSON-RPC) 컨텍스트라 자식·프로비저닝 출력을 `onOutput` 싱크로 모아 도구 응답에
64
+ * 담는다(process.stdout 오염 0 — 트랜스포트 보호).
61
65
  *
62
66
  * 인자:
63
67
  * { scope?: 'unit'|'integration'|'all', filter?: string }
@@ -65,8 +69,8 @@ export declare function runMigrationTool(args: ToolArgs, cwd: string): Promise<T
65
69
  * filter — vitest 위치 인자(파일 패턴 substring)
66
70
  *
67
71
  * 반환:
68
- * data: { exitCode, scope, filter, output }
69
- * text: vitest 자체 출력(사람 UI 그대로)
72
+ * data: { ok, exitCode, scope, filter, output }
73
+ * text: gaon test 출력(프로비저닝 + vitest · 사람 UI 그대로)
70
74
  */
71
75
  export declare function runTestsTool(args: ToolArgs, cwd: string): Promise<ToolResult>;
72
76
  /**
package/dist/mcp/tools.js CHANGED
@@ -32,6 +32,7 @@ import { join, resolve as resolvePath } from 'node:path';
32
32
  import { pathToFileURL } from 'node:url';
33
33
  import { registerTsResolve } from '../tsResolve.js';
34
34
  import { runDbMigrate } from '../db/migrate.js';
35
+ import { runTestCommand } from '../commands/test.js';
35
36
  import { scanSchemaDir, columns } from '@gaonjs/data';
36
37
  // ── 유틸 ────────────────────────────────────────────────────
37
38
  /** 문자열 옵션 안전 추출. */
@@ -276,10 +277,14 @@ export async function runMigrationTool(args, cwd) {
276
277
  }
277
278
  // ── 도구 4: run_tests ────────────────────────────────────────
278
279
  /**
279
- * `run_tests` — 프로젝트 테스트를 실행한다(§9 · vitest spawn).
280
+ * `run_tests` — 프로젝트 테스트를 정본 `gaon test` 하네스로 실행한다(§9).
280
281
  *
281
- * `gaon test` 같은 경로를 쓰되, MCP 컨텍스트에서는 stdout 파이프로
282
- * 캡처해서 결과를 도구 응답에 담아 돌려준다(자식이 종료할 때까지 대기).
282
+ * 결정 270: 이전엔 여기서 `vitest run` **직접 spawn** `gaon test`(runTestCommand)의
283
+ * 가지 필수 거동을 전부 우회했다 — ① `<db>_test` 프로비저닝(결정 111) NATS
284
+ * `GAON_STREAM_PREFIX` 격리(결정 130) ③ 사용자 `test` 스크립트 우선(결정 170). 주석은
285
+ * "같은 경로" 라 주장했으나 거짓이었다. 이제 runTestCommand 에 위임하고, MCP(stdio
286
+ * JSON-RPC) 컨텍스트라 자식·프로비저닝 출력을 `onOutput` 싱크로 모아 도구 응답에
287
+ * 담는다(process.stdout 오염 0 — 트랜스포트 보호).
283
288
  *
284
289
  * 인자:
285
290
  * { scope?: 'unit'|'integration'|'all', filter?: string }
@@ -287,8 +292,8 @@ export async function runMigrationTool(args, cwd) {
287
292
  * filter — vitest 위치 인자(파일 패턴 substring)
288
293
  *
289
294
  * 반환:
290
- * data: { exitCode, scope, filter, output }
291
- * text: vitest 자체 출력(사람 UI 그대로)
295
+ * data: { ok, exitCode, scope, filter, output }
296
+ * text: gaon test 출력(프로비저닝 + vitest · 사람 UI 그대로)
292
297
  */
293
298
  export async function runTestsTool(args, cwd) {
294
299
  const scope = (stringOpt(args, 'scope') ?? 'all');
@@ -296,39 +301,17 @@ export async function runTestsTool(args, cwd) {
296
301
  if (!['unit', 'integration', 'all'].includes(scope)) {
297
302
  return errorResult(`scope 는 'unit' | 'integration' | 'all' 중 하나여야 합니다. 입력값: '${scope}'`);
298
303
  }
299
- // scopeArgs test.ts 와 동일 규칙을 재구성한다 — 순환 import 를 피하려고
300
- // 여기서 직접 계산한다(관례는 test.ts scopeArgs 정합).
301
- const scopeExtras = scope === 'unit'
302
- ? ['--exclude', '**/*.integration.test.ts']
303
- : scope === 'integration'
304
- ? ['integration.test']
305
- : [];
306
- const passthrough = [...scopeExtras, ...(filter ? [filter] : [])];
307
- // 실행 경로: 로컬 vitest 바이너리 우선. 없으면 에러 안내.
308
- const vitestBin = join(resolvePath(cwd), 'node_modules', '.bin', 'vitest');
309
- if (!existsSync(vitestBin)) {
310
- return errorResult(`vitest 가 설치돼 있지 않습니다: ${vitestBin}\n` +
311
- `→ 프로젝트에서 \`pnpm add -D vitest\` 후 다시 시도하세요.`);
312
- }
313
- const child = spawn(vitestBin, ['run', ...passthrough], {
304
+ let output = '';
305
+ const exitCode = await runTestCommand(filter ? [filter] : [], {
314
306
  cwd,
315
- env: process.env,
316
- stdio: ['ignore', 'pipe', 'pipe'],
317
- });
318
- let stdout = '';
319
- let stderr = '';
320
- child.stdout.on('data', (d) => {
321
- stdout += d.toString('utf8');
322
- });
323
- child.stderr.on('data', (d) => {
324
- stderr += d.toString('utf8');
325
- });
326
- const exitCode = await new Promise((resolveExit) => {
327
- child.on('close', (code) => resolveExit(code ?? 1));
307
+ scope,
308
+ json: false,
309
+ onOutput: (chunk) => {
310
+ output += chunk;
311
+ },
328
312
  });
329
- const output = stdout + (stderr ? `\n[stderr]\n${stderr}` : '');
330
313
  return {
331
- text: output || `(vitest 출력 없음 · exit ${exitCode})`,
314
+ text: output || `(gaon test 출력 없음 · exit ${exitCode})`,
332
315
  data: {
333
316
  ok: exitCode === 0,
334
317
  exitCode,
package/dist/serve.js CHANGED
@@ -20,6 +20,7 @@ import { loadGaonConfig, wireGaon, findConfigPath } from '@gaonjs/config';
20
20
  import { registerTsResolve } from './tsResolve.js';
21
21
  import { parsePort } from './port.js';
22
22
  import { computeHealth, DEV_HEALTH_PATH } from './dev/health.js';
23
+ import { createReforkSupervisor, reforkPolicyFromEnv } from './cluster.js';
23
24
  /**
24
25
  * 워커 수를 결정한다: 옵션 > env `WEB_CONCURRENCY` > 1. `'auto'` = 코어 수
25
26
  * (availableParallelism). 0·음수·비수치는 1 로 떨어진다(안전 기본). 결정 84.
@@ -48,6 +49,8 @@ function humanEvent(e) {
48
49
  return ` gaon serve · 클러스터 — 워커 ${e.workers}개 fork (node:cluster)`;
49
50
  case 'worker-exit':
50
51
  return ` ⚠ 워커 종료(pid ${e.pid ?? '?'} · code ${e.code}${e.signal ? ` · ${e.signal}` : ''})${e.restarted ? ' — 교체 fork' : ''}`;
52
+ case 'worker-backoff':
53
+ return ` ⏳ 워커 부팅 크래시 반복(streak ${e.streak}) — ${Math.round(e.delayMs / 100) / 10}s 후 교체 fork (crash-loop 백오프)`;
51
54
  case 'stopping':
52
55
  return ' gaon serve · 종료 중 (graceful) ...';
53
56
  case 'stopped':
@@ -71,14 +74,36 @@ async function runClusterPrimary(workerCount, opts) {
71
74
  };
72
75
  emit({ kind: 'cluster', workers: workerCount });
73
76
  let shuttingDown = false;
77
+ // A5(결정 261): 재fork 백오프. 워커별 fork 시각을 추적해, 부팅 직후 죽는(짧은 수명) 크래시는
78
+ // 지수 백오프로 늦추고 정상 워커 교체는 즉시 처리한다. 감독 로직은 createReforkSupervisor 로
79
+ // 분리(cluster 미의존 · 결정적 테스트 가능). fork 는 이 감독자만 수행한다.
80
+ const forkedAt = new Map();
81
+ const doFork = () => {
82
+ const w = cluster.fork();
83
+ forkedAt.set(w.id, Date.now());
84
+ };
85
+ const supervisor = createReforkSupervisor(reforkPolicyFromEnv(), {
86
+ fork: doFork,
87
+ schedule: (fn, delayMs) => {
88
+ const timer = setTimeout(fn, delayMs);
89
+ return () => clearTimeout(timer);
90
+ },
91
+ now: () => Date.now(),
92
+ onBackoff: ({ delayMs, streak }) => {
93
+ if (delayMs > 0)
94
+ emit({ kind: 'worker-backoff', delayMs, streak });
95
+ },
96
+ });
74
97
  for (let i = 0; i < workerCount; i++)
75
- cluster.fork();
98
+ doFork();
76
99
  cluster.on('exit', (worker, code, signal) => {
77
100
  if (shuttingDown)
78
101
  return;
79
- // 예기치 않은 종료 교체 fork 로 워커 수를 유지한다.
102
+ const at = forkedAt.get(worker.id) ?? Date.now();
103
+ forkedAt.delete(worker.id);
104
+ // 예기치 않은 종료 → 교체 fork(백오프 정책에 따라 즉시 또는 지연)로 워커 수를 유지한다.
80
105
  emit({ kind: 'worker-exit', pid: worker.process.pid, code, signal, restarted: true });
81
- cluster.fork();
106
+ supervisor.onCrash(at);
82
107
  });
83
108
  await new Promise((resolvePromise) => {
84
109
  const stop = () => {
@@ -88,6 +113,8 @@ async function runClusterPrimary(workerCount, opts) {
88
113
  signals.off('SIGINT', stop);
89
114
  signals.off('SIGTERM', stop);
90
115
  emit({ kind: 'stopping' });
116
+ // 대기 중인 백오프 refork 타이머를 취소한다 — 종료 중 새 워커가 뜨지 않게(결정 261).
117
+ supervisor.cancelAll();
91
118
  for (const w of Object.values(cluster.workers ?? {}))
92
119
  w?.kill('SIGTERM');
93
120
  const killTimer = setTimeout(() => {
@@ -86,6 +86,10 @@ await SendWelcomeMail.at(someDate, user.id) // 특정 시각 실행
86
86
  `curve`(백오프 곡선 ms) · `jitter` · `concurrency`.
87
87
  - **실패** — 재시도를 소진하면 DLQ 로 간다. `gaon jobs list --failed` ·
88
88
  `gaon jobs retry <id>` 로 조회·재적재한다.
89
+ - **워커 복원력(결정 258)** — 재시도 재적재나 DLQ 이관을 하는 도중 NATS 가
90
+ 순단해 발행 자체가 실패해도, 워커의 큐 소비 루프는 **멈추지 않는다**. 그 잡은
91
+ ack/DLQ 하지 않고 되돌려(재전달 백스톱) 유실을 막고, 실패는 로그로 남긴다 —
92
+ 한 잡의 인프라 순단이 큐 전체를 조용히 세우지 않는다.
89
93
  - **실행 컨텍스트(결정 129)** — `gaon work` 는 `gaon serve` 와 **같은 도메인
90
94
  배선**(DB 커넥션 · 메일)을 태운다. 그래서 잡 핸들러에서 모델 조회(`User.find`),
91
95
  `Mail.deliver`, `broadcast`, 다른 잡 `.later()` 를 웹 컨트롤러에서와 똑같이 쓴다
@@ -413,4 +417,5 @@ async create() {
413
417
  | 결정 230 | 스케줄러/앱 시간대 = `gaon.config.ts` 의 `timezone` → `process.env.TZ`(config 가 런치 `TZ` 보다 우선 · 앱 전역 단일 타임존 · §5) |
414
418
  | 결정 233 | 크론 리더 페일오버 중복 발행 dedupe (결정론적 dedupe 키 + JetStream 중복 윈도우로 틱당 1회 발행 수렴 · §5) |
415
419
  | 결정 211 | `gaon dev` all-in-one — serve·work·hub 자동 기동 · dev 워커 동시성 4(`GAON_WORKER_CONCURRENCY`) · `--no-work`/`--no-hub` (§6) |
420
+ | 결정 258 | 워커 소비 루프 복원력(§1) — 재시도/DLQ 발행이 NATS 순단으로 실패해도 큐 소비가 멈추지 않음(nak 재전달 백스톱 · 잡 유실 방지 · 실패 로그) |
416
421
  | §7 | 비동기 배터리 원문 (백오프 기본값 = M7 벤치마크 확정) |
@@ -216,7 +216,7 @@ export const posts = table('posts', {
216
216
  | `having` | `('count', op, val)` · `('sum'\|'avg'\|'min'\|'max', col, op, val)` | `GroupChain` | groupBy 뒤 **집계값** 필터 (그룹 키 필터는 `where`). `.having('count', '>', 2)` · `.having('sum', 'price', '>=', 1000)` |
217
217
  | `distinct` | `()` · `(col \| col[])` | `Chain` · `SelectChain` | 인자 없으면 `SELECT DISTINCT` 전체 행(집계·벌크 쓰기 이어짐), 컬럼을 주면 그 컬럼만 뽑는 `SelectChain`. `distinct().count()` 는 `count(distinct id)` |
218
218
  | `withCount` | `(...rels)` | `IncludedChain` | 관계별 개수를 **상관 서브쿼리**로 얹는다 — `withCount('comments')` → 각 Rec 에 `commentsCount: bigint`. 조인이 아니라 행이 안 늘어 `limit` 과 함께 써도 개수가 정확. **hasMany·hasOne·belongsToMany 만**(belongsTo 는 항상 0/1 이라 throw). `include` 와 같은 체인에 실린다(`include('author').withCount('comments')`) |
219
- | `join` | `(table, 'table.col', 'self.col')` | `JoinChain` | INNER JOIN — **필터·정렬 수단**이고 반환은 **자기 테이블의 Rec**(조인 테이블 컬럼은 안 실림 → 뽑아야 하면 §5 `Post.query()`). 조인 테이블 조건은 한정 이름(`where('users.name', '=', ...)`), 1:N 부풀림은 `distinct()` 로 접는다. `join`/`leftJoin`·`where`·`orderBy`·`distinct`·`select`·`pluck`·`count`·`exists`·`first`·`all` 이어짐 |
219
+ | `join` | `(table, 'table.col', 'self.col')` | `JoinChain` | INNER JOIN — **필터·정렬 수단**이고 반환은 **자기 테이블의 Rec**(조인 테이블 컬럼은 안 실림 → 뽑아야 하면 §5 `Post.query()`). **자기 테이블 컬럼은 한정 없이 그대로** 쓴다 — `t.timestamps()`·`t.id()` 로 양 테이블이 `createdAt`·`id` 를 공유해도 조인 시 자기 테이블로 자동 한정돼 `where('createdAt', ..)` 가 안전하다(ambiguous column 방지). **조인 테이블** 조건만 한정 이름(`where('users.name', '=', ...)`)으로 쓴다. 1:N 부풀림은 `distinct()` 로 접는다. `join`/`leftJoin`·`where`·`orderBy`·`distinct`·`select`·`pluck`·`count`·`exists`·`first`·`all` 이어짐 |
220
220
  | `leftJoin` | `(table, 'table.col', 'self.col')` | `JoinChain` | LEFT OUTER JOIN — 짝 없는 자기 행도 남는다. "짝 없는 것만" = `.where('posts.id', 'is null')` |
221
221
 
222
222
  > 조인 노출은 정본 결정 28 게이트 (f)(원안 = 기각·`Post.query()` 로만)를
@@ -80,6 +80,12 @@ export default channel({
80
80
 
81
81
  멤버 식별자는 로그인 사용자면 `user:<id>`, 익명이면 `conn:<uuid>` 다.
82
82
 
83
+ **핸들러가 throw 하면 그 연결만 닫힌다(결정 257).** `onMessage`/`onLeave` 안에서
84
+ 예외(코드 결함·DB 순단)가 나도 **서버는 죽지 않는다** — HTTP 액션이 500 으로
85
+ 마감되는 것과 대칭으로, 그 연결에 에러 프레임을 보내고 닫는다(다른 연결·서버는
86
+ 그대로 산다). 즉 `onMessage` 예외는 전역 장애가 아니라 연결 단위 장애다. 재시도가
87
+ 필요한 로직은 핸들러 안에서 try/catch 로 감싸 직접 통제한다.
88
+
83
89
  ### 2.5 서버 개시 broadcast (결정 126)
84
90
 
85
91
  `ctx.broadcast` 는 클라이언트 연결 훅(`onJoin`/`onMessage`/`onLeave`) **안에서만** 쓸 수 있다 —
@@ -233,6 +239,15 @@ export function useRoom(roomId: number) {
233
239
  겹친다 — 인스턴스마다 다른 `GAON_HUB_PORT` 를 주거나 호스트를 분리한다.
234
240
  `gaon dev` 는 내장 허브를 기본 포트로 띄우므로, 같은 호스트에서 별도
235
241
  `gaon hub` 를 돌릴 땐 포트를 바꾼다.
242
+ - **디스커버리 endpoint 는 소유 리더만 지운다(결정 259).** standby 인스턴스의
243
+ 정상 종료나 리더 교대가 **활성 리더의** 도달 주소(KV `gaon_hub/endpoint`)를
244
+ 지우지 않는다 — 그렇지 않으면 재접속하는 웹서버가 허브를 못 찾고 무한
245
+ 백오프에 빠진다. 삭제는 저장된 주소가 자기 것일 때 + revision CAS 로만
246
+ 일어나, 롤링 재시작 중에도 디스커버리가 살아 있다.
247
+ - **리스 TTL 은 역할별로 독립이다(결정 260).** 허브(`GAON_HUB_TTL_MS`)와
248
+ 스케줄러(고정 5s)는 서로 다른 KV 버킷(`gaon_lease_<역할>`)을 써 각자 TTL 을
249
+ 가진다 — 한 버킷을 공유하던 시절의 TTL 플래핑(부팅 순서 의존)이 없다.
250
+ 허브 TTL 을 튜닝해도 스케줄러 failover 타이밍에 영향을 주지 않는다.
236
251
 
237
252
  ## 정본 예시
238
253
 
@@ -311,6 +326,9 @@ export default channel({
311
326
  | 결정 207 | 허브 fail-fast(§5) — 리스는 얻고 TCP 포트 bind 실패 시 좀비 리더 대신 리스 사임 + `process.exit(1)`(F-13 fix · `onFatal` 훅으로 주입 가능) |
312
327
  | 결정 225 | 프레즌스 연결 축 refcount(§3) — 같은 멤버의 멀티탭·멀티서버 연결을 refcount 해 마지막 연결에서만 이탈 · cleanupServer 는 그 서버 연결만 회수(타서버 불간섭) |
313
328
  | 결정 227 | 특정/다중 유저 타겟 발송(§2.6) — `sendToUsers(name, userIds, data)` · `broadcast` 와 대칭 · 대상 연결에만 전달(멀티서버·멀티탭) · 도달 유저 수 반환(오프라인=0) · 수정 1 연결 추적 위에 얹음 |
329
+ | 결정 257 | 채널 `onMessage`/`onLeave` throw 는 그 연결만 마감(§2.4) — 사용자 핸들러 예외가 unhandledRejection 으로 serve 를 죽이지 않는다(HTTP 500 대칭 · 에러 프레임 + 연결 종료) |
330
+ | 결정 259 | 허브 디스커버리 endpoint 는 소유 리더만 삭제(§5) — addr 일치 + revision CAS · standby 종료·리더 교대가 활성 endpoint 를 지우지 않음(재접속 서버 허브 발견 보존) |
331
+ | 결정 260 | 리스 TTL 역할별 독립(§5) — 허브·스케줄러가 `gaon_lease_<역할>` 별도 버킷 · 공유 버킷 MaxAge 플래핑 제거 |
314
332
 
315
333
  ## `@gaonjs/seal` 켠 앱의 채널
316
334
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.41.6",
3
+ "version": "0.42.0",
4
4
  "description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,13 +27,13 @@
27
27
  "@modelcontextprotocol/sdk": "^1.29.0",
28
28
  "typescript": "^5.9.0",
29
29
  "vite": "^7.0.0",
30
- "@gaonjs/async": "0.15.1",
31
- "@gaonjs/config": "0.17.5",
30
+ "@gaonjs/async": "0.15.2",
31
+ "@gaonjs/config": "0.17.7",
32
32
  "@gaonjs/core": "0.2.4",
33
+ "@gaonjs/data": "0.17.2",
33
34
  "@gaonjs/i18n": "0.2.3",
34
- "@gaonjs/data": "0.17.1",
35
35
  "@gaonjs/mail": "0.3.1",
36
- "@gaonjs/web": "0.20.0"
36
+ "@gaonjs/web": "0.20.2"
37
37
  },
38
38
  "scripts": {
39
39
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""