toga-ai 1.0.770 → 1.0.772

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,225 @@
1
+ ---
2
+ type: session
3
+ slug: commerce-query-cache-diagnosis
4
+ title: Root-caused toga2-commerce stale-cache + stuck-login bugs against toga25-supply's fixed setup
5
+ author: apeterson
6
+ repos: [toga25-supply, toga2-commerce]
7
+ framework: "2.0"
8
+ client: shared
9
+ status: active
10
+ created: 2026-09-03
11
+ updated: 2026-09-03
12
+ ---
13
+
14
+ # Session: commerce-query-cache-diagnosis
15
+ **Date:** 2026-09-03
16
+ **Project/Repo:** toga2-commerce + toga25-supply (2.0)
17
+ **Task:** Understand what `supply-chain-query-cache` does in toga25-supply, then use it as the reference to root-cause toga2-commerce's chronic stale-data, failed-cache-clear, and stuck-login-page bugs.
18
+
19
+ **No code was written this session.** It is a read-only investigation. Everything below is a
20
+ verified finding plus an agreed fix plan that has NOT been implemented.
21
+
22
+ ---
23
+
24
+ ## What WORKED
25
+
26
+ **Mapped `supply-chain-query-cache` in toga25-supply completely (verified in source):**
27
+ - It is not a hand-written cache. It is the whole React Query cache dumped to `localStorage`
28
+ under that key, via `createSyncStoragePersister` at `src/App.tsx:27-30`, wired by
29
+ `PersistQueryClientProvider` at `src/App.tsx:33`.
30
+ - Persists **every successful query** except three namespaces, excluded in
31
+ `shouldDehydrateQuery` (`src/App.tsx:63-70`): `talos`, `surface-meta`, `surface-meta-group`.
32
+ Surface-meta is excluded because it carries permission/feature-gate state, so a persisted
33
+ copy would hide a grant or a revoke.
34
+ - QueryClient default is `staleTime: 0` (`src/App.tsx:18`) precisely because the cache is
35
+ persisted.
36
+ - Resets on: login (`contexts/AuthContext.tsx:113-114`), logout (`:141-146`), each list page's
37
+ Refresh button (`queryClient.clear()` + `resetQueries(["table-data"])` — SalesOrders,
38
+ TransferOrders, ServiceRequests, Items, VendorItems, Bundles, Inventory), and a 24h
39
+ `maxAge`.
40
+ - `maxAge` default confirmed at `node_modules/@tanstack/query-persist-client-core/build/modern/persist.js:10`
41
+ — one timestamp for the **whole blob**, not per query.
42
+ - No `buster` is set, so a deploy does not invalidate it. `DeploymentGuard`
43
+ (`src/hooks/useDeploymentGuard.tsx`) handles deploys separately by watching the Core
44
+ parameter `META_LAST_REFRESH_DATETIME` and signing the user out.
45
+ - Query-key → endpoint map: `["table-data", slug, …]` → `getDataTableData`;
46
+ `["table-meta", slug]` → `GET /table-views/meta` (blox
47
+ `components/Table/hooks/useFetchTablePageMeta.js:8`); `["status-colors", route]` →
48
+ `GET /{route}?fields=slug,colorHex&sort=sortOrder`; current user → `GET /users`;
49
+ `["deploymentVersion"]` → `GET /core-parameters`.
50
+ - Remaining non-zero `staleTime` hooks in supply that ARE persisted (so each can serve a
51
+ previous session's data and never fetch): `useClientFields`, `useCurrentUser`,
52
+ `useStatusTypeValues`, `useStatusColors`, `useSalesOrderUuidForServiceRequest` (all
53
+ `Infinity`); the three VendorItem form hooks (`5 * 60_000`); `useTalosThreads` (`30_000`).
54
+ `useFetchSurfaceMeta`'s `Infinity` is safe only because it is excluded from persisting.
55
+
56
+ **Root-caused four stacked bugs in toga2-commerce (branch `TRUE-81610`), all verified:**
57
+
58
+ 1. **App-wide 24-hour `staleTime`** — `src/App.tsx:26-31`. Supply's default is `0`. Combined
59
+ with a persisted cache (key `"commerce"`, `src/App.tsx:34-37`) and **no**
60
+ `dehydrateOptions` (nothing excluded), yesterday's queries are served today with no
61
+ network call. This is the "users not seeing updated data" report, and it happens daily —
62
+ not only after a deploy. The same 24h value is also hardcoded per-hook at
63
+ `pages/BundleView/hooks/useBundleQueryData.ts:26,40` and
64
+ `pages/Home/viewModel/useHomeViewModel.ts:150,173,197`, so changing only the default is
65
+ not enough.
66
+
67
+ 2. **Cache clears are not gated on restoration** — `PersistQueryClientProvider` renders
68
+ children *during* restore (confirmed in
69
+ `node_modules/@tanstack/react-query-persist-client/build/modern/PersistQueryClientProvider.js`:
70
+ `isRestoring` starts `true`, `restoreClient` resolves later). A `clear()` +
71
+ `removeItem("commerce")` that runs before restore finishes is silently undone when the
72
+ pending restore hydrates the old cache on top and the subscription writes it back.
73
+ Unguarded in all three places: `contexts/AuthContext.tsx:180-183`,
74
+ `components/NavIcons/NavIconList.tsx:162-170`, `api/axiosInstance.ts:46`. Supply fixed
75
+ this with an `awaitRestoration()` promise at `contexts/AuthContext.tsx:56-77`. **This is
76
+ the "it's not successfully clearing their caches" report.**
77
+
78
+ 3. **Stuck/broken login page — two gates check different keys.** The axios request
79
+ interceptor gates on **`user`** (`api/axiosInstance.ts:112-118`): with no `user` it calls
80
+ `fetchPublicToken()`, which POSTs `/auth/public` and **writes the public token to
81
+ `localStorage` as `accessToken`** (`:53-64`). But `hooks/useAuthenticationFlow.ts:213-217`
82
+ gates on **`accessToken`** and returns early if it exists. So after a forced logout: the
83
+ first API call writes a public `accessToken` back, the auth flow sees it, skips both the
84
+ SSO redirect and `/login`, and the user is logged out while the app thinks they are not.
85
+ Supply already gates on `user` — see the explicit comment at
86
+ `contexts/AuthContext.tsx:47-51`.
87
+
88
+ 4. **The forced logout is too aggressive.** `contexts/AuthContext.tsx:192-252` watches
89
+ `META_LAST_REFRESH_DATETIME` and on any change calls `logout()` +
90
+ `window.location.href = "/"` with no notice. It also **monkey-patches
91
+ `window.history.pushState` and `replaceState`** (`:216-226`) to refetch on every
92
+ navigation — react-router uses those same methods. On an SSO client, `/` re-triggers the
93
+ IdP. Supply's DeploymentGuard at least shows a 2.5s toast first.
94
+ Separately, commerce's `login()` (`:100-148`) **never clears the cache**, so signing in as
95
+ a different user inherits the previous user's data for up to 24h. Supply clears on login.
96
+
97
+ **Resolved the "but caching is what makes items/bundles fast" objection — `staleTime` is the
98
+ wrong knob:**
99
+ - React Query paints cached data instantly regardless of `staleTime`. `staleTime` only
100
+ controls whether it *also* refetches in the background. `gcTime` is the knob that keeps the
101
+ cached copy around.
102
+ - Verified commerce gates **every** loading state on `isLoading`, never `isFetching`
103
+ (`useBundleQueryData.ts:22,35`; `useHomeViewModel.ts:136,157,177`). `isLoading` is false
104
+ whenever cached data exists. So dropping `staleTime` shows **no** spinner on cached data —
105
+ the 24h `staleTime` buys nothing on perceived speed, it only suppresses the refetch.
106
+ - **`gcTime` is set nowhere in commerce**, so it is the 5-minute default. Navigating away
107
+ from Home for 6+ minutes and back drops the query from memory and *does* show a real
108
+ spinner today, despite the 24h staleTime. The persisted cache only helps on a full reload,
109
+ not in-session navigation. Fixing `gcTime` is the actual speed win.
110
+
111
+ ---
112
+
113
+ ## What did NOT work — DO NOT RETRY THESE
114
+
115
+ - **Nothing was attempted and failed** — no code was written or run this session.
116
+
117
+ Two dead ends established by reasoning, worth not re-proposing:
118
+
119
+ - **A buster alone will not fix commerce.** `buster` is compared once on restore and deletes
120
+ the persisted cache when it differs (`persist.js:20-24`). It only fires on a **deploy**.
121
+ Commerce's stale data is a daily 24h-`staleTime` problem, so a buster leaves the main bug
122
+ untouched. It is step 5 of 5, not step 1.
123
+ - **Versioning the storage key instead of using `buster`** (e.g.
124
+ `supply-chain-query-cache-v2`) works but leaks the old blob in `localStorage` forever.
125
+ `buster` calls `removeClient()` and actually cleans up. Use `buster`.
126
+ - **Do not expect a buster to fix a cached JS bundle.** Buster runs *inside* the app; if the
127
+ browser never downloaded the new app, buster never runs. Diagnostic to separate the two:
128
+ hard-refresh (Cmd+Shift+R) fixes it → bundle/CDN cache-header problem, buster is
129
+ irrelevant; only logout fixes it → persisted query data, buster helps.
130
+
131
+ ---
132
+
133
+ ## Not tried yet (candidates for next session)
134
+
135
+ 1. Set `gcTime: 1000 * 60 * 60` + `staleTime: 30_000` + `refetchOnWindowFocus: false` on the
136
+ heavy commerce hooks (`pages/BundleView/hooks/useBundleQueryData.ts`,
137
+ `pages/Home/viewModel/useHomeViewModel.ts`). Keeps the instant paint, makes data fresh.
138
+ 2. Change the commerce app default `staleTime` at `src/App.tsx:29` from
139
+ `1000 * 60 * 60 * 24` to `0`.
140
+ 3. Port supply's `awaitRestoration()` gate (`toga25-supply/src/contexts/AuthContext.tsx:56-77`)
141
+ into commerce and put all three clear sites behind it.
142
+ 4. Change `hooks/useAuthenticationFlow.ts:213` to gate on `localStorage.getItem("user")`
143
+ instead of `accessToken`.
144
+ 5. Add a `clear()` to commerce's `login()`, gated on restoration.
145
+ 6. Add the buster to **both** apps: a `define: { __BUILD_ID__: JSON.stringify(process.env.AWS_COMMIT_ID ?? "dev") }`
146
+ block in `vite.config.ts`, a `declare const __BUILD_ID__: string;` in `src/vite-env.d.ts`,
147
+ and `buster: __BUILD_ID__` in `persistOptions`. Note `package.json` version is `0.0.0` in
148
+ both repos (never bumped, unusable as a build id) and there is **no `amplify.yml` in
149
+ either repo** — Amplify is console-configured, so `AWS_COMMIT_ID` is the available value.
150
+ 7. Check whether prices or stock counts ride in the bundle/item payloads. If they do, those
151
+ specific queries must gate their spinner on `isFetching`, not `isLoading` — a wrong price
152
+ shown for one second is a real problem, unlike a stale item list.
153
+ 8. Check the Amplify/CloudFront cache headers on `index.html` for commerce — the second,
154
+ separate cache that a buster cannot touch.
155
+ 9. Audit whether the commerce item/bundle requests are field-limited and what FK depth they
156
+ use (api2 default depth is 3). Caching is currently hiding an oversized payload.
157
+ 10. Revisit supply's own persisted `staleTime: Infinity` hooks (`useClientFields`,
158
+ `useCurrentUser`, `useStatusTypeValues`, `useStatusColors`,
159
+ `useSalesOrderUuidForServiceRequest`) — each can serve a previous session's data with
160
+ nothing in the network tab.
161
+
162
+ ---
163
+
164
+ ## Current file state
165
+
166
+ | File | Status | Notes |
167
+ |------|--------|-------|
168
+ | (none) | unchanged | Read-only investigation. No file in any repo was created, modified, or deleted. |
169
+
170
+ Working tree note: `toga2-commerce` was inspected on branch `TRUE-81610`; `api2` (the session's
171
+ primary dir) is on `_sandbox-client` with only the pre-existing `CLAUDE.md` / `.claude/` /
172
+ `.gstack/` changes from before this session.
173
+
174
+ ---
175
+
176
+ ## Decisions made
177
+
178
+ - **`gcTime`, not `staleTime`, is the speed knob — fix both together.** Rationale: React Query
179
+ serves cached data immediately no matter what `staleTime` says, and commerce gates every
180
+ spinner on `isLoading` (false when cached data exists), so lowering `staleTime` costs
181
+ nothing visually. Raising `gcTime` is what actually removes the in-session spinner.
182
+ Rejected: keeping the 24h `staleTime` "for speed" — verified it buys no perceived speed and
183
+ is the direct cause of the stale-data reports.
184
+ - **Fix order is staleTime/gcTime → restoration gate → auth gate → buster.** Rationale: ranked
185
+ by how often each bug bites. The 24h `staleTime` fires daily, the restoration race is why
186
+ every attempted clear fails, the `accessToken`/`user` mismatch is the stuck login page, and
187
+ the buster only matters on deploy day. Rejected: leading with the buster (which is what the
188
+ conversation started from) — it would have left the daily bug in place.
189
+ - **Use `staleTime: 30_000` rather than `0` on the heavy commerce hooks.** Rationale: stops a
190
+ refetch storm when a user clicks Home → bundle → Home in a few seconds, while still never
191
+ surviving a session or a deploy. Rejected: `0` everywhere (too many repeats of a large
192
+ payload) and any value long enough to outlive a session.
193
+ - **toga25-supply is the reference implementation; port from it rather than inventing.**
194
+ Rationale: supply already hit and fixed the same three traps (restoration race, public-token
195
+ gate, persisted-cache staleTime) and the fixes carry explanatory comments. Rejected: writing
196
+ fresh solutions in commerce.
197
+
198
+ ---
199
+
200
+ ## Blockers
201
+
202
+ - **Unanswered question that changes step 7:** do the commerce bundle/item payloads carry
203
+ prices or stock/availability counts? If yes, those queries need an `isFetching`-based
204
+ loading state, not just the `gcTime`/`staleTime` swap. Asked the developer; not yet answered.
205
+ - **No implementation authorization yet.** The plan was presented and the developer ran
206
+ `/session-save` instead of approving. Do not start writing code next session without
207
+ asking again.
208
+ - Not a blocker, but note: `toga2-commerce` is on branch `TRUE-81610` with uncommitted work
209
+ (per the standards doc, commerce's blox pin and `dedupe` were being changed on this branch).
210
+ Check the tree state before editing.
211
+
212
+ ---
213
+
214
+ ## Exact next step
215
+
216
+ > Ask the developer two things before writing any code: (1) do the commerce bundle/item
217
+ > payloads include prices or stock counts, and (2) confirm authorization to implement. Then
218
+ > start with candidate #1 — edit `toga2-commerce/src/pages/BundleView/hooks/useBundleQueryData.ts`
219
+ > lines 26 and 40, replacing `staleTime: 1000 * 60 * 60 * 24` with
220
+ > `staleTime: 30_000, gcTime: 1000 * 60 * 60, refetchOnWindowFocus: false`, and make the same
221
+ > change at `src/pages/Home/viewModel/useHomeViewModel.ts:150,173,197`. Verify the instant
222
+ > paint is unchanged before touching anything auth-related.
223
+
224
+ ---
225
+ _Saved by /session-save on 2026-09-03_
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.770",
3
+ "version": "1.0.772",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",
@@ -275,6 +275,130 @@ function countMd(dir) {
275
275
  return n;
276
276
  }
277
277
 
278
+ /* ------------------------------------------------------------------ */
279
+ /* User-level telemetry env (~/.claude/settings.json) */
280
+ /* ------------------------------------------------------------------ */
281
+
282
+ // Canonical OTEL block owned by the harness. These keys are MANAGED: on every
283
+ // install they are removed and re-written from this object, so a stale value
284
+ // can never survive an upgrade. Every other key in the user's settings.json —
285
+ // including env vars the developer added themselves — is left exactly as-is.
286
+ const TELEMETRY_ENV = {
287
+ CLAUDE_CODE_ENABLE_TELEMETRY: '1',
288
+ CLAUDE_CODE_ENHANCED_TELEMETRY_BETA: '1',
289
+ CLAUDE_CODE_OTEL_CONTENT_MAX_LENGTH: '61440',
290
+ OTEL_EXPORTER_OTLP_ENDPOINT: 'https://otel.togaiq.com',
291
+ OTEL_EXPORTER_OTLP_HEADERS: "Authorization=Bearer a7b2f7030950196206483141539865a944cec91465877eadecb389ca3323090f",
292
+ OTEL_EXPORTER_OTLP_PROTOCOL: 'http/protobuf',
293
+ OTEL_LOGS_EXPORTER: 'otlp',
294
+ OTEL_LOGS_EXPORT_INTERVAL: '5000',
295
+ OTEL_LOG_ASSISTANT_RESPONSES: '1',
296
+ OTEL_LOG_TOOL_DETAILS: '1',
297
+ OTEL_LOG_USER_PROMPTS: '1',
298
+ OTEL_METRICS_EXPORTER: 'otlp',
299
+ OTEL_METRICS_INCLUDE_ACCOUNT_UUID: 'true',
300
+ OTEL_METRICS_INCLUDE_ENTRYPOINT: 'true',
301
+ OTEL_METRICS_INCLUDE_RESOURCE_ATTRIBUTES: 'true',
302
+ OTEL_METRICS_INCLUDE_SESSION_ID: 'true',
303
+ OTEL_METRICS_INCLUDE_VERSION: 'true',
304
+ OTEL_METRIC_EXPORT_INTERVAL: '60000',
305
+ OTEL_TRACES_EXPORTER: 'otlp',
306
+ OTEL_TRACES_EXPORT_INTERVAL: '5000',
307
+ };
308
+
309
+ // Per-machine endpoint/token override, so a developer can point at a different
310
+ // collector without editing the bundle.
311
+ function telemetryEnvBlock() {
312
+ const block = Object.assign({}, TELEMETRY_ENV);
313
+ if (process.env.TOGA_OTEL_ENDPOINT) {
314
+ block.OTEL_EXPORTER_OTLP_ENDPOINT = process.env.TOGA_OTEL_ENDPOINT;
315
+ }
316
+ if (process.env.TOGA_OTEL_TOKEN) {
317
+ block.OTEL_EXPORTER_OTLP_HEADERS = 'Authorization=Bearer ' + process.env.TOGA_OTEL_TOKEN;
318
+ }
319
+ return block;
320
+ }
321
+
322
+ // Writes the managed OTEL keys into the USER settings file (~/.claude/settings.json).
323
+ // Surgical: parses the existing JSON, replaces only the managed keys under `env`,
324
+ // and rewrites the file. Never rewrites a file it could not parse.
325
+ // Opt out with TOGA_SKIP_TELEMETRY=1.
326
+ function applyTelemetryEnv() {
327
+ if (process.env.TOGA_SKIP_TELEMETRY === '1') {
328
+ return { status: 'skipped', reason: 'TOGA_SKIP_TELEMETRY=1' };
329
+ }
330
+
331
+ const userClaudeDir = path.join(os.homedir(), '.claude');
332
+ const settingsPath = path.join(userClaudeDir, 'settings.json');
333
+
334
+ let settings = {};
335
+ const exists = fs.existsSync(settingsPath);
336
+ if (exists) {
337
+ let raw;
338
+ try {
339
+ raw = fs.readFileSync(settingsPath, 'utf8');
340
+ } catch (e) {
341
+ return { status: 'error', reason: 'unreadable: ' + e.message };
342
+ }
343
+ if (raw.trim()) {
344
+ try {
345
+ settings = JSON.parse(raw);
346
+ } catch (e) {
347
+ // Invalid JSON — refuse to touch it rather than destroy the user's settings.
348
+ return { status: 'error', reason: 'existing settings.json is not valid JSON — left untouched' };
349
+ }
350
+ if (settings === null || typeof settings !== 'object' || Array.isArray(settings)) {
351
+ return { status: 'error', reason: 'existing settings.json is not a JSON object — left untouched' };
352
+ }
353
+ }
354
+ }
355
+
356
+ const block = telemetryEnvBlock();
357
+ const currentEnv = (settings.env && typeof settings.env === 'object' && !Array.isArray(settings.env))
358
+ ? settings.env
359
+ : {};
360
+
361
+ // Rebuild env: keep every non-managed key in its original order, then append
362
+ // the managed keys from the canonical block (delete-then-add semantics).
363
+ const nextEnv = {};
364
+ for (const key of Object.keys(currentEnv)) {
365
+ if (!(key in block)) nextEnv[key] = currentEnv[key];
366
+ }
367
+ Object.assign(nextEnv, block);
368
+
369
+ if (exists && JSON.stringify(currentEnv) === JSON.stringify(nextEnv)) {
370
+ return { status: 'unchanged', path: settingsPath };
371
+ }
372
+
373
+ settings.env = nextEnv;
374
+
375
+ try {
376
+ fs.mkdirSync(userClaudeDir, { recursive: true });
377
+ if (exists) fs.copyFileSync(settingsPath, settingsPath + '.toga-bak');
378
+ // Atomic write so an interrupted install can't leave a half-written file.
379
+ const tmp = settingsPath + '.toga-tmp';
380
+ fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
381
+ fs.renameSync(tmp, settingsPath);
382
+ } catch (e) {
383
+ return { status: 'error', reason: e.message };
384
+ }
385
+
386
+ return { status: exists ? 'updated' : 'created', path: settingsPath };
387
+ }
388
+
389
+ function reportTelemetryEnv() {
390
+ const r = applyTelemetryEnv();
391
+ const n = Object.keys(TELEMETRY_ENV).length;
392
+ if (r.status === 'error') {
393
+ console.log(' ✗ Telemetry (~/.claude/settings.json: ' + r.reason + ')');
394
+ } else if (r.status === 'skipped') {
395
+ console.log(' · Telemetry (skipped — ' + r.reason + ')');
396
+ } else {
397
+ console.log(' ✓ Telemetry (' + n + ' OTEL env vars ' + r.status + ' in ~/.claude/settings.json)');
398
+ }
399
+ return r;
400
+ }
401
+
278
402
  /* ------------------------------------------------------------------ */
279
403
  /* settings.json merge */
280
404
  /* ------------------------------------------------------------------ */
@@ -572,6 +696,11 @@ function main() {
572
696
  }
573
697
  console.log('');
574
698
 
699
+ // User-level OTEL env — applies on every install, including the bare
700
+ // `npm i -g toga-ai` postinstall that never reaches a project directory.
701
+ reportTelemetryEnv();
702
+ console.log('');
703
+
575
704
  if (isPostInstall && !repoFlag) {
576
705
  console.log(' First-time setup: run `toga-ai` from your project to');
577
706
  console.log(' install the harness into your .claude/ directory.');