@cartanova/qgrid-cli 2.6.2 → 2.6.5

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 (72) hide show
  1. package/README.ko.md +2 -0
  2. package/README.md +2 -0
  3. package/bundle/dist/application/qgrid/expired-token-digest.js +78 -0
  4. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +40 -6
  5. package/bundle/dist/application/qgrid/qgrid.frame.js +22 -7
  6. package/bundle/dist/application/qgrid/qgrid.types.js +13 -3
  7. package/bundle/dist/application/qgrid/token-death.js +29 -4
  8. package/bundle/dist/application/queries.generated.js +2 -1
  9. package/bundle/dist/application/request-log/request-log.model.js +37 -5
  10. package/bundle/dist/application/request-log-step/request-log-step.model.js +1 -1
  11. package/bundle/dist/application/sonamu.generated.js +41 -3
  12. package/bundle/dist/application/token/token.model.js +44 -14
  13. package/bundle/dist/i18n/en.js +3 -1
  14. package/bundle/dist/i18n/ko.js +3 -1
  15. package/bundle/dist/i18n/sd.generated.js +18 -1
  16. package/bundle/dist/migrations/20260807164057_alter_request_logs_alter1.js +13 -0
  17. package/bundle/dist/server-error-handler.js +5 -3
  18. package/bundle/dist/sonamu.config.js +8 -1
  19. package/bundle/dist/utils/slack-notify.js +2 -2
  20. package/bundle/src/application/qgrid/expired-token-digest.test.ts +49 -0
  21. package/bundle/src/application/qgrid/expired-token-digest.ts +93 -0
  22. package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +0 -3
  23. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +30 -2
  24. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +44 -6
  25. package/bundle/src/application/qgrid/qgrid.frame.ts +13 -4
  26. package/bundle/src/application/qgrid/qgrid.types.ts +10 -0
  27. package/bundle/src/application/qgrid/token-death.test.ts +33 -3
  28. package/bundle/src/application/qgrid/token-death.ts +32 -3
  29. package/bundle/src/application/queries.generated.ts +3 -0
  30. package/bundle/src/application/request-log/request-log.entity.json +26 -1
  31. package/bundle/src/application/request-log/request-log.model.ts +50 -10
  32. package/bundle/src/application/sonamu.generated.http +6 -0
  33. package/bundle/src/application/sonamu.generated.ts +42 -2
  34. package/bundle/src/application/token/token.model.test.ts +13 -3
  35. package/bundle/src/application/token/token.model.ts +34 -17
  36. package/bundle/src/i18n/en.ts +3 -0
  37. package/bundle/src/i18n/ko.ts +3 -0
  38. package/bundle/src/i18n/sd.generated.ts +17 -0
  39. package/bundle/src/migrations/20260807164057_alter_request_logs_alter1.ts +13 -0
  40. package/bundle/src/server-error-handler.test.ts +58 -0
  41. package/bundle/src/server-error-handler.ts +16 -5
  42. package/bundle/src/sonamu.config.ts +13 -0
  43. package/bundle/src/utils/slack-notify.test.ts +6 -4
  44. package/bundle/src/utils/slack-notify.ts +10 -3
  45. package/bundle/web-dist/client/assets/index-BGQSGMcK.css +1 -0
  46. package/bundle/web-dist/client/assets/index-DTy-s79t.js +78 -0
  47. package/bundle/web-dist/client/assets/logs-DYqUSqg-.js +1 -0
  48. package/bundle/web-dist/client/assets/{monit-BSa5lSZp.js → monit-GnFS0_Xa.js} +1 -1
  49. package/bundle/web-dist/client/assets/routes-CBwpn4d-.js +5 -0
  50. package/bundle/web-dist/client/assets/{sd.generated-CjW3rfJ8.js → sd.generated-DYdwSB2I.js} +1 -1
  51. package/bundle/web-dist/client/assets/{services.generated-B-dMUtEu.js → services.generated-BsAvotjG.js} +1 -1
  52. package/bundle/web-dist/client/assets/{show-C1jEPhuS.js → show-DDgukGuY.js} +3 -3
  53. package/bundle/web-dist/client/assets/{tokens-1e66MAC0.js → tokens-Co1wPrXX.js} +1 -1
  54. package/bundle/web-dist/client/assets/{use-update-token-mutation-DueEppqt.js → use-update-token-mutation-DqEpK7It.js} +1 -1
  55. package/bundle/web-dist/client/index.html +3 -3
  56. package/bundle/web-dist/server/assets/{lazyRouteComponent-PRxXWW3t.js → lazyRouteComponent-BaZaveNc.js} +1 -1
  57. package/bundle/web-dist/server/assets/{logs-CwoOsveV.js → logs-DMOw3SjD.js} +82 -40
  58. package/bundle/web-dist/server/assets/{monit-DIsEsDJN.js → monit-UmcqHroX.js} +2 -2
  59. package/bundle/web-dist/server/assets/{routes-Bts-KJ9W.js → routes-Sv_4FjtH.js} +47 -45
  60. package/bundle/web-dist/server/assets/{sd.generated-ek-tz-7H.js → sd.generated-B0xqnHEo.js} +21 -0
  61. package/bundle/web-dist/server/assets/{services.generated-Bh7Vs8Ds.js → services.generated-DqAKskAS.js} +16 -1
  62. package/bundle/web-dist/server/assets/{show-DJfaCNUw.js → show-C-oSdzHX.js} +31 -25
  63. package/bundle/web-dist/server/assets/{tokens-BA4vwDat.js → tokens-h-2A3wfD.js} +112 -109
  64. package/bundle/web-dist/server/assets/{use-update-token-mutation-BQRzEPy2.js → use-update-token-mutation-DHCaa7Ku.js} +1 -1
  65. package/bundle/web-dist/server/entry-server.generated.js +70 -29
  66. package/package.json +1 -1
  67. package/skills/qgrid/references/cli-env-and-server-boot.md +8 -0
  68. package/skills/qgrid/references/token-auth-quota-lifecycle.md +3 -2
  69. package/bundle/web-dist/client/assets/index-2cAgaLOm.css +0 -1
  70. package/bundle/web-dist/client/assets/index-BgWzzjvj.js +0 -78
  71. package/bundle/web-dist/client/assets/logs-Lyv8OfHE.js +0 -1
  72. package/bundle/web-dist/client/assets/routes-Cp0re-Nn.js +0 -5
@@ -1,6 +1,6 @@
1
- import { J as __commonJSMin, Y as __exportAll, Z as __toESM, _ as require_jsx_runtime, v as require_react } from "./sd.generated-ek-tz-7H.js";
2
- import { i as RequestLogStepService, r as RequestLogService } from "./services.generated-Bh7Vs8Ds.js";
3
- import { a as Link } from "./lazyRouteComponent-PRxXWW3t.js";
1
+ import { J as __commonJSMin, Y as __exportAll, Z as __toESM, _ as require_jsx_runtime, v as require_react } from "./sd.generated-B0xqnHEo.js";
2
+ import { i as RequestLogStepService, r as RequestLogService } from "./services.generated-DqAKskAS.js";
3
+ import { a as Link } from "./lazyRouteComponent-BaZaveNc.js";
4
4
  import { t as Route } from "../entry-server.generated.js";
5
5
  import { n as formatMicroUsd, t as cacheHitRate } from "./cost-CSH_t17I.js";
6
6
  import minpath from "node:path";
@@ -25269,13 +25269,16 @@ function Section({ title, defaultOpen = true, children }) {
25269
25269
  });
25270
25270
  }
25271
25271
  function Metric({ label, value }) {
25272
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("dt", {
25273
- className: "text-[10px] uppercase tracking-wider text-sand-400 font-medium",
25274
- children: label
25275
- }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("dd", {
25276
- className: "text-[15px] font-semibold text-sand-800 tabular-nums mt-0.5",
25277
- children: value
25278
- })] });
25272
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
25273
+ className: "min-w-0",
25274
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("dt", {
25275
+ className: "text-[10px] uppercase tracking-wider text-sand-400 font-medium truncate",
25276
+ children: label
25277
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("dd", {
25278
+ className: "text-[15px] font-semibold text-sand-800 tabular-nums mt-0.5 truncate",
25279
+ children: value
25280
+ })]
25281
+ });
25279
25282
  }
25280
25283
  var STATUS_STYLE = {
25281
25284
  running: "bg-blue-100 text-blue-600",
@@ -25306,10 +25309,10 @@ function HeaderBar({ data }) {
25306
25309
  const hasFallback = !isRunning && data.requested_model_name !== null && data.model_name !== null && data.requested_model_name !== data.model_name;
25307
25310
  const hasRequestedOnly = !isRunning && data.requested_model_name !== null && data.model_name === null;
25308
25311
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
25309
- className: "panel overflow-hidden px-5 py-3 flex items-center gap-2",
25312
+ className: "panel overflow-hidden px-4 py-3 flex flex-wrap items-center gap-x-2 gap-y-1.5 sm:px-5",
25310
25313
  children: [
25311
25314
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
25312
- className: "text-[15px] font-semibold text-sand-900",
25315
+ className: "text-[15px] font-semibold text-sand-900 break-all",
25313
25316
  children: isRunning ? "실행 중" : hasFallback ? `${data.requested_model_name} → ${data.model_name}` : data.model_name ?? data.requested_model_name ?? "Unknown model"
25314
25317
  }),
25315
25318
  hasRequestedOnly && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
@@ -25441,13 +25444,16 @@ function formatTokensPerSec(outputTokens, durationMs, ttftMs, enabled) {
25441
25444
  return `${Math.round(outputTokens / (generationMs / 1e3)).toLocaleString()} tok/s`;
25442
25445
  }
25443
25446
  function CompactMetric({ label, value }) {
25444
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
25445
- className: "text-[10px] uppercase tracking-wider text-sand-400 font-medium",
25446
- children: label
25447
- }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
25448
- className: "mt-0.5 text-[13px] font-semibold text-sand-800 tabular-nums",
25449
- children: value
25450
- })] });
25447
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
25448
+ className: "min-w-0",
25449
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
25450
+ className: "text-[10px] uppercase tracking-wider text-sand-400 font-medium truncate",
25451
+ children: label
25452
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
25453
+ className: "mt-0.5 text-[13px] font-semibold text-sand-800 tabular-nums truncate",
25454
+ children: value
25455
+ })]
25456
+ });
25451
25457
  }
25452
25458
  function readToolInputImages(args) {
25453
25459
  if (!args || typeof args !== "object" || Array.isArray(args)) return [];
@@ -25662,7 +25668,7 @@ function StepTreeItem({ entry, tokensPerSecEnabled }) {
25662
25668
  children: [generate && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
25663
25669
  className: "rounded-md border border-sand-100 bg-sand-50/70 p-3",
25664
25670
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
25665
- className: "grid grid-cols-2 md:grid-cols-4 xl:grid-cols-8 gap-3",
25671
+ className: "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-8 gap-x-4 gap-y-3",
25666
25672
  children: [
25667
25673
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CompactMetric, {
25668
25674
  label: "Duration",
@@ -25928,16 +25934,16 @@ function RequestDetail({ id }) {
25928
25934
  hasHistory && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(HistorySection, { history }),
25929
25935
  hasTools && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ToolsSection, { tools }),
25930
25936
  hasSteps ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
25931
- className: "flex gap-4 items-start",
25937
+ className: "flex flex-col lg:flex-row gap-4 items-stretch lg:items-start",
25932
25938
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
25933
- className: "flex-1 min-w-0",
25934
- children: promptSections
25935
- }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
25936
- className: "flex-1 min-w-0 space-y-4",
25939
+ className: "order-1 lg:order-2 flex-1 min-w-0 space-y-4",
25937
25940
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(StepTreeSection, {
25938
25941
  steps: stepTree,
25939
25942
  tokensPerSecEnabled
25940
25943
  })
25944
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
25945
+ className: "order-2 lg:order-1 flex-1 min-w-0",
25946
+ children: promptSections
25941
25947
  })]
25942
25948
  }) : promptSections,
25943
25949
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Section, {
@@ -1,7 +1,7 @@
1
- import { Z as __toESM, _ as require_jsx_runtime, g as useQueryClient, v as require_react } from "./sd.generated-ek-tz-7H.js";
2
- import { a as TokenService, n as QgridService } from "./services.generated-Bh7Vs8Ds.js";
1
+ import { Z as __toESM, _ as require_jsx_runtime, g as useQueryClient, v as require_react } from "./sd.generated-B0xqnHEo.js";
2
+ import { a as TokenService, n as QgridService } from "./services.generated-DqAKskAS.js";
3
3
  import { i as Q, o as lucideKeyRound } from "../entry-server.generated.js";
4
- import { t as useUpdateTokenMutation } from "./use-update-token-mutation-BQRzEPy2.js";
4
+ import { t as useUpdateTokenMutation } from "./use-update-token-mutation-DHCaa7Ku.js";
5
5
  //#region ~icons/lucide/plus.jsx
6
6
  var import_react = /* @__PURE__ */ __toESM(require_react());
7
7
  var import_jsx_runtime = require_jsx_runtime();
@@ -392,113 +392,116 @@ function TokenTable({ data, isLoading }) {
392
392
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
393
393
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
394
394
  className: "panel overflow-hidden",
395
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("table", {
396
- className: "w-full text-[13px]",
397
- children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("tr", {
398
- className: "panel-header",
399
- children: [
400
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", {
401
- className: "text-left px-5 py-2.5 text-[10px] uppercase tracking-wider text-sand-400 font-medium",
402
- children: "Provider"
403
- }),
404
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", {
405
- className: "text-left px-5 py-2.5 text-[10px] uppercase tracking-wider text-sand-400 font-medium",
406
- children: "Name"
407
- }),
408
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", {
409
- className: "text-left px-5 py-2.5 text-[10px] uppercase tracking-wider text-sand-400 font-medium",
410
- children: "Token"
411
- }),
412
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", {
413
- className: "text-left px-5 py-2.5 text-[10px] uppercase tracking-wider text-sand-400 font-medium",
414
- children: "Status"
415
- }),
416
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", {
417
- className: "text-left px-5 py-2.5 text-[10px] uppercase tracking-wider text-sand-400 font-medium",
418
- children: "Expires"
419
- }),
420
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", { className: "w-20 px-3 py-2.5" })
421
- ]
422
- }) }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("tbody", {
423
- className: "divide-y divide-sand-100/80",
424
- children: tokens.map((token) => {
425
- const expiry = formatExpiry(getExpiresAt(token));
426
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("tr", {
427
- className: `transition-colors duration-150 hover:bg-sand-100/60 ${token.active ? "" : "opacity-50"}`,
428
- children: [
429
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", {
430
- className: "px-5 py-3",
431
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
432
- className: "text-xs font-medium text-sand-500 uppercase",
433
- children: token.provider
434
- })
435
- }),
436
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", {
437
- className: "px-5 py-3",
438
- children: token.name ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
439
- className: "text-sm text-sand-700",
440
- children: token.name
441
- }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
442
- className: "text-[11px] text-sand-400",
443
- children: "—"
444
- })
445
- }),
446
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", {
447
- className: "px-5 py-3",
448
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("code", {
449
- className: "text-[13px] font-mono text-sand-800",
450
- children: maskAccessToken(token)
451
- })
452
- }),
453
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", {
454
- className: "px-5 py-3",
455
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
456
- type: "button",
457
- className: `relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-200 ${token.active ? "bg-sage-400" : "bg-sand-300"}`,
458
- onClick: () => handleToggle(token),
459
- disabled: toggleMutation.isPending,
460
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: `inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform duration-200 ${token.active ? "translate-x-4.5" : "translate-x-0.5"}` })
461
- })
462
- }),
463
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", {
464
- className: "px-5 py-3",
465
- children: expiry.text && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
466
- className: `text-xs ${expiry.color}`,
467
- children: expiry.text
468
- })
469
- }),
470
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", {
471
- className: "px-3 py-3",
472
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
473
- className: "flex items-center gap-1",
474
- children: [
475
- !token.active && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
476
- type: "button",
477
- title: "기존 이름으로 다시 로그인",
478
- className: "px-2 py-0.5 mr-1 text-[11px] font-medium rounded-md border border-sand-300 text-sand-600 hover:bg-sand-100 disabled:opacity-50 transition-colors duration-150 inline-flex items-center gap-1",
479
- disabled: oauth.loadingProvider !== null,
480
- onClick: () => void oauth.start(token.provider, token.name ?? ""),
481
- children: oauth.loadingProvider === token.provider ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "w-3 h-3 border-2 border-sand-400 border-t-transparent rounded-full animate-spin" }), "로그인 대기"] }) : "재로그인"
482
- }),
483
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
484
- type: "button",
485
- className: "p-1 rounded text-sand-400 hover:text-sienna-500 transition-colors duration-150",
486
- onClick: () => openEdit(token),
487
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(lucidePencil, { className: "size-4" })
488
- }),
489
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
490
- type: "button",
491
- className: "p-1 rounded text-sand-400 hover:text-red-500 transition-colors duration-150",
492
- onClick: () => setDeleteTarget(token),
493
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(lucideTrash2, { className: "size-4" })
494
- })
495
- ]
395
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
396
+ className: "overflow-x-auto",
397
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("table", {
398
+ className: "w-full min-w-[640px] text-[13px]",
399
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("tr", {
400
+ className: "panel-header",
401
+ children: [
402
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", {
403
+ className: "text-left px-5 py-2.5 text-[10px] uppercase tracking-wider text-sand-400 font-medium",
404
+ children: "Provider"
405
+ }),
406
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", {
407
+ className: "text-left px-5 py-2.5 text-[10px] uppercase tracking-wider text-sand-400 font-medium",
408
+ children: "Name"
409
+ }),
410
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", {
411
+ className: "text-left px-5 py-2.5 text-[10px] uppercase tracking-wider text-sand-400 font-medium",
412
+ children: "Token"
413
+ }),
414
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", {
415
+ className: "text-left px-5 py-2.5 text-[10px] uppercase tracking-wider text-sand-400 font-medium",
416
+ children: "Status"
417
+ }),
418
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", {
419
+ className: "text-left px-5 py-2.5 text-[10px] uppercase tracking-wider text-sand-400 font-medium",
420
+ children: "Expires"
421
+ }),
422
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("th", { className: "w-20 px-3 py-2.5" })
423
+ ]
424
+ }) }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("tbody", {
425
+ className: "divide-y divide-sand-100/80",
426
+ children: tokens.map((token) => {
427
+ const expiry = formatExpiry(getExpiresAt(token));
428
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("tr", {
429
+ className: `transition-colors duration-150 hover:bg-sand-100/60 ${token.active ? "" : "opacity-50"}`,
430
+ children: [
431
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", {
432
+ className: "px-5 py-3",
433
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
434
+ className: "text-xs font-medium text-sand-500 uppercase",
435
+ children: token.provider
436
+ })
437
+ }),
438
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", {
439
+ className: "px-5 py-3",
440
+ children: token.name ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
441
+ className: "text-sm text-sand-700",
442
+ children: token.name
443
+ }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
444
+ className: "text-[11px] text-sand-400",
445
+ children: "—"
446
+ })
447
+ }),
448
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", {
449
+ className: "px-5 py-3",
450
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("code", {
451
+ className: "text-[13px] font-mono text-sand-800",
452
+ children: maskAccessToken(token)
453
+ })
454
+ }),
455
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", {
456
+ className: "px-5 py-3",
457
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
458
+ type: "button",
459
+ className: `relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-200 ${token.active ? "bg-sage-400" : "bg-sand-300"}`,
460
+ onClick: () => handleToggle(token),
461
+ disabled: toggleMutation.isPending,
462
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: `inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform duration-200 ${token.active ? "translate-x-4.5" : "translate-x-0.5"}` })
463
+ })
464
+ }),
465
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", {
466
+ className: "px-5 py-3",
467
+ children: expiry.text && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
468
+ className: `text-xs ${expiry.color}`,
469
+ children: expiry.text
470
+ })
471
+ }),
472
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", {
473
+ className: "px-3 py-3",
474
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
475
+ className: "flex items-center gap-1",
476
+ children: [
477
+ !token.active && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
478
+ type: "button",
479
+ title: "기존 이름으로 다시 로그인",
480
+ className: "px-2 py-0.5 mr-1 text-[11px] font-medium rounded-md border border-sand-300 text-sand-600 hover:bg-sand-100 disabled:opacity-50 transition-colors duration-150 inline-flex items-center gap-1",
481
+ disabled: oauth.loadingProvider !== null,
482
+ onClick: () => void oauth.start(token.provider, token.name ?? ""),
483
+ children: oauth.loadingProvider === token.provider ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "w-3 h-3 border-2 border-sand-400 border-t-transparent rounded-full animate-spin" }), "로그인 대기"] }) : "재로그인"
484
+ }),
485
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
486
+ type: "button",
487
+ className: "p-1 rounded text-sand-400 hover:text-sienna-500 transition-colors duration-150",
488
+ onClick: () => openEdit(token),
489
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(lucidePencil, { className: "size-4" })
490
+ }),
491
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
492
+ type: "button",
493
+ className: "p-1 rounded text-sand-400 hover:text-red-500 transition-colors duration-150",
494
+ onClick: () => setDeleteTarget(token),
495
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(lucideTrash2, { className: "size-4" })
496
+ })
497
+ ]
498
+ })
496
499
  })
497
- })
498
- ]
499
- }, token.id);
500
- })
501
- })]
500
+ ]
501
+ }, token.id);
502
+ })
503
+ })]
504
+ })
502
505
  })
503
506
  }),
504
507
  oauth.codeEntry && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
@@ -1,4 +1,4 @@
1
- import { n as QgridService, o as useMutation } from "./services.generated-Bh7Vs8Ds.js";
1
+ import { n as QgridService, o as useMutation } from "./services.generated-DqAKskAS.js";
2
2
  //#region src/services/token/use-update-token-mutation.ts
3
3
  function useUpdateTokenMutation() {
4
4
  return useMutation({ mutationFn: ({ id, name, quotaThreshold, weight }) => QgridService.updateToken(id, name, quotaThreshold, weight) });
@@ -1,5 +1,5 @@
1
- import { A as functionalUpdate, B as resolveStaleTime, C as infiniteQueryBehavior, D as notifyManager, E as onlineManager, F as matchQuery, I as noop$1, J as __commonJSMin, K as focusManager, L as partialMatchKey, M as hashQueryKeyByOptions, P as matchMutation, U as skipToken, X as __require, Z as __toESM, _ as require_jsx_runtime, c as _enum, d as string, h as QueryClientProvider, j as hashKey, l as number, m as require_shim, n as SUPPORTED_LOCALES, p as require_react_dom, q as Subscribable, r as setLocale, t as SD, u as object, v as require_react, w as Query, y as Mutation } from "./assets/sd.generated-ek-tz-7H.js";
2
- import { A as decodePath, C as findRouteMatch, D as createLRUCache, E as processRouteTree, F as last, I as replaceEqualDeep, M as findLast, N as functionalUpdate$1, O as invariant, P as isPromise, S as findFlatMatch, T as processRouteMasks, _ as interpolatePath, a as Link$1, b as trimPath, c as usePrevious$1, d as useRouter, f as getRouterContext, g as cleanPath, h as isNotFound, i as createRootRouteWithContext, j as deepEqual$2, k as createControlledPromise, l as matchContext, m as rootRouteId, n as createFileRoute, p as warning, r as createLazyFileRoute, s as useLayoutEffect$3, t as lazyRouteComponent, u as useRouterState, v as joinPaths, w as findSingleMatch, x as trimPathRight, y as resolvePath } from "./assets/lazyRouteComponent-PRxXWW3t.js";
1
+ import { A as functionalUpdate, B as resolveStaleTime, C as infiniteQueryBehavior, D as notifyManager, E as onlineManager, F as matchQuery, I as noop$1, J as __commonJSMin, K as focusManager, L as partialMatchKey, M as hashQueryKeyByOptions, P as matchMutation, U as skipToken, X as __require, Z as __toESM, _ as require_jsx_runtime, c as _enum, d as string, h as QueryClientProvider, j as hashKey, l as number, m as require_shim, n as SUPPORTED_LOCALES, p as require_react_dom, q as Subscribable, r as setLocale, t as SD, u as object, v as require_react, w as Query, y as Mutation } from "./assets/sd.generated-B0xqnHEo.js";
2
+ import { A as decodePath, C as findRouteMatch, D as createLRUCache, E as processRouteTree, F as last, I as replaceEqualDeep, M as findLast, N as functionalUpdate$1, O as invariant, P as isPromise, S as findFlatMatch, T as processRouteMasks, _ as interpolatePath, a as Link$1, b as trimPath, c as usePrevious$1, d as useRouter, f as getRouterContext, g as cleanPath, h as isNotFound, i as createRootRouteWithContext, j as deepEqual$2, k as createControlledPromise, l as matchContext, m as rootRouteId, n as createFileRoute, p as warning, r as createLazyFileRoute, s as useLayoutEffect$3, t as lazyRouteComponent, u as useRouterState, v as joinPaths, w as findSingleMatch, x as trimPathRight, y as resolvePath } from "./assets/lazyRouteComponent-BaZaveNc.js";
3
3
  //#region ../../node_modules/.pnpm/@tanstack+query-core@5.95.2/node_modules/@tanstack/query-core/build/modern/hydration.js
4
4
  function defaultTransformerFn(data) {
5
5
  return data;
@@ -22675,8 +22675,9 @@ var lucideTerminal = (props) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg
22675
22675
  })
22676
22676
  });
22677
22677
  //#endregion
22678
- //#region src/components/Sidebar.tsx
22679
- var sections = [{
22678
+ //#region src/components/nav-items.ts
22679
+ /** 사이드바(데스크톱)와 하단 탭 바(모바일)가 공유하는 단일 정의. */
22680
+ var NAV_SECTIONS = [{
22680
22681
  label: "Overview",
22681
22682
  items: [{
22682
22683
  title: "Dashboard",
@@ -22693,23 +22694,57 @@ var sections = [{
22693
22694
  },
22694
22695
  {
22695
22696
  title: "Request Logs",
22697
+ shortTitle: "Logs",
22696
22698
  path: "/logs",
22697
22699
  icon: lucideFileText
22698
22700
  },
22699
22701
  {
22700
22702
  title: "Server Logs",
22703
+ shortTitle: "Server",
22701
22704
  path: "/monit",
22702
22705
  icon: lucideTerminal
22703
22706
  }
22704
22707
  ]
22705
22708
  }];
22706
- var qgridVersion = "2.6.2";
22709
+ var NAV_ITEMS = NAV_SECTIONS.flatMap((section) => section.items);
22710
+ function isNavItemActive(path, pathname) {
22711
+ if (path === "/") return pathname === "/" || pathname === "";
22712
+ return pathname.startsWith(path);
22713
+ }
22714
+ //#endregion
22715
+ //#region src/components/BottomNav.tsx
22716
+ /**
22717
+ * 좁은 화면 전용 하단 탭 바.
22718
+ *
22719
+ * 사이드바는 `md` 미만에서 숨겨지는데, 그러면 URL 을 직접 치지 않는 한 페이지 이동이
22720
+ * 불가능해진다. 항목이 4개뿐이라 햄버거 메뉴보다 탭 바가 맞다 — 한 번의 탭으로 이동하고
22721
+ * 현재 위치도 항상 보인다.
22722
+ */
22723
+ function BottomNav() {
22724
+ const pathname = useRouterState({ select: (s) => s.location.pathname });
22725
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("nav", {
22726
+ className: "md:hidden shrink-0 border-t border-sand-200/60 bg-sand-50/95 backdrop-blur-sm pb-[env(safe-area-inset-bottom)]",
22727
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
22728
+ className: "flex",
22729
+ children: NAV_ITEMS.map((item) => {
22730
+ const active = isNavItemActive(item.path, pathname);
22731
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Link$1, {
22732
+ to: item.path,
22733
+ className: `flex-1 flex flex-col items-center gap-0.5 py-2 no-underline transition-colors duration-150 ${active ? "text-sienna-600" : "text-sand-500"}`,
22734
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(item.icon, { className: `size-5 ${active ? "text-sienna-500" : "text-sand-400"}` }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
22735
+ className: `text-[10px] ${active ? "font-medium" : ""}`,
22736
+ children: item.shortTitle ?? item.title
22737
+ })]
22738
+ }, item.path);
22739
+ })
22740
+ })
22741
+ });
22742
+ }
22743
+ //#endregion
22744
+ //#region src/components/Sidebar.tsx
22745
+ var qgridVersion = "2.6.5";
22707
22746
  function Sidebar({ className }) {
22708
22747
  const pathname = useRouterState({ select: (s) => s.location.pathname });
22709
- const isActive = (path) => {
22710
- if (path === "/") return pathname === "/" || pathname === "";
22711
- return pathname.startsWith(path);
22712
- };
22713
22748
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("aside", {
22714
22749
  className: `hidden md:flex w-[200px] bg-sand-50 flex-col shrink-0 border-r border-sand-200/40 ${className ?? ""}`,
22715
22750
  children: [
@@ -22722,7 +22757,7 @@ function Sidebar({ className }) {
22722
22757
  }),
22723
22758
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("nav", {
22724
22759
  className: "flex-1 px-2.5 py-3 space-y-4 overflow-y-auto",
22725
- children: sections.map((section) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
22760
+ children: NAV_SECTIONS.map((section) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
22726
22761
  className: "px-2 pb-1.5",
22727
22762
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
22728
22763
  className: "text-[10px] uppercase tracking-[0.08em] text-sand-400 font-medium",
@@ -22731,7 +22766,7 @@ function Sidebar({ className }) {
22731
22766
  }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
22732
22767
  className: "space-y-0.5",
22733
22768
  children: section.items.map((item) => {
22734
- const active = isActive(item.path);
22769
+ const active = isNavItemActive(item.path, pathname);
22735
22770
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Link$1, {
22736
22771
  to: item.path,
22737
22772
  className: `flex items-center gap-2 px-2 py-[6px] rounded-[7px] text-[13px] no-underline transition-all duration-150 ${active ? "bg-sand-200/60 text-sand-900 font-medium" : "text-sand-500 hover:bg-sand-100 hover:text-sand-700"}`,
@@ -22803,20 +22838,24 @@ function App({ children }) {
22803
22838
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
22804
22839
  className: "flex h-screen w-full bg-sand-100 overflow-hidden font-sans",
22805
22840
  children: [showSidebar && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Sidebar, {}), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
22806
- className: "flex-1 flex flex-col overflow-hidden",
22807
- children: [showSidebar && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Toolbar, {}), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("main", {
22808
- className: "flex-1 overflow-y-auto main-scroll content-inset",
22809
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
22810
- className: "px-6 py-5 md:px-8",
22811
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react.Suspense, {
22812
- fallback: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
22813
- className: "text-sand-400 text-center py-8 text-sm",
22814
- children: "로딩 중..."
22815
- }),
22816
- children
22841
+ className: "flex-1 flex flex-col overflow-hidden min-w-0",
22842
+ children: [
22843
+ showSidebar && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Toolbar, {}),
22844
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("main", {
22845
+ className: "flex-1 overflow-y-auto main-scroll content-inset",
22846
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
22847
+ className: "px-3 py-4 sm:px-6 sm:py-5 md:px-8",
22848
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react.Suspense, {
22849
+ fallback: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
22850
+ className: "text-sand-400 text-center py-8 text-sm",
22851
+ children: "로딩 중..."
22852
+ }),
22853
+ children
22854
+ })
22817
22855
  })
22818
- })
22819
- })]
22856
+ }),
22857
+ showSidebar && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(BottomNav, {})
22858
+ ]
22820
22859
  })]
22821
22860
  });
22822
22861
  }
@@ -44584,11 +44623,11 @@ function RootComponent() {
44584
44623
  }
44585
44624
  //#endregion
44586
44625
  //#region src/routes/tokens.tsx
44587
- var $$splitComponentImporter$4 = () => import("./assets/tokens-BA4vwDat.js");
44626
+ var $$splitComponentImporter$4 = () => import("./assets/tokens-h-2A3wfD.js");
44588
44627
  var Route$4 = createFileRoute("/tokens")({ component: lazyRouteComponent($$splitComponentImporter$4, "component") });
44589
44628
  //#endregion
44590
44629
  //#region src/routes/monit.tsx
44591
- var $$splitComponentImporter$3 = () => import("./assets/monit-DIsEsDJN.js");
44630
+ var $$splitComponentImporter$3 = () => import("./assets/monit-UmcqHroX.js");
44592
44631
  var monitSearchSchema = object({ level: _enum([
44593
44632
  "all",
44594
44633
  "scaling",
@@ -44601,10 +44640,12 @@ var Route$3 = createFileRoute("/monit")({
44601
44640
  });
44602
44641
  //#endregion
44603
44642
  //#region src/routes/logs.tsx
44604
- var $$splitComponentImporter$2 = () => import("./assets/logs-CwoOsveV.js");
44643
+ var $$splitComponentImporter$2 = () => import("./assets/logs-DMOw3SjD.js");
44605
44644
  var logsSearchSchema = object({
44606
44645
  token: string().optional(),
44607
44646
  project: string().optional(),
44647
+ model: string().optional(),
44648
+ sort: string().optional(),
44608
44649
  page: number().optional().default(1)
44609
44650
  });
44610
44651
  var Route$2 = createFileRoute("/logs")({
@@ -44613,11 +44654,11 @@ var Route$2 = createFileRoute("/logs")({
44613
44654
  });
44614
44655
  //#endregion
44615
44656
  //#region src/routes/index.tsx
44616
- var $$splitComponentImporter$1 = () => import("./assets/routes-Bts-KJ9W.js");
44657
+ var $$splitComponentImporter$1 = () => import("./assets/routes-Sv_4FjtH.js");
44617
44658
  var Route$1 = createFileRoute("/")({ component: lazyRouteComponent($$splitComponentImporter$1, "component") });
44618
44659
  //#endregion
44619
44660
  //#region src/routes/requests/show.tsx
44620
- var $$splitComponentImporter = () => import("./assets/show-DJfaCNUw.js");
44661
+ var $$splitComponentImporter = () => import("./assets/show-C-oSdzHX.js");
44621
44662
  var showSearchSchema = object({ id: number() });
44622
44663
  var Route = createFileRoute("/requests/show")({
44623
44664
  validateSearch: showSearchSchema,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartanova/qgrid-cli",
3
- "version": "2.6.2",
3
+ "version": "2.6.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/cartanova-ai/qgrid"
@@ -88,6 +88,14 @@ When helping a user set up qgrid locally, check for `QGRID_PROJECT_NAME` or conf
88
88
 
89
89
  On shutdown, it stops provider dispatchers and the token subscriber.
90
90
 
91
+ ### Readiness during startup
92
+
93
+ HTTP listening opens before the dispatchers finish starting, so requests can arrive while a provider is still unavailable. On dev0 that window is 1–2 minutes: 25 OpenAI workers spawn at `SPAWN_INTERVAL_MS` apart, plus codex process init.
94
+
95
+ - `QgridDispatcher.startupState` tracks each provider as `starting` → `ready` or `failed`. A request for an unready provider throws `ServiceUnavailableException` (503, with a `Retry-After` header) while starting, and `InternalServerErrorException` (500) once initialization has failed — retrying helps in the first case and never in the second. Both used to be `QuotaError`, which told callers their tokens were exhausted and gave them nothing to act on.
96
+ - `GET /api/qgrid/health` reports `ready` plus per-provider state. A 200 alone does not mean requests will succeed; read `providers` for that.
97
+ - `handleServerError` trusts `statusCode` only on `instanceof SoException`. Sonamu's own `isSoException` is a `statusCode !== undefined` duck-type check, which would pass upstream fetch errors straight through and echo an upstream 401 as if the caller were unauthorized.
98
+
91
99
  ## OAuth callbacks
92
100
 
93
101
  - Anthropic OAuth: loopback dashboards use `/callback` on the qgrid server (base derived per request from `Origin`, falling back to `X-Forwarded-Host`/`Host`). Remote dashboards cannot use a public callback (client redirect-URI allowlist), so `oauthStart` returns `mode: "code"` with the console callback and the user pastes the shown `code#state` into the dashboard (`oauthComplete`). No env configuration either way; direct non-HTTP calls fall back to localhost and `PORT`.
@@ -208,9 +208,10 @@ When a refresh fails in a way that only re-login can fix, qgrid removes the toke
208
208
  - Detection lives at the existing refresh failure points, not a background scanner. OpenAI classifies on the error code (`refresh_token_expired`, `refresh_token_reused`, `refresh_token_invalidated`) regardless of HTTP status; Anthropic treats refresh 400/401 as auth-dead.
209
209
  - Before deactivating, qgrid re-reads the token and confirms the refresh token used by the failed attempt still matches the stored one. Anthropic refresh tokens rotate, so a late retry carrying an already-rotated token is a normal race, not a death — those are logged and skipped. `QgridFrame.refreshToken` also dedups concurrent refreshes per token.
210
210
  - Deactivation is one conditional `active=true → false` update. The process whose update affects a row is the only one that sends the Slack death notification, so a shared database produces exactly one alert per event.
211
- - The provider's last active token is never auto-deactivated. A systemic failure (client_id revocation, OAuth contract change) would otherwise empty the pool; qgrid logs an error and keeps the token instead.
212
- - The other notification is registration: a login whose account has no existing token sends one alert. Re-login replaces an existing row through the account dedup and stays silent, as does manually toggling a token back to active — neither is a new account joining the pool. When the provider response carries no account identifier, dedup is skipped entirely, so qgrid cannot tell new from returning and stays silent; it warns instead, making the unreplaced row diagnosable.
211
+ - The provider's last active token is never auto-deactivated. A systemic failure (client_id revocation, OAuth contract change) would otherwise empty the pool; qgrid logs an error, keeps the token, and sends its own Slack alert. That path leaves no state change behind, so every following request would re-alert — a per-provider 30-minute cooldown holds it to one. The cooldown is in-process, so N instances can each emit once per window.
212
+ - The other notification is registration: every completed login sends one alert, whether the account is new or replacing an existing row through the account dedup. Manually toggling a token back to active is not a registration and sends nothing.
213
213
  - Notifications are fail-open: unset `SLACK_BOT_TOKEN`/`SLACK_CHANNEL_ID` is a silent no-op, and send failures (including HTTP 200 with `ok:false`) only warn. Payloads carry an internal reason code such as `anthropic:400`, never the raw provider response body.
214
+ - A periodic digest (`expired-token-digest.ts`) re-announces tokens that are still inactive, because the one-shot death alert is lost on anyone not watching the channel at that moment and re-login only happens when a person does it. It runs every `SLACK_EXPIRY_DIGEST_INTERVAL_MINUTES` (unset or 0 disables) and sends nothing when no token is inactive — a periodic "all clear" trains people to ignore the channel. Owners come from `SLACK_USER_MAP` (`tokenName:SlackUserId`), which is explicit rather than resolved through `users.list`: Slack handles diverge from token names often enough (`haze`→`haze.lee`, `byeongjun`→`potados`) that auto-matching drops the mention silently, and a digest without the mention has no one to act on it. Shared accounts are left out of the map on purpose and appear by name only. Each instance sends its own digest — unlike deactivation there is no state change to gate on, and a few duplicates a day cost less than a locking scheme.
214
215
 
215
216
  ## Quota Threshold Semantics
216
217