fix(oracle-r2): same-origin baseUrl + refresh actually re-fetches

Oracle round-2 review caught two real bugs:

1. Production baseUrl bypassed the nginx /api proxy
   - api.ts defaulted to https://demo.flow-master.ai in prod
   - Browser would hit cross-origin and CORS-fail
   - Now: baseUrl='' everywhere; nginx.conf already reverse-proxies /api/*
   - vite.config.ts proxy still handles dev

2. Refresh button didn't refresh
   - setMode('live') early-returned when already in live mode
   - Now: setMode() and refreshLive() share runLiveFetch(); refreshLive
     ignores the same-mode guard and always re-runs
   - 4 new vitest regressions in state/store.test.ts cover the contract
   - Smoke now asserts /api/ea2/work-items is called twice after Refresh

Also:
- buildScenarios.ts parallelized: cap N=6 candidates, Promise.all per-
  candidate fetches → live mode now ~3s instead of 30s
- CommandBar + LeftRail preview toasts now name the exact endpoint
  (/api/runtime/transactions/{id}/actions) in the visible text
- Landing 'Go live' button rebound to refreshLive() when already live;
  copy changed to 'Live · refresh'
- README: scenario table now renders (added separator row); deploy
  section points at the real ops PR + the actual overlay path
  (overlays/demo, not overlays/mc.flow-master.ai); CORS doc clarifies
  same-origin requirement

Constraint: browsers reject cross-origin → same-origin /api/* required
Rejected: dev/prod baseUrl divergence | created production bug
Confidence: high
Scope-risk: narrow
Not-tested: production image actually built + served by ops PR (gated by trusted updater + DNS)
This commit is contained in:
2026-06-14 00:26:38 +04:00
parent 2b83e3ad0e
commit e3b4ed62c0
10 changed files with 237 additions and 81 deletions
+41 -21
View File
@@ -28,6 +28,8 @@ interface AppState {
/** snapshot = bundled scenarios.json; live = in-browser API client */
mode: DataMode;
setMode: (m: DataMode) => Promise<void>;
/** Force re-fetch of live scenarios. No-op in snapshot mode. */
refreshLive: () => Promise<void>;
liveLoading: boolean;
liveError: string | null;
@@ -69,38 +71,56 @@ const initialScenario = SNAPSHOT_SCENARIOS[0];
let toastSeq = 0;
async function runLiveFetch(
set: (partial: Partial<AppState>) => void,
get: () => AppState,
) {
const prevMode = get().mode;
set({ mode: "live", liveLoading: true, liveError: null });
try {
const ping = await api.ping();
if (!ping.ok) throw new Error(ping.reason || "backend unreachable");
const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi();
const merged = [...scenarios, ...syntheticScenarios];
const first = merged[0];
const keepCurrent = get().scenarios.some((s) => s.id === get().scenarioId);
set({
scenarios: merged,
liveTotals: { workItems: workItems.length, distinctDefs },
liveFetchedAt: Date.now(),
liveLoading: false,
scenarioId: keepCurrent ? get().scenarioId : first?.id ?? get().scenarioId,
selectedStepId: keepCurrent ? get().selectedStepId : first?.defaultStepId ?? null,
});
get().pushToast(
"ok",
prevMode === "live"
? `Refreshed · ${scenarios.length} live + ${syntheticScenarios.length} blueprint scenarios`
: `Live mode · ${scenarios.length} live + ${syntheticScenarios.length} blueprint scenarios`,
);
} catch (e) {
set({ liveLoading: false, liveError: (e as Error).message, mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS });
get().pushToast("err", `Live mode failed: ${(e as Error).message.slice(0, 80)} — falling back to snapshot`);
}
}
export const useApp = create<AppState>((set, get) => ({
scene: "landing",
setScene: (scene) => set({ scene }),
mode: "snapshot",
setMode: async (mode) => {
if (mode === get().mode) return;
if (mode === "snapshot") {
if (get().mode === "snapshot") return;
set({ mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS, liveError: null, liveLoading: false });
get().pushToast("info", "Switched to snapshot mode (bundled JSON)");
return;
}
set({ mode: "live", liveLoading: true, liveError: null });
try {
const ping = await api.ping();
if (!ping.ok) throw new Error(ping.reason || "backend unreachable");
const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi();
const merged = [...scenarios, ...syntheticScenarios];
const first = merged[0];
set({
scenarios: merged,
liveTotals: { workItems: workItems.length, distinctDefs },
liveFetchedAt: Date.now(),
liveLoading: false,
scenarioId: first?.id ?? get().scenarioId,
selectedStepId: first?.defaultStepId ?? null,
});
get().pushToast("ok", `Live mode · ${scenarios.length} live + ${syntheticScenarios.length} blueprint scenarios`);
} catch (e) {
set({ liveLoading: false, liveError: (e as Error).message, mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS });
get().pushToast("err", `Live mode failed: ${(e as Error).message.slice(0, 80)} — falling back to snapshot`);
}
await runLiveFetch(set, get);
},
refreshLive: async () => {
if (get().mode !== "live") return;
await runLiveFetch(set, get);
},
liveLoading: false,