@cartanova/qgrid-cli 2.0.3 → 2.1.1

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.
@@ -72,6 +72,14 @@ async function deleteOAuthState(state: string): Promise<void> {
72
72
  await cache.delete({ key: `${OAUTH_STATE_PREFIX}${state}` });
73
73
  }
74
74
 
75
+ function getOAuthRedirectUri(): string {
76
+ const publicBaseUrl = process.env.QGRID_PUBLIC_BASE_URL?.replace(/\/+$/, "");
77
+ if (publicBaseUrl) return `${publicBaseUrl}/callback`;
78
+
79
+ const serverPort = process.env.PORT ?? "44900";
80
+ return `http://localhost:${serverPort}/callback`;
81
+ }
82
+
75
83
  const logger = getLogger(["qgrid"]);
76
84
  const oauthLogger = getLogger(["qgrid", "oauth"]);
77
85
 
@@ -101,37 +109,50 @@ class QgridFrameClass extends BaseFrameClass {
101
109
  const result = await QgridDispatcher.query(args, args.timeout);
102
110
 
103
111
  if (effectiveLogMode === "auto") {
104
- const toolCallCount = result.content.filter((item) => item.type === "tool-call").length;
105
- const responseText = result.content
106
- .filter((item) => item.type === "text")
107
- .map((item) => item.text)
108
- .join("\n");
109
-
110
- RequestLogModel.save([
111
- {
112
- token_name: result.tokenName,
113
- project_name: args.projectName?.length ? args.projectName : null,
114
- model_name: result.model ?? null,
115
- user_prompt: args.prompt,
116
- system_prompt: args.system ?? null,
117
- response: responseText,
118
- input_tokens: result.usage.input_tokens,
119
- output_tokens: result.usage.output_tokens,
120
- cache_read_tokens: result.usage.cache_read_input_tokens,
121
- cache_creation_tokens: result.usage.cache_creation_input_tokens,
122
- duration_ms: result.durationMs,
123
- cost_usd: result.costUsd !== null ? Math.round(result.costUsd * MICRO_USD) : null,
124
- effort: args.effort ?? null,
125
- history: args.history ? JSON.parse(args.history) : null,
126
- status: "succeeded",
127
- tool_call_count: toolCallCount,
128
- },
129
- ]).catch((e) => logger.error(`requestLog save failed: ${(e as Error).message}`));
112
+ this.saveAutoRequestLog(args, result);
130
113
  }
131
114
 
132
115
  return result;
133
116
  }
134
117
 
118
+ // logMode "auto" 단일 요청 로깅: run lifecycle(step 분해) 없이 request_log 1건만 남긴다.
119
+ // query와 queryStream 양쪽에서 사용.
120
+ private saveAutoRequestLog(args: QueryInput, result: QueryOutput): void {
121
+ const toolCallCount = result.content.filter((item) => item.type === "tool-call").length;
122
+ const responseText = result.content
123
+ .filter((item) => item.type === "text")
124
+ .map((item) => item.text)
125
+ .join("\n");
126
+
127
+ RequestLogModel.save([
128
+ {
129
+ token_name: result.tokenName,
130
+ project_name: args.projectName?.length ? args.projectName : null,
131
+ model_name: result.model ?? null,
132
+ user_prompt: args.prompt,
133
+ system_prompt: args.system ?? null,
134
+ response: responseText,
135
+ input_tokens: result.usage.input_tokens,
136
+ output_tokens: result.usage.output_tokens,
137
+ cache_read_tokens: result.usage.cache_read_input_tokens,
138
+ cache_creation_tokens: result.usage.cache_creation_input_tokens,
139
+ duration_ms: result.durationMs,
140
+ cost_usd: result.costUsd !== null ? Math.round(result.costUsd * MICRO_USD) : null,
141
+ effort: args.effort ?? null,
142
+ // malformed history가 와도 성공한 턴(특히 stream sse.end())을 깨지 않도록 방어.
143
+ history: ((): { type: string }[] | null => {
144
+ try {
145
+ return args.history ? JSON.parse(args.history) : null;
146
+ } catch {
147
+ return null;
148
+ }
149
+ })(),
150
+ status: "succeeded",
151
+ tool_call_count: toolCallCount,
152
+ },
153
+ ]).catch((e) => logger.error(`requestLog save failed: ${(e as Error).message}`));
154
+ }
155
+
135
156
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
136
157
  async prepareStream(args: QueryInput): Promise<{ streamId: string }> {
137
158
  const streamId = crypto.randomUUID();
@@ -214,6 +235,9 @@ class QgridFrameClass extends BaseFrameClass {
214
235
  } catch (e) {
215
236
  logger.error(`stream afterQuery failed: ${(e as Error).message}`);
216
237
  }
238
+ } else if (effectiveLogMode === "auto") {
239
+ // run lifecycle을 안 타는 단일 stream 요청도 request_log 1건은 남긴다 (query와 대칭).
240
+ this.saveAutoRequestLog(args, streamResult);
217
241
  }
218
242
  if (!sse.closed) sse.publish("done", { ...streamResult, runContext });
219
243
  } else if (streamError) {
@@ -360,8 +384,7 @@ class QgridFrameClass extends BaseFrameClass {
360
384
  async oauthStart(name: string): Promise<OAuthStartResult> {
361
385
  const { codeVerifier, codeChallenge, state } = generatePKCE();
362
386
 
363
- const serverPort = process.env.PORT ?? "44900";
364
- const redirectUri = `http://localhost:${serverPort}/callback`;
387
+ const redirectUri = getOAuthRedirectUri();
365
388
  const authUrl = buildAuthUrl(codeChallenge, state, redirectUri);
366
389
 
367
390
  await setOAuthState(state, { codeVerifier, name, redirectUri });
@@ -175,9 +175,7 @@ class RequestLogModelClass extends BaseModelClass<
175
175
  cache_creation_tokens: 0,
176
176
  duration_ms: 0,
177
177
  tool_call_count: 0,
178
- ...(params.history !== undefined
179
- ? { history: params.history as { type: string }[] }
180
- : {}),
178
+ ...(params.history !== undefined ? { history: params.history as { type: string }[] } : {}),
181
179
  });
182
180
  return wdb.transaction(async (trx) => {
183
181
  const ids = await trx.ubUpsert("request_logs");
@@ -43,6 +43,7 @@ const ANTHROPIC_COSTS: Record<string, ModelCosts> = {
43
43
  "claude-opus-4-5": { inputTokens: 5, outputTokens: 25, cachedInputTokens: 0.5 },
44
44
  "claude-opus-4-6": { inputTokens: 5, outputTokens: 25, cachedInputTokens: 0.5 },
45
45
  "claude-opus-4-7": { inputTokens: 5, outputTokens: 25, cachedInputTokens: 0.5 },
46
+ "claude-opus-4-8": { inputTokens: 5, outputTokens: 25, cachedInputTokens: 0.5 },
46
47
  };
47
48
 
48
49
  const DEFAULT_COSTS: ModelCosts = { inputTokens: 3, outputTokens: 15, cachedInputTokens: 0.3 };
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { CodexAppServerWorker, type StreamCallbacks } from "./codex-worker";
4
+
5
+ function createWorkerWithFakeRpc() {
6
+ const worker = new CodexAppServerWorker({
7
+ tokenId: 1,
8
+ tokenName: "token",
9
+ accessToken: "access",
10
+ accountId: "account",
11
+ });
12
+ const handlers = new Map<string, (params: never) => void>();
13
+ const rpc = {
14
+ onNotification(method: string, handler: (params: never) => void) {
15
+ handlers.set(method, handler);
16
+ },
17
+ };
18
+ (
19
+ worker as unknown as {
20
+ rpc: typeof rpc;
21
+ }
22
+ ).rpc = rpc;
23
+ return { worker, handlers };
24
+ }
25
+
26
+ describe("CodexAppServerWorker active turn cleanup", () => {
27
+ it("rejects a non-streaming turn when the worker process exits", async () => {
28
+ const { worker } = createWorkerWithFakeRpc();
29
+ const promise = (
30
+ worker as unknown as {
31
+ consumeTurnNotifications(threadId: string, model: string): Promise<unknown>;
32
+ }
33
+ ).consumeTurnNotifications("thread-1", "gpt-test");
34
+
35
+ (
36
+ worker as unknown as {
37
+ failActiveTurn(error: Error): void;
38
+ }
39
+ ).failActiveTurn(new Error("codex worker exited while turn was running"));
40
+
41
+ await expect(promise).rejects.toThrow("codex worker exited while turn was running");
42
+ });
43
+
44
+ it("notifies stream callbacks when the worker process exits", async () => {
45
+ const { worker } = createWorkerWithFakeRpc();
46
+ const callbacks: StreamCallbacks = {
47
+ onDelta: vi.fn(),
48
+ onComplete: vi.fn(),
49
+ onError: vi.fn(),
50
+ };
51
+ const promise = (
52
+ worker as unknown as {
53
+ consumeStreamNotifications(
54
+ threadId: string,
55
+ model: string,
56
+ cb: StreamCallbacks,
57
+ ): Promise<void>;
58
+ }
59
+ ).consumeStreamNotifications("thread-1", "gpt-test", callbacks);
60
+
61
+ (
62
+ worker as unknown as {
63
+ failActiveTurn(error: Error): void;
64
+ }
65
+ ).failActiveTurn(new Error("codex worker exited while turn was running"));
66
+
67
+ await expect(promise).rejects.toThrow("codex worker exited while turn was running");
68
+ expect(callbacks.onError).toHaveBeenCalledWith(
69
+ expect.objectContaining({ message: "codex worker exited while turn was running" }),
70
+ );
71
+ });
72
+ });
@@ -1,5 +1,5 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
- import { mkdirSync, readFileSync, rmSync } from "node:fs";
2
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
 
4
4
  import { getLogger } from "@logtape/logtape";
5
5
 
@@ -54,6 +54,9 @@ export interface StreamCallbacks {
54
54
  onTurnId?: (turnId: string) => void;
55
55
  }
56
56
 
57
+ type RefreshableWorkerCredentials = Pick<WorkerConfig, "accessToken" | "accountId" | "planType">;
58
+ type ActiveTurnAbort = (error: Error) => void;
59
+
57
60
  // thread/start 공통 옵션
58
61
  const THREAD_DEFAULTS = {
59
62
  sandbox: "read-only",
@@ -68,6 +71,31 @@ const BASE_INSTRUCTIONS = {
68
71
  withSchema: "You are a helpful assistant. Respond using the provided output schema.",
69
72
  } as const;
70
73
 
74
+ // codex가 매 요청에 자동 주입하는 내장 tool(shell/web_search/spawn_agent 등 14개)과
75
+ // instruction 블록(permissions/environment_context/skills, ~10KB)을 비활성화
76
+ // $CODEX_HOME/config.toml 로 써넣으면 codex 부팅 시 scan
77
+ // 통제불가: update_plan/request_user_input
78
+ const CODEX_CONFIG_TOML = `web_search = "disabled"
79
+ include_permissions_instructions = false
80
+ include_apps_instructions = false
81
+ include_environment_context = false
82
+
83
+ [features]
84
+ shell_tool = false
85
+ tool_search = false
86
+ tool_suggest = false
87
+ multi_agent = false
88
+ image_generation = false
89
+ apps = false
90
+ plugins = false
91
+
92
+ [tools]
93
+ view_image = false
94
+
95
+ [skills]
96
+ include_instructions = false
97
+ `;
98
+
71
99
  // ── Worker ──────────────────────────────────────────────────────────
72
100
 
73
101
  export class CodexAppServerWorker {
@@ -78,8 +106,11 @@ export class CodexAppServerWorker {
78
106
  private ready = false;
79
107
  private destroyed = false;
80
108
  private busy = false;
109
+ private activeTurnAbort?: ActiveTurnAbort;
81
110
  active = true;
82
111
  onReady?: () => void;
112
+ // restart 시 rpc 객체가 새로 생성되므로, 핸들러를 보관했다가 매 spawn마다 재바인딩한다.
113
+ serverRequestHandler?: (method: string, params: unknown) => Promise<unknown>;
83
114
 
84
115
  constructor(private config: WorkerConfig) {
85
116
  const suffix = config.workerIndex !== undefined ? `-${config.workerIndex}` : "";
@@ -91,15 +122,26 @@ export class CodexAppServerWorker {
91
122
  private async spawnAndInit(): Promise<void> {
92
123
  const cwd = `${this.codexHome}/cwd`;
93
124
  mkdirSync(cwd, { recursive: true });
125
+ // codex 내장 tool/web_search/instruction 블록 비활성화
126
+ writeFileSync(`${this.codexHome}/config.toml`, CODEX_CONFIG_TOML);
94
127
 
95
128
  this.proc = spawn("codex", ["app-server", "--listen", "stdio://"], {
96
129
  stdio: ["pipe", "pipe", "pipe"],
97
130
  cwd,
98
- env: { PATH: process.env.PATH, TMPDIR: process.env.TMPDIR, CODEX_HOME: this.codexHome },
131
+ env: {
132
+ PATH: process.env.PATH,
133
+ TMPDIR: process.env.TMPDIR,
134
+ CODEX_HOME: this.codexHome,
135
+ // environment 비활성화 → has_environment=false → shell/apply_patch/view_image 제거
136
+ CODEX_EXEC_SERVER_URL: "none",
137
+ },
99
138
  });
100
139
 
101
140
  this.proc.on("exit", (code) => {
102
141
  logger.info(`worker ${this.config.tokenName} exited (code=${code})`);
142
+ this.failActiveTurn(
143
+ new Error(`codex worker exited while turn was running (code=${code ?? "unknown"})`),
144
+ );
103
145
  this.ready = false;
104
146
  this.rpc = null;
105
147
  this.proc = null;
@@ -107,6 +149,8 @@ export class CodexAppServerWorker {
107
149
  });
108
150
 
109
151
  this.rpc = new CodexRpcClient(this.proc);
152
+ // restart 후에도 refresh server-request 핸들러를 유지 (없으면 codex 토큰 회전 실패 → session expired)
153
+ this.bindServerRequestHandler();
110
154
 
111
155
  await this.rpc.request("initialize", {
112
156
  clientInfo: { name: "qgrid", version: "1.0.0" },
@@ -179,6 +223,7 @@ export class CodexAppServerWorker {
179
223
  async kill(): Promise<void> {
180
224
  this.destroyed = true;
181
225
  this.ready = false;
226
+ this.failActiveTurn(new Error("codex worker stopped"));
182
227
  this.rpc?.destroy();
183
228
 
184
229
  if (this.proc) {
@@ -213,6 +258,24 @@ export class CodexAppServerWorker {
213
258
  return this.config.tokenName;
214
259
  }
215
260
 
261
+ canReuseForToken(_tokenName: string, credentials: RefreshableWorkerCredentials): boolean {
262
+ return (
263
+ this.config.accountId === credentials.accountId &&
264
+ (this.config.planType ?? undefined) === (credentials.planType ?? undefined)
265
+ );
266
+ }
267
+
268
+ updateTokenState(tokenName: string, credentials: RefreshableWorkerCredentials): void {
269
+ this.config.tokenName = tokenName;
270
+ this.config.accessToken = credentials.accessToken;
271
+ this.config.accountId = credentials.accountId;
272
+ if (credentials.planType) {
273
+ this.config.planType = credentials.planType;
274
+ } else {
275
+ delete this.config.planType;
276
+ }
277
+ }
278
+
216
279
  // ── Rate limits ─────────────────────────────────────────────────
217
280
 
218
281
  async getRateLimits(): Promise<unknown> {
@@ -223,6 +286,14 @@ export class CodexAppServerWorker {
223
286
  // ── Server-request delegation ───────────────────────────────────
224
287
 
225
288
  setServerRequestHandler(handler: (method: string, params: unknown) => Promise<unknown>): void {
289
+ this.serverRequestHandler = handler;
290
+ this.bindServerRequestHandler();
291
+ }
292
+
293
+ // spawnAndInit에서 rpc 생성 직후 호출. restart로 rpc가 새로 만들어져도 핸들러가 유지된다.
294
+ bindServerRequestHandler(): void {
295
+ const handler = this.serverRequestHandler;
296
+ if (!handler) return;
226
297
  this.rpc?.onServerRequest("account/chatgptAuthTokens/refresh", (params) =>
227
298
  handler("account/chatgptAuthTokens/refresh", params),
228
299
  );
@@ -305,11 +376,18 @@ export class CodexAppServerWorker {
305
376
  } catch {}
306
377
  }
307
378
 
379
+ private failActiveTurn(error: Error): void {
380
+ const abort = this.activeTurnAbort;
381
+ this.activeTurnAbort = undefined;
382
+ abort?.(error);
383
+ }
384
+
308
385
  private consumeTurnNotifications(threadId: string, model: string): Promise<TurnResult> {
309
386
  return new Promise<TurnResult>((resolve, reject) => {
387
+ let settled = false;
388
+ let abort: ActiveTurnAbort;
310
389
  const timeout = setTimeout(() => {
311
- cleanup();
312
- reject(new Error("turn timeout (600s)"));
390
+ finishWithError(new Error("turn timeout (600s)"));
313
391
  }, 600_000);
314
392
 
315
393
  let text = "";
@@ -324,6 +402,7 @@ export class CodexAppServerWorker {
324
402
 
325
403
  const cleanup = () => {
326
404
  clearTimeout(timeout);
405
+ if (this.activeTurnAbort === abort) this.activeTurnAbort = undefined;
327
406
  for (const n of [
328
407
  "item/completed",
329
408
  "item/agentMessage/delta",
@@ -335,6 +414,23 @@ export class CodexAppServerWorker {
335
414
  }
336
415
  };
337
416
 
417
+ const finishWithError = (error: Error) => {
418
+ if (settled) return;
419
+ settled = true;
420
+ cleanup();
421
+ reject(error);
422
+ };
423
+
424
+ const finishWithResult = (result: TurnResult) => {
425
+ if (settled) return;
426
+ settled = true;
427
+ cleanup();
428
+ resolve(result);
429
+ };
430
+
431
+ abort = finishWithError;
432
+ this.activeTurnAbort = abort;
433
+
338
434
  this.rpc!.onNotification("item/completed", (p) => {
339
435
  if (p.threadId !== threadId) return;
340
436
  if (p.item.type === "agentMessage") text = p.item.text;
@@ -348,16 +444,14 @@ export class CodexAppServerWorker {
348
444
  this.rpc!.onNotification("turn/completed", (p) => {
349
445
  if (p.threadId !== threadId) return;
350
446
  durationMs = p.turn.durationMs ?? 0;
351
- cleanup();
352
- if (p.turn.status === "completed") resolve({ text, usage, durationMs, model });
353
- else reject(new Error(`turn failed: ${JSON.stringify(p.turn.error)}`));
447
+ if (p.turn.status === "completed") finishWithResult({ text, usage, durationMs, model });
448
+ else finishWithError(new Error(`turn failed: ${JSON.stringify(p.turn.error)}`));
354
449
  });
355
450
 
356
451
  this.rpc!.onNotification("error", (p) => {
357
452
  if (p.threadId !== threadId) return;
358
453
  if (!p.willRetry) {
359
- cleanup();
360
- reject(new Error(`codex error: ${p.error.message}`));
454
+ finishWithError(new Error(`codex error: ${p.error.message}`));
361
455
  }
362
456
  });
363
457
  });
@@ -369,10 +463,10 @@ export class CodexAppServerWorker {
369
463
  cb: StreamCallbacks,
370
464
  ): Promise<void> {
371
465
  return new Promise<void>((resolve, reject) => {
466
+ let settled = false;
467
+ let abort: ActiveTurnAbort;
372
468
  const timeout = setTimeout(() => {
373
- cleanup();
374
- cb.onError(new Error("turn timeout (600s)"));
375
- reject(new Error("turn timeout (600s)"));
469
+ finishWithError(new Error("turn timeout (600s)"));
376
470
  }, 600_000);
377
471
 
378
472
  let text = "";
@@ -386,6 +480,7 @@ export class CodexAppServerWorker {
386
480
 
387
481
  const cleanup = () => {
388
482
  clearTimeout(timeout);
483
+ if (this.activeTurnAbort === abort) this.activeTurnAbort = undefined;
389
484
  for (const n of [
390
485
  "item/completed",
391
486
  "item/agentMessage/delta",
@@ -397,6 +492,25 @@ export class CodexAppServerWorker {
397
492
  }
398
493
  };
399
494
 
495
+ const finishWithError = (error: Error) => {
496
+ if (settled) return;
497
+ settled = true;
498
+ cleanup();
499
+ cb.onError(error);
500
+ reject(error);
501
+ };
502
+
503
+ const finishWithComplete = (result: TurnResult) => {
504
+ if (settled) return;
505
+ settled = true;
506
+ cleanup();
507
+ cb.onComplete(result);
508
+ resolve();
509
+ };
510
+
511
+ abort = finishWithError;
512
+ this.activeTurnAbort = abort;
513
+
400
514
  this.rpc!.onNotification("item/agentMessage/delta", (p) => {
401
515
  if (p.threadId !== threadId) return;
402
516
  text += p.delta;
@@ -415,24 +529,17 @@ export class CodexAppServerWorker {
415
529
 
416
530
  this.rpc!.onNotification("turn/completed", (p) => {
417
531
  if (p.threadId !== threadId) return;
418
- cleanup();
419
532
  if (p.turn.status === "completed") {
420
- cb.onComplete({ text, usage, durationMs: p.turn.durationMs ?? 0, model });
421
- resolve();
533
+ finishWithComplete({ text, usage, durationMs: p.turn.durationMs ?? 0, model });
422
534
  } else {
423
- const err = new Error(`turn ${p.turn.status}: ${JSON.stringify(p.turn.error)}`);
424
- cb.onError(err);
425
- reject(err);
535
+ finishWithError(new Error(`turn ${p.turn.status}: ${JSON.stringify(p.turn.error)}`));
426
536
  }
427
537
  });
428
538
 
429
539
  this.rpc!.onNotification("error", (p) => {
430
540
  if (p.threadId !== threadId) return;
431
541
  if (!p.willRetry) {
432
- cleanup();
433
- const err = new Error(`codex error: ${p.error.message}`);
434
- cb.onError(err);
435
- reject(err);
542
+ finishWithError(new Error(`codex error: ${p.error.message}`));
436
543
  }
437
544
  });
438
545
  });
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { type OpenAICredentials } from "../../../application/token/token.types";
4
+ import { type CodexAppServerWorker } from "./codex-worker";
5
+ import { OpenAIDispatcher } from "./openai-dispatcher";
6
+
7
+ function credentials(overrides: Partial<OpenAICredentials> = {}): OpenAICredentials {
8
+ return {
9
+ accessToken: "access",
10
+ refreshToken: "refresh",
11
+ accessTokenExpiresAt: Date.now() + 60_000,
12
+ accountId: "account",
13
+ planType: "plus",
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ function fakeWorker(reusable: boolean): CodexAppServerWorker {
19
+ return {
20
+ kill: vi.fn(async () => {}),
21
+ canReuseForToken: vi.fn(() => reusable),
22
+ updateTokenState: vi.fn(),
23
+ } as unknown as CodexAppServerWorker;
24
+ }
25
+
26
+ describe("OpenAIDispatcher token updates", () => {
27
+ it("keeps existing workers when only OpenAI auth tokens rotate", async () => {
28
+ const dispatcher = new OpenAIDispatcher();
29
+ const worker = fakeWorker(true);
30
+ const spawnWorkers = vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
31
+ dispatcher.workerPool.set(1, [worker]);
32
+
33
+ const rotated = credentials({
34
+ accessToken: "new-access",
35
+ refreshToken: "new-refresh",
36
+ idToken: "new-id",
37
+ accessTokenExpiresAt: Date.now() + 120_000,
38
+ });
39
+ await dispatcher.onTokenUpdated(1, "renamed-token", rotated);
40
+
41
+ expect(worker.kill).not.toHaveBeenCalled();
42
+ expect(worker.updateTokenState).toHaveBeenCalledWith("renamed-token", rotated);
43
+ expect(spawnWorkers).not.toHaveBeenCalled();
44
+ expect(dispatcher.workerPool.get(1)).toEqual([worker]);
45
+ });
46
+
47
+ it("restarts workers when the OpenAI login identity changes", async () => {
48
+ const dispatcher = new OpenAIDispatcher();
49
+ const worker = fakeWorker(false);
50
+ const spawnWorkers = vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
51
+ dispatcher.workerPool.set(1, [worker]);
52
+
53
+ const changedIdentity = credentials({ accountId: "other-account" });
54
+ await dispatcher.onTokenUpdated(1, "token", changedIdentity);
55
+
56
+ expect(worker.kill).toHaveBeenCalledTimes(1);
57
+ expect(spawnWorkers).toHaveBeenCalledWith(1, "token", changedIdentity);
58
+ });
59
+ });
@@ -92,6 +92,18 @@ export class OpenAIDispatcher implements ProviderDispatcher {
92
92
 
93
93
  async onTokenUpdated(id: number, name: string, credentials: OpenAICredentials): Promise<void> {
94
94
  const existing = this.workerPool.get(id) ?? [];
95
+ if (existing.length === 0) {
96
+ await this.spawnWorkers(id, name, credentials);
97
+ return;
98
+ }
99
+
100
+ if (existing.every((w) => w.canReuseForToken(name, credentials))) {
101
+ existing.forEach((w) => w.updateTokenState(name, credentials));
102
+ logger.info(`workers updated in-place for token ${id} (${name})`);
103
+ this.drainQueue();
104
+ return;
105
+ }
106
+
95
107
  this.workerPool.delete(id);
96
108
  await Promise.allSettled(existing.map((w) => w.kill()));
97
109
  await this.spawnWorkers(id, name, credentials);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartanova/qgrid-cli",
3
- "version": "2.0.3",
3
+ "version": "2.1.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/cartanova-ai/qgrid"