@cartanova/qgrid-cli 2.6.1 → 2.6.4

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 (81) hide show
  1. package/README.ko.md +2 -0
  2. package/README.md +2 -0
  3. package/bundle/dist/application/qgrid/oauth.js +21 -3
  4. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +40 -6
  5. package/bundle/dist/application/qgrid/qgrid.frame.js +47 -19
  6. package/bundle/dist/application/qgrid/qgrid.types.js +13 -3
  7. package/bundle/dist/application/qgrid/token-death.js +77 -0
  8. package/bundle/dist/application/queries.generated.js +3 -2
  9. package/bundle/dist/application/request-log/request-log.model.js +105 -31
  10. package/bundle/dist/application/request-log-step/request-log-step.model.js +1 -1
  11. package/bundle/dist/application/sonamu.generated.js +60 -5
  12. package/bundle/dist/application/sonamu.generated.sso.js +18 -2
  13. package/bundle/dist/application/token/token.model.js +53 -2
  14. package/bundle/dist/i18n/en.js +3 -1
  15. package/bundle/dist/i18n/ko.js +3 -1
  16. package/bundle/dist/i18n/sd.generated.js +18 -1
  17. package/bundle/dist/migrations/20260807133553_manual__backfill_request_log_cost.js +49 -0
  18. package/bundle/dist/migrations/20260807145144_alter_request_logs_alter1.js +13 -0
  19. package/bundle/dist/migrations/20260807164057_alter_request_logs_alter1.js +13 -0
  20. package/bundle/dist/server-error-handler.js +5 -3
  21. package/bundle/dist/sonamu.config.js +5 -1
  22. package/bundle/dist/utils/providers/openai/openai-refresh.js +12 -1
  23. package/bundle/dist/utils/slack-notify.js +65 -0
  24. package/bundle/src/application/qgrid/oauth.ts +20 -1
  25. package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +15 -3
  26. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +30 -2
  27. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +44 -6
  28. package/bundle/src/application/qgrid/qgrid.frame.ts +67 -47
  29. package/bundle/src/application/qgrid/qgrid.types.ts +10 -0
  30. package/bundle/src/application/qgrid/token-death.test.ts +150 -0
  31. package/bundle/src/application/qgrid/token-death.ts +96 -0
  32. package/bundle/src/application/queries.generated.ts +5 -2
  33. package/bundle/src/application/request-log/request-log.entity.json +46 -7
  34. package/bundle/src/application/request-log/request-log.model.test.ts +153 -42
  35. package/bundle/src/application/request-log/request-log.model.ts +136 -50
  36. package/bundle/src/application/sonamu.generated.http +19 -1
  37. package/bundle/src/application/sonamu.generated.sso.ts +16 -0
  38. package/bundle/src/application/sonamu.generated.ts +58 -3
  39. package/bundle/src/application/token/token.model.test.ts +49 -0
  40. package/bundle/src/application/token/token.model.ts +75 -0
  41. package/bundle/src/i18n/en.ts +3 -0
  42. package/bundle/src/i18n/ko.ts +3 -0
  43. package/bundle/src/i18n/sd.generated.ts +17 -0
  44. package/bundle/src/migrations/20260807133553_manual__backfill_request_log_cost.ts +110 -0
  45. package/bundle/src/migrations/20260807145144_alter_request_logs_alter1.ts +13 -0
  46. package/bundle/src/migrations/20260807164057_alter_request_logs_alter1.ts +13 -0
  47. package/bundle/src/server-error-handler.test.ts +58 -0
  48. package/bundle/src/server-error-handler.ts +16 -5
  49. package/bundle/src/sonamu.config.ts +6 -0
  50. package/bundle/src/utils/providers/openai/openai-refresh.ts +17 -0
  51. package/bundle/src/utils/slack-notify.test.ts +97 -0
  52. package/bundle/src/utils/slack-notify.ts +74 -0
  53. package/bundle/web-dist/client/assets/index-BGQSGMcK.css +1 -0
  54. package/bundle/web-dist/client/assets/index-CnNT0mdq.js +78 -0
  55. package/bundle/web-dist/client/assets/logs-DCY81UWb.js +1 -0
  56. package/bundle/web-dist/client/assets/{monit-CyqMmy1G.js → monit-D5OoAday.js} +1 -1
  57. package/bundle/web-dist/client/assets/routes-B4D9C86u.js +5 -0
  58. package/bundle/web-dist/client/assets/{sd.generated-CjW3rfJ8.js → sd.generated-DYdwSB2I.js} +1 -1
  59. package/bundle/web-dist/client/assets/{services.generated-BuT6Gp8V.js → services.generated-BsAvotjG.js} +1 -1
  60. package/bundle/web-dist/client/assets/{show-C5WxfoAt.js → show-BSX8ii7Z.js} +3 -3
  61. package/bundle/web-dist/client/assets/tokens-Gapk41cz.js +1 -0
  62. package/bundle/web-dist/client/assets/{use-update-token-mutation-DBkxL29Y.js → use-update-token-mutation-DqEpK7It.js} +1 -1
  63. package/bundle/web-dist/client/index.html +3 -3
  64. package/bundle/web-dist/server/assets/{lazyRouteComponent-PRxXWW3t.js → lazyRouteComponent-BaZaveNc.js} +1 -1
  65. package/bundle/web-dist/server/assets/{logs-D0z1Ghj1.js → logs-DMOw3SjD.js} +91 -42
  66. package/bundle/web-dist/server/assets/{monit-dBXxnAyI.js → monit-UmcqHroX.js} +2 -2
  67. package/bundle/web-dist/server/assets/{routes-B3p7fuwk.js → routes-Sv_4FjtH.js} +52 -46
  68. package/bundle/web-dist/server/assets/{sd.generated-ek-tz-7H.js → sd.generated-B0xqnHEo.js} +21 -0
  69. package/bundle/web-dist/server/assets/{services.generated-BNuxWs7K.js → services.generated-DqAKskAS.js} +23 -8
  70. package/bundle/web-dist/server/assets/{show-0iNioJ2I.js → show-C-oSdzHX.js} +31 -25
  71. package/bundle/web-dist/server/assets/{tokens-CvytQ9Xy.js → tokens-h-2A3wfD.js} +292 -191
  72. package/bundle/web-dist/server/assets/{use-update-token-mutation-BBCC2GKM.js → use-update-token-mutation-DHCaa7Ku.js} +1 -1
  73. package/bundle/web-dist/server/entry-server.generated.js +70 -29
  74. package/package.json +1 -1
  75. package/skills/qgrid/references/cli-env-and-server-boot.md +8 -0
  76. package/skills/qgrid/references/token-auth-quota-lifecycle.md +11 -0
  77. package/bundle/web-dist/client/assets/index-BGGBFmzE.js +0 -78
  78. package/bundle/web-dist/client/assets/index-GA9b-zy4.css +0 -1
  79. package/bundle/web-dist/client/assets/logs-Dh19u6Tk.js +0 -1
  80. package/bundle/web-dist/client/assets/routes-Cy0gCq8c.js +0 -5
  81. package/bundle/web-dist/client/assets/tokens-Ciolqw4l.js +0 -1
@@ -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.1";
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.4";
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-CvytQ9Xy.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-dBXxnAyI.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-D0z1Ghj1.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-B3p7fuwk.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-0iNioJ2I.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.1",
3
+ "version": "2.6.4",
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`.
@@ -201,6 +201,17 @@ OpenAI token changes can create, update, deactivate, or kill persistent Codex wo
201
201
 
202
202
  Anthropic token changes only affect the in-memory token pool because every request fresh-spawns Claude Code.
203
203
 
204
+ ## Auth-Dead Detection And Token Registration
205
+
206
+ When a refresh fails in a way that only re-login can fix, qgrid removes the token from routing instead of letting every subsequent request 401.
207
+
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
+ - 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
+ - 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, 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
+ - 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
+
204
215
  ## Quota Threshold Semantics
205
216
 
206
217
  `quota_threshold` is a routing gate, not a hard external-provider guarantee.