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
+2 -1
View File
@@ -19,6 +19,7 @@ export default function App() {
const tourActive = useApp((s) => s.tour.active);
const mode = useApp((s) => s.mode);
const setMode = useApp((s) => s.setMode);
const refreshLive = useApp((s) => s.refreshLive);
const liveLoading = useApp((s) => s.liveLoading);
const liveFetchedAt = useApp((s) => s.liveFetchedAt);
@@ -80,7 +81,7 @@ export default function App() {
{mode === "live" && liveAge && <span className="topbar-age">{liveAge}</span>}
</button>
{mode === "live" && (
<button className="link-btn" onClick={() => setMode("live")} disabled={liveLoading} title="Re-fetch live scenarios">
<button className="link-btn" onClick={refreshLive} disabled={liveLoading} title="Re-fetch live scenarios">
<Refresh size={12} /> Refresh
</button>
)}
+13 -5
View File
@@ -17,12 +17,13 @@ export default function CommandBar() {
const scenarios = useApp((s) => s.scenarios);
const mode = useApp((s) => s.mode);
const setMode = useApp((s) => s.setMode);
const refreshLive = useApp((s) => s.refreshLive);
const pushToast = useApp((s) => s.pushToast);
const sc = scenarioById(scenarioId);
const previewAction = (label: string) => {
pushToast("info", `${label} is preview-only in this demo. Wire to /api/runtime/transactions to make it real.`);
pushToast("info", `${label} is preview-only. Wire to POST /api/runtime/transactions/{id}/actions to make it real.`);
};
useEffect(() => {
@@ -112,10 +113,17 @@ export default function CommandBar() {
)}
<Command.Group heading="Data mode">
<Command.Item onSelect={() => { setMode("live"); close(); }}>
<Refresh size={13} /> {mode === "live" ? "Refresh live scenarios" : "Switch to LIVE mode (fetch demo.flow-master.ai)"}
<span className="cmd-hint">{mode === "live" ? "re-fetch" : "in-browser"}</span>
</Command.Item>
{mode === "live" ? (
<Command.Item onSelect={() => { refreshLive(); close(); }}>
<Refresh size={13} /> Refresh live scenarios
<span className="cmd-hint">re-fetch demo.flow-master.ai</span>
</Command.Item>
) : (
<Command.Item onSelect={() => { setMode("live"); close(); }}>
<Refresh size={13} /> Switch to LIVE mode (fetch demo.flow-master.ai)
<span className="cmd-hint">in-browser</span>
</Command.Item>
)}
<Command.Item onSelect={() => { setMode("snapshot"); close(); }}>
<Layers size={13} /> Switch to SNAPSHOT mode
<span className="cmd-hint">bundled JSON</span>
+3 -3
View File
@@ -76,14 +76,14 @@ export default function LeftRail() {
<div className="agent-acts">
<button
className="btn btn-primary"
onClick={() => useApp.getState().pushToast("info", `Confirm "${a.intent.slice(0, 50)}…" is preview-only.`)}
onClick={() => useApp.getState().pushToast("info", `Confirm "${a.intent.slice(0, 40)}…" is preview-only. Wire to POST /api/runtime/transactions/{id}/actions to ship.`)}
title="Preview-only — wire to /api/runtime/transactions/{id}/actions to ship"
>
<Check size={12} /> Confirm <span className="preview-marker">preview</span>
</button>
<button
className="btn btn-ghost"
onClick={() => useApp.getState().pushToast("info", `Reject is preview-only.`)}
className="btn btn-ghost btn-decline"
onClick={() => useApp.getState().pushToast("info", `Reject is preview-only. Wire to POST /api/runtime/transactions/{id}/actions to ship.`)}
title="Preview-only — wire to /api/runtime/transactions/{id}/actions to ship"
>
<Close size={12} /> Reject
+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>();
+4 -3
View File
@@ -12,6 +12,7 @@ export default function Landing() {
const scenarios = useApp((s) => s.scenarios);
const mode = useApp((s) => s.mode);
const setMode = useApp((s) => s.setMode);
const refreshLive = useApp((s) => s.refreshLive);
const liveLoading = useApp((s) => s.liveLoading);
const liveTotals = useApp((s) => s.liveTotals);
@@ -61,12 +62,12 @@ export default function Landing() {
</button>
<button
className={`btn btn-ghost btn-lg${mode === "live" ? " is-on" : ""}`}
onClick={() => setMode(mode === "live" ? "snapshot" : "live")}
onClick={() => mode === "live" ? refreshLive() : setMode("live")}
disabled={liveLoading}
title="Toggle between bundled snapshot and a live in-browser fetch from demo.flow-master.ai"
title={mode === "live" ? "Re-fetch live scenarios from demo.flow-master.ai" : "Toggle between bundled snapshot and a live in-browser fetch"}
>
{liveLoading ? <span className="spin" /> : <Pulse size={13} />}
{mode === "live" ? "Live mode · on" : "Go live"}
{mode === "live" ? "Live · refresh" : "Go live"}
</button>
</div>
+77
View File
@@ -0,0 +1,77 @@
// Regression: refreshLive() must re-fetch when already in live mode,
// and setMode("live") repeated must not early-return.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { useApp } from "./store";
function stubFetch() {
const calls: string[] = [];
const store = new Map<string, string>();
globalThis.sessionStorage = {
getItem: (k) => store.get(k) ?? null,
setItem: (k, v) => { store.set(k, String(v)); },
removeItem: (k) => { store.delete(k); },
clear: () => store.clear(),
key: () => null,
length: 0,
} as Storage;
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input.toString();
calls.push(url);
if (url.endsWith("/api/v1/auth/dev-login")) {
return new Response(JSON.stringify({ access_token: "T" }), { status: 200 });
}
if (url.endsWith("/api/v1/auth/me")) {
return new Response(JSON.stringify({ user_id: "u", tenant_id: "t", email: "dev@flow-master.ai" }), { status: 200 });
}
if (url.includes("/api/ea2/work-items")) {
return new Response(JSON.stringify({ items: [] }), { status: 200 });
}
return new Response("not stubbed", { status: 404 });
}) as unknown as typeof fetch;
return calls;
}
beforeEach(() => {
useApp.setState({ mode: "snapshot", liveLoading: false, liveError: null, liveFetchedAt: null });
});
describe("store live-mode + refresh", () => {
it("setMode('live') performs a network fetch", async () => {
const calls = stubFetch();
await useApp.getState().setMode("live");
const meCalls = calls.filter((u) => u.endsWith("/api/v1/auth/me")).length;
const wiCalls = calls.filter((u) => u.includes("/api/ea2/work-items")).length;
expect(meCalls).toBeGreaterThanOrEqual(1);
expect(wiCalls).toBeGreaterThanOrEqual(1);
expect(useApp.getState().mode).toBe("live");
expect(useApp.getState().liveFetchedAt).toBeTruthy();
});
it("refreshLive() triggers a second fetch and bumps liveFetchedAt", async () => {
const calls = stubFetch();
await useApp.getState().setMode("live");
const initialFetched = useApp.getState().liveFetchedAt!;
const initialWi = calls.filter((u) => u.includes("/api/ea2/work-items")).length;
await new Promise((r) => setTimeout(r, 5));
await useApp.getState().refreshLive();
const afterWi = calls.filter((u) => u.includes("/api/ea2/work-items")).length;
expect(afterWi).toBeGreaterThan(initialWi);
expect(useApp.getState().liveFetchedAt!).toBeGreaterThanOrEqual(initialFetched);
});
it("refreshLive() in snapshot mode is a no-op (does NOT fetch)", async () => {
const calls = stubFetch();
await useApp.getState().refreshLive();
expect(calls.length).toBe(0);
expect(useApp.getState().mode).toBe("snapshot");
});
it("setMode('snapshot') when already snapshot does not re-toast", async () => {
stubFetch();
const before = useApp.getState().toasts.length;
await useApp.getState().setMode("snapshot");
expect(useApp.getState().toasts.length).toBe(before);
});
});
+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,