@cartanova/qgrid-cli 2.4.4 → 2.4.6

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 (56) hide show
  1. package/bundle/dist/application/monit/log-buffer.js +76 -0
  2. package/bundle/dist/application/monit/monit.frame.js +86 -0
  3. package/bundle/dist/application/monit/monit.types.js +42 -0
  4. package/bundle/dist/application/qgrid/oauth.js +1 -1
  5. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +3 -3
  6. package/bundle/dist/application/qgrid/qgrid.frame.js +14 -12
  7. package/bundle/dist/application/qgrid/qgrid.types.js +3 -2
  8. package/bundle/dist/application/queries.generated.js +7 -2
  9. package/bundle/dist/application/request-log/request-log.model.js +1 -1
  10. package/bundle/dist/sonamu.config.js +14 -10
  11. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +5 -2
  12. package/bundle/dist/utils/providers/anthropic/claude-session.js +3 -3
  13. package/bundle/dist/utils/providers/openai/codex-worker.js +5 -3
  14. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +179 -15
  15. package/bundle/src/application/monit/log-buffer.test.ts +121 -0
  16. package/bundle/src/application/monit/log-buffer.ts +121 -0
  17. package/bundle/src/application/monit/monit.frame.test.ts +176 -0
  18. package/bundle/src/application/monit/monit.frame.ts +77 -0
  19. package/bundle/src/application/monit/monit.types.ts +54 -0
  20. package/bundle/src/application/qgrid/qgrid.frame.test.ts +24 -0
  21. package/bundle/src/application/qgrid/qgrid.frame.ts +2 -0
  22. package/bundle/src/application/qgrid/qgrid.types.ts +1 -0
  23. package/bundle/src/application/queries.generated.ts +8 -0
  24. package/bundle/src/application/sonamu.generated.http +12 -0
  25. package/bundle/src/sonamu.config.ts +5 -2
  26. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +5 -0
  27. package/bundle/src/utils/providers/openai/codex-worker.test.ts +26 -0
  28. package/bundle/src/utils/providers/openai/codex-worker.ts +3 -1
  29. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +212 -7
  30. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +297 -17
  31. package/bundle/web-dist/client/assets/index-BOXhNo0k.js +78 -0
  32. package/bundle/web-dist/client/assets/index-yZcjDoV0.css +1 -0
  33. package/bundle/web-dist/client/assets/{logs-vuI-Zgnq.js → logs-BShH_sut.js} +1 -1
  34. package/bundle/web-dist/client/assets/monit-CoEAp5S0.js +1 -0
  35. package/bundle/web-dist/client/assets/{routes-0Fki0JEi.js → routes-Dr3cGnV3.js} +2 -2
  36. package/bundle/web-dist/client/assets/{sd.generated-WA5SGJYZ.js → sd.generated-D2Y9QeiN.js} +1 -1
  37. package/bundle/web-dist/client/assets/{services.generated-DzZLnb7S.js → services.generated-CmxXl220.js} +1 -1
  38. package/bundle/web-dist/client/assets/{show-BreAgaFH.js → show-CXII6Iy7.js} +3 -3
  39. package/bundle/web-dist/client/assets/{tokens-4VgHQtXd.js → tokens-hP1NecIg.js} +1 -1
  40. package/bundle/web-dist/client/assets/use-update-token-mutation-6J1T9U2-.js +1 -0
  41. package/bundle/web-dist/client/index.html +3 -3
  42. package/bundle/web-dist/server/assets/{lazyRouteComponent-BrPAFtBA.js → lazyRouteComponent-BlfOh9S3.js} +1 -1
  43. package/bundle/web-dist/server/assets/{logs-CKM6W3W6.js → logs-DYly4ipQ.js} +3 -3
  44. package/bundle/web-dist/server/assets/monit-CYyY2S2f.js +1577 -0
  45. package/bundle/web-dist/server/assets/{routes-DhIpPgXz.js → routes-Dqu3osXt.js} +14 -6
  46. package/bundle/web-dist/server/assets/{sd.generated-Cwg2f53z.js → sd.generated-CE6D5clm.js} +1 -1
  47. package/bundle/web-dist/server/assets/{services.generated-CsotZP44.js → services.generated-DyZUzXiq.js} +39 -2
  48. package/bundle/web-dist/server/assets/{show-CV2UFjaH.js → show-BDul8xLb.js} +3 -3
  49. package/bundle/web-dist/server/assets/{tokens-DfGj1y2v.js → tokens-fzMW4G74.js} +4 -4
  50. package/bundle/web-dist/server/assets/{use-update-token-mutation-BL96w-Pg.js → use-update-token-mutation-BejUW5sw.js} +1 -1
  51. package/bundle/web-dist/server/entry-server.generated.js +71 -27
  52. package/package.json +1 -1
  53. package/skills/qgrid/references/ai-sdk-provider-contract.md +25 -0
  54. package/bundle/web-dist/client/assets/index-D1ExP77_.js +0 -78
  55. package/bundle/web-dist/client/assets/index-Dc-qeEPm.css +0 -1
  56. package/bundle/web-dist/client/assets/use-update-token-mutation-BV6msjuy.js +0 -1
@@ -92,6 +92,32 @@ function createWorkerWithNotificationRpc(
92
92
  return { worker, request, handlers };
93
93
  }
94
94
 
95
+ describe("CodexAppServerWorker home isolation", () => {
96
+ it("places worker homes under the configured OS temp directory", () => {
97
+ const previousTmpdir = process.env.TMPDIR;
98
+ const customTmpdir = mkdtempSync(join(tmpdir(), "qgrid-tmp-root-test-"));
99
+
100
+ try {
101
+ process.env.TMPDIR = customTmpdir;
102
+ const worker = new CodexAppServerWorker({
103
+ tokenId: 41,
104
+ tokenName: "token",
105
+ accessToken: "access",
106
+ accountId: "account",
107
+ workerIndex: 14,
108
+ });
109
+
110
+ expect((worker as unknown as { codexHome: string }).codexHome).toBe(
111
+ join(customTmpdir, "qgrid-codex", "41-14"),
112
+ );
113
+ } finally {
114
+ if (previousTmpdir === undefined) delete process.env.TMPDIR;
115
+ else process.env.TMPDIR = previousTmpdir;
116
+ rmSync(customTmpdir, { recursive: true, force: true });
117
+ }
118
+ });
119
+ });
120
+
95
121
  describe("CodexAppServerWorker active turn cleanup", () => {
96
122
  it("rejects a non-streaming turn when the worker process exits", async () => {
97
123
  const { worker } = createWorkerWithFakeRpc();
@@ -1,5 +1,7 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
2
  import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
3
5
 
4
6
  import { getLogger } from "@logtape/logtape";
5
7
 
@@ -169,7 +171,7 @@ export class CodexAppServerWorker {
169
171
 
170
172
  constructor(private config: WorkerConfig) {
171
173
  const suffix = config.workerIndex !== undefined ? `-${config.workerIndex}` : "";
172
- this.codexHome = `/tmp/qgrid-codex/${config.tokenId}${suffix}`;
174
+ this.codexHome = join(tmpdir(), "qgrid-codex", `${config.tokenId}${suffix}`);
173
175
  }
174
176
 
175
177
  // ── Lifecycle ───────────────────────────────────────────────────
@@ -4,7 +4,15 @@ import { QuotaThresholdExceededError } from "../../../application/qgrid/qgrid.ty
4
4
  import { type OpenAICredentials } from "../../../application/token/token.types";
5
5
  import { type GenerateRequest, type GenerateResult } from "../common/provider-dispatcher";
6
6
  import { type CodexAppServerWorker } from "./codex-worker";
7
- import { ImageGenerationError, OpenAIDispatcher } from "./openai-dispatcher";
7
+ import {
8
+ estimateOpenAIWorkerRssGiB,
9
+ ImageGenerationError,
10
+ makeOpenAIWorkerId,
11
+ MAX_OPENAI_WORKERS_PER_TOKEN,
12
+ type OpenAIWorkerPoolConfig,
13
+ OpenAIDispatcher,
14
+ resolveOpenAIWorkerPoolConfig,
15
+ } from "./openai-dispatcher";
8
16
  import { type OpenAIQuotaUsageResult } from "./openai-quota";
9
17
 
10
18
  const { readOpenAIQuotaUsageMock, loggerInfoMock, loggerWarnMock } = vi.hoisted(() => ({
@@ -70,7 +78,7 @@ function resultFor(worker: CodexAppServerWorker, threadId = "thread-new"): Gener
70
78
  durationMs: 10,
71
79
  model: "gpt-5-codex",
72
80
  threadCoord: {
73
- workerId: worker.tokenId * 10 + worker.workerIndex,
81
+ workerId: makeOpenAIWorkerId(worker.tokenId, worker.workerIndex),
74
82
  threadId,
75
83
  epoch: worker.epoch,
76
84
  },
@@ -159,6 +167,21 @@ function addWorkers(dispatcher: OpenAIDispatcher, workers: CodexAppServerWorker[
159
167
  for (const [tokenId, group] of byToken) dispatcher.workerPool.set(tokenId, group);
160
168
  }
161
169
 
170
+ function autoscaleConfig(
171
+ overrides: Partial<OpenAIWorkerPoolConfig> = {},
172
+ ): OpenAIWorkerPoolConfig {
173
+ return {
174
+ autoscale: true,
175
+ minWorkersPerToken: 1,
176
+ maxWorkersPerToken: 3,
177
+ scaleIntervalMs: 5_000,
178
+ scaleDownIdleMs: 600_000,
179
+ maxEstimatedRssGiB: 16,
180
+ minHostAvailableGiB: 20,
181
+ ...overrides,
182
+ };
183
+ }
184
+
162
185
  async function waitForQueue(dispatcher: OpenAIDispatcher, length = 1): Promise<void> {
163
186
  for (let i = 0; i < 10; i++) {
164
187
  await new Promise((resolve) => setTimeout(resolve, 0));
@@ -167,6 +190,175 @@ async function waitForQueue(dispatcher: OpenAIDispatcher, length = 1): Promise<v
167
190
  expect(dispatcher.queueLength).toBe(length);
168
191
  }
169
192
 
193
+ describe("OpenAIDispatcher worker capacity", () => {
194
+ it("supports 20 workers per token without worker id collisions", () => {
195
+ expect(MAX_OPENAI_WORKERS_PER_TOKEN).toBe(20);
196
+
197
+ const tokenIds = [1, 2, 41, 47, 55, 56, 58];
198
+ const workerIds = tokenIds.flatMap((tokenId) =>
199
+ Array.from({ length: MAX_OPENAI_WORKERS_PER_TOKEN }, (_, workerIndex) =>
200
+ makeOpenAIWorkerId(tokenId, workerIndex),
201
+ ),
202
+ );
203
+
204
+ expect(new Set(workerIds)).toHaveLength(workerIds.length);
205
+ expect(makeOpenAIWorkerId(1, 10)).not.toBe(makeOpenAIWorkerId(2, 0));
206
+ });
207
+
208
+ it("defaults to autoscaling from 5 to 15 workers per token", () => {
209
+ expect(resolveOpenAIWorkerPoolConfig({})).toMatchObject({
210
+ autoscale: true,
211
+ minWorkersPerToken: 5,
212
+ maxWorkersPerToken: 15,
213
+ });
214
+ });
215
+
216
+ it("keeps an explicit fixed worker mode for emergency rollback", () => {
217
+ expect(
218
+ resolveOpenAIWorkerPoolConfig({
219
+ QGRID_WORKERS_PER_TOKEN: "5",
220
+ QGRID_OPENAI_AUTOSCALE: "false",
221
+ }),
222
+ ).toMatchObject({
223
+ autoscale: false,
224
+ minWorkersPerToken: 5,
225
+ maxWorkersPerToken: 5,
226
+ });
227
+ });
228
+
229
+ it("estimates worker RSS from the dev0 measurement model", () => {
230
+ expect(estimateOpenAIWorkerRssGiB(50)).toBeCloseTo(8.56, 2);
231
+ expect(estimateOpenAIWorkerRssGiB(60)).toBeCloseTo(10.13, 2);
232
+ });
233
+ });
234
+
235
+ describe("OpenAIDispatcher monit stats", () => {
236
+ it("reports worker counts grouped by token name", () => {
237
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig({ autoscale: false }), () => 64);
238
+ addWorkers(dispatcher, [
239
+ fakeWorker({ tokenId: 1, tokenName: "openai/nk" }),
240
+ fakeWorker({ tokenId: 1, tokenName: "openai/nk", workerIndex: 1 }),
241
+ fakeWorker({ tokenId: 2, tokenName: "openai/haze" }),
242
+ ]);
243
+
244
+ expect(dispatcher.workerCountsByToken).toEqual([
245
+ { name: "openai/haze", count: 1 },
246
+ { name: "openai/nk", count: 2 },
247
+ ]);
248
+ });
249
+ });
250
+
251
+ describe("OpenAIDispatcher autoscaling", () => {
252
+ it("scales six active tokens from 5 to 15 workers per token", async () => {
253
+ const dispatcher = new OpenAIDispatcher(
254
+ autoscaleConfig({
255
+ minWorkersPerToken: 5,
256
+ maxWorkersPerToken: 15,
257
+ maxEstimatedRssGiB: 16,
258
+ }),
259
+ () => 64,
260
+ );
261
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
262
+
263
+ for (let tokenId = 1; tokenId <= 6; tokenId++) {
264
+ await dispatcher.onTokenAdded(tokenId, `token-${tokenId}`, credentials(), null, 1);
265
+ dispatcher.workerPool.set(
266
+ tokenId,
267
+ Array.from({ length: 5 }, (_, workerIndex) => fakeWorker({ tokenId, workerIndex })),
268
+ );
269
+ }
270
+
271
+ vi.spyOn(dispatcher, "spawnSingleWorker").mockImplementation(
272
+ async (tokenId, tokenName, _credentials, workerIndex) =>
273
+ fakeWorker({ tokenId, tokenName, workerIndex }),
274
+ );
275
+
276
+ for (let step = 0; step < 12; step++) await dispatcher.scaleUpOneStep();
277
+
278
+ expect(dispatcher.workerCount).toBe(90);
279
+ expect([...dispatcher.workerPool.values()].map((workers) => workers.length)).toEqual([
280
+ 15, 15, 15, 15, 15, 15,
281
+ ]);
282
+ expect(estimateOpenAIWorkerRssGiB(dispatcher.workerCount)).toBeLessThan(16);
283
+ });
284
+
285
+ it("requests a scale-up evaluation when a request enters the queue", async () => {
286
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
287
+ addWorkers(dispatcher, [fakeWorker({ tokenId: 1, busy: true })]);
288
+ const scaleUpOneStep = vi.spyOn(dispatcher, "scaleUpOneStep").mockResolvedValue(undefined);
289
+
290
+ const queued = dispatcher.enqueue(async () => "ok");
291
+ const rejection = expect(queued).rejects.toThrow("TEST_CLEANUP");
292
+ await waitForQueue(dispatcher);
293
+ dispatcher.rejectAllQueued("TEST_CLEANUP");
294
+
295
+ await rejection;
296
+ expect(scaleUpOneStep).toHaveBeenCalledTimes(1);
297
+ });
298
+
299
+ it("adds one worker to each active token in a scale-up step", async () => {
300
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
301
+ const tokenCredentials = credentials();
302
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
303
+ await dispatcher.onTokenAdded(1, "token", tokenCredentials, null, 1);
304
+ dispatcher.workerPool.set(1, [fakeWorker({ tokenId: 1, workerIndex: 0 })]);
305
+ const added = fakeWorker({ tokenId: 1, workerIndex: 1 });
306
+ const spawnSingleWorker = vi.spyOn(dispatcher, "spawnSingleWorker").mockResolvedValue(added);
307
+
308
+ await dispatcher.scaleUpOneStep();
309
+
310
+ expect(spawnSingleWorker).toHaveBeenCalledWith(1, "token", tokenCredentials, 1);
311
+ expect(dispatcher.workerPool.get(1)).toHaveLength(2);
312
+ expect(dispatcher.workerCount).toBe(2);
313
+ });
314
+
315
+ it("does not scale past the estimated RSS guard", async () => {
316
+ const dispatcher = new OpenAIDispatcher(
317
+ autoscaleConfig({ maxEstimatedRssGiB: 0.9 }),
318
+ () => 64,
319
+ );
320
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
321
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
322
+ dispatcher.workerPool.set(1, [fakeWorker({ tokenId: 1, workerIndex: 0 })]);
323
+ const spawnSingleWorker = vi.spyOn(dispatcher, "spawnSingleWorker");
324
+
325
+ await dispatcher.scaleUpOneStep();
326
+
327
+ expect(spawnSingleWorker).not.toHaveBeenCalled();
328
+ expect(dispatcher.workerCount).toBe(1);
329
+ });
330
+
331
+ it("does not scale when host available memory is below the floor", async () => {
332
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 10);
333
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
334
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
335
+ dispatcher.workerPool.set(1, [fakeWorker({ tokenId: 1, workerIndex: 0 })]);
336
+ const spawnSingleWorker = vi.spyOn(dispatcher, "spawnSingleWorker");
337
+
338
+ await dispatcher.scaleUpOneStep();
339
+
340
+ expect(spawnSingleWorker).not.toHaveBeenCalled();
341
+ expect(dispatcher.workerCount).toBe(1);
342
+ });
343
+
344
+ it("removes only idle workers above the configured minimum for inactive tokens", async () => {
345
+ const dispatcher = new OpenAIDispatcher(autoscaleConfig(), () => 64);
346
+ vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
347
+ await dispatcher.onTokenAdded(1, "token", credentials(), null, 1);
348
+ const busy = fakeWorker({ tokenId: 1, workerIndex: 0, busy: true });
349
+ const idle = fakeWorker({ tokenId: 1, workerIndex: 1 });
350
+ dispatcher.workerPool.set(1, [busy, idle]);
351
+ dispatcher.onTokenDeactivated(1);
352
+
353
+ await dispatcher.scaleDownOneStep();
354
+ await dispatcher.scaleDownOneStep();
355
+
356
+ expect(idle.kill).toHaveBeenCalledTimes(1);
357
+ expect(busy.kill).not.toHaveBeenCalled();
358
+ expect(dispatcher.workerPool.get(1)).toEqual([busy]);
359
+ });
360
+ });
361
+
170
362
  describe("OpenAIDispatcher token updates", () => {
171
363
  it("keeps existing workers when only OpenAI auth tokens rotate", async () => {
172
364
  const dispatcher = new OpenAIDispatcher();
@@ -329,7 +521,9 @@ describe("OpenAIDispatcher weighted routing", () => {
329
521
 
330
522
  const cold1 = await dispatcher.generate(baseReq());
331
523
  const reused = await dispatcher.generate(
332
- baseReq({ reuse: { workerId: 20, threadId: "thread-B", epoch: 1 } }),
524
+ baseReq({
525
+ reuse: { workerId: makeOpenAIWorkerId(2, 0), threadId: "thread-B", epoch: 1 },
526
+ }),
333
527
  );
334
528
  const cold2 = await dispatcher.generate(baseReq());
335
529
 
@@ -470,7 +664,9 @@ describe("OpenAIDispatcher quota threshold gate", () => {
470
664
  );
471
665
 
472
666
  const result = await dispatcher.generate(
473
- baseReq({ reuse: { workerId: 10, threadId: "thread-1", epoch: 1 } }),
667
+ baseReq({
668
+ reuse: { workerId: makeOpenAIWorkerId(1, 0), threadId: "thread-1", epoch: 1 },
669
+ }),
474
670
  );
475
671
 
476
672
  expect(result.tokenName).toBe("tok-B");
@@ -492,7 +688,9 @@ describe("OpenAIDispatcher quota threshold gate", () => {
492
688
  );
493
689
 
494
690
  const result = await dispatcher.generate(
495
- baseReq({ reuse: { workerId: 10, threadId: "thread-1", epoch: 1 } }),
691
+ baseReq({
692
+ reuse: { workerId: makeOpenAIWorkerId(1, 0), threadId: "thread-1", epoch: 1 },
693
+ }),
496
694
  );
497
695
 
498
696
  expect(result.tokenName).toBe("tok-A");
@@ -543,7 +741,11 @@ describe("OpenAIDispatcher quota threshold gate", () => {
543
741
  readOpenAIQuotaUsageMock.mockResolvedValueOnce(quotaOk(85));
544
742
 
545
743
  await expect(
546
- dispatcher.generate(baseReq({ reuse: { workerId: 10, threadId: "thread-1", epoch: 1 } })),
744
+ dispatcher.generate(
745
+ baseReq({
746
+ reuse: { workerId: makeOpenAIWorkerId(1, 0), threadId: "thread-1", epoch: 1 },
747
+ }),
748
+ ),
547
749
  ).rejects.toBeInstanceOf(QuotaThresholdExceededError);
548
750
  });
549
751
  });
@@ -621,7 +823,10 @@ describe("OpenAIDispatcher image generation routing", () => {
621
823
  it("forces cold thread for image requests even when a reuse coordinate is present", async () => {
622
824
  const dispatcher = new OpenAIDispatcher();
623
825
  const worker = await dispatcher.acquireReuseWorker(
624
- baseReq({ imageGeneration: true, reuse: { workerId: 10, threadId: "t1", epoch: 1 } }),
826
+ baseReq({
827
+ imageGeneration: true,
828
+ reuse: { workerId: makeOpenAIWorkerId(1, 0), threadId: "t1", epoch: 1 },
829
+ }),
625
830
  );
626
831
  expect(worker).toBeNull();
627
832
  });