fix(oracle-r2): same-origin baseUrl + refresh actually re-fetches
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled

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
+1 -2
View File
@@ -12,9 +12,8 @@ export interface ApiConfig {
// In dev, route via vite proxy (same-origin) to dodge CORS; in production
// the build is deployed at the same origin as the backend, so an empty
// baseUrl is correct.
const isDev = import.meta.env.DEV;
const DEFAULT_CONFIG: ApiConfig = {
baseUrl: import.meta.env.VITE_FM_BASE || (isDev ? "" : "https://demo.flow-master.ai"),
baseUrl: import.meta.env.VITE_FM_BASE ?? "",
email: import.meta.env.VITE_FM_EMAIL || "dev@flow-master.ai",
};
+23 -22
View File
@@ -243,29 +243,30 @@ export async function buildLiveScenariosFromApi(signal?: AbortSignal): Promise<{
const candidates = [...byDef.values()].filter((d) => d.cases.length >= 2 || (d.statuses.running ?? 0) >= 1);
candidates.sort((a, b) => b.cases.length - a.cases.length);
// For each candidate fetch graph + a couple runtimes.
interface Enriched { bucket: CandidateBucket; graph: ProcessGraph; headlineRt: RuntimeTransaction | null; recent: RuntimeTransaction[] }
const enriched: Enriched[] = [];
for (const c of candidates.slice(0, 20)) {
if (signal?.aborted) break;
const graph = await api.graph(c.key, signal);
if (!graph?.process_definition?.config?.nodes?.length) continue;
const headlineCase =
c.cases.find((w) => w.status === "running") ||
c.cases.find((w) => w.status === "waiting_for_user") ||
c.cases.find((w) => w.status === "errored" || w.status === "failed") ||
c.cases[0];
const headlineRt = headlineCase?.transaction_id ? await api.transaction(headlineCase.transaction_id, signal) : null;
const recent: RuntimeTransaction[] = [];
for (const w of c.cases.slice(0, 6)) {
if (signal?.aborted) break;
if (!w.transaction_id || w.transaction_id === headlineCase?.transaction_id) continue;
const r = await api.transaction(w.transaction_id, signal);
if (r) recent.push(r);
if (recent.length >= 3) break;
}
enriched.push({ bucket: c, graph, headlineRt, recent });
}
const TOP_N = 6;
const enrichedRaw = await Promise.all(
candidates.slice(0, TOP_N).map(async (c): Promise<Enriched | null> => {
if (signal?.aborted) return null;
const graph = await api.graph(c.key, signal);
if (!graph?.process_definition?.config?.nodes?.length) return null;
const headlineCase =
c.cases.find((w) => w.status === "running") ||
c.cases.find((w) => w.status === "waiting_for_user") ||
c.cases.find((w) => w.status === "errored" || w.status === "failed") ||
c.cases[0];
const recentCandidates = c.cases
.filter((w) => w.transaction_id && w.transaction_id !== headlineCase?.transaction_id)
.slice(0, 3);
const [headlineRt, ...recentResults] = await Promise.all([
headlineCase?.transaction_id ? api.transaction(headlineCase.transaction_id, signal) : Promise.resolve(null),
...recentCandidates.map((w) => api.transaction(w.transaction_id, signal)),
]);
const recent = recentResults.filter((r): r is RuntimeTransaction => r != null);
return { bucket: c, graph, headlineRt, recent };
}),
);
const enriched: Enriched[] = enrichedRaw.filter((e): e is Enriched => e != null);
// Classify into families.
const used = new Set<string>();