fix(oracle-r2): same-origin baseUrl + refresh actually re-fetches
Some checks failed
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:
Shad 2026-06-14 00:26:38 +04:00
parent 2b83e3ad0e
commit e3b4ed62c0
10 changed files with 237 additions and 81 deletions

View File

@ -31,15 +31,15 @@ on Gitea.
## Scenarios in the catalog ## Scenarios in the catalog
| id | mode | source | | id | mode | source |
| ----------- | --------- | ------------------------------------------------------------------- | |-------------|-----------|-------------------------------------------------------------------|
| procurement | live | Purchase Requisition → PO (`pr_to_po_def` on demo.flow-master.ai) | | procurement | live | Purchase Requisition → PO (`pr_to_po_def` on demo.flow-master.ai) |
| extra-1 | live | Atlas F1 Fresh (procurement variant) | | extra-1 | live | Atlas F1 Fresh (procurement variant) |
| extra-2 | live | Atlas F1 Fresh (procurement variant) | | extra-2 | live | Atlas F1 Fresh (procurement variant) |
| ar | blueprint | AR · Customer Refund Approval | | ar | blueprint | AR · Customer Refund Approval |
| hcm | blueprint | HCM · New Hire Onboarding | | hcm | blueprint | HCM · New Hire Onboarding |
| gl | blueprint | GL · Period-End Close | | gl | blueprint | GL · Period-End Close |
| service | blueprint | Service Ops · Customer Incident | | service | blueprint | Service Ops · Customer Incident |
**Live** = backed by a real EA2 process definition currently in the demo **Live** = backed by a real EA2 process definition currently in the demo
backend, with real runtime transactions and a real work-item queue. backend, with real runtime transactions and a real work-item queue.
@ -68,15 +68,20 @@ prior version. Safeguards now in place:
## Live-mode mechanics (the CORS gotcha) ## Live-mode mechanics (the CORS gotcha)
`demo.flow-master.ai` does not advertise CORS headers for arbitrary origins. `demo.flow-master.ai` does not advertise CORS headers for arbitrary origins.
`src/lib/api.ts` therefore uses an **empty `baseUrl` everywhere** (both dev
and prod). All `/api/*` requests are same-origin from the browser's
perspective; whatever is serving the page is responsible for proxying them
to the backend.
- In **dev** (`pnpm dev`): Vite proxies `/api/*` to - **Dev** (`pnpm dev`): `vite.config.ts` proxies `/api/*` to
`${VITE_FM_BASE:-https://demo.flow-master.ai}` (see `vite.config.ts`). The `${VITE_FM_BASE:-https://demo.flow-master.ai}`.
browser sees a same-origin request, no CORS check. `src/lib/api.ts` uses an - **Prod** (Docker image): the bundled `nginx.conf` reverse-proxies `/api/*`
empty `baseUrl` in dev for this. to `https://demo.flow-master.ai`. The image is intended to sit behind the
- In **production**: deploy the build at the same origin as the backend (or `mc.flow-master.ai` ingress (see `FM06/flowmaster-ops` overlay).
behind a reverse proxy that passes `/api/*` through). Cross-origin - **Anywhere else**: set `VITE_FM_BASE=https://your-backend` at build time
deployments fail gracefully — `setMode("live")` catches the error, surfaces and accept that browsers will reject the cross-origin call. Live mode then
it in the topbar banner and a toast, and falls back to snapshot mode. fails gracefully — `setMode("live")` catches the error, raises an
`mc-banner-err` banner + error toast, and falls back to snapshot.
## Run, test, build ## Run, test, build
@ -129,15 +134,25 @@ fetch_scenarios.mjs # Node script to refresh src/scenarios.json
## Deploy ## Deploy
Production deployment is tracked in `FM06/flowmaster-ops` under Production deployment is tracked in
`manifests/overlays/mc.flow-master.ai/`. A static nginx serves `dist/`; [FM06/flowmaster-ops PR #1164](https://gitea.flow-master.ai/FM06/flowmaster-ops/pulls/1164),
ingress fronts it at `mc.flow-master.ai`. `/api/*` is reverse-proxied to the which adds three resources to `manifests/overlays/demo/`:
same backend `demo.flow-master.ai` talks to — that's what makes live mode
work in production without CORS. - `mc-deployment.yaml` — 2-replica nginx Deployment in the `demo` namespace
- `mc-service.yaml` — ClusterIP service on port 80
- `mc-ingress.yaml` — Traefik ingress at `mc.flow-master.ai` with cert-manager DNS-01 cert
**Status:** the PR is open and mergeable. The live URL `https://mc.flow-master.ai` is **not yet serving** — two pre-merge action items remain for the trusted updater:
1. Cloudflare A record `mc.flow-master.ai → 65.21.71.186, 91.98.159.56` (same as `hakeem.flow-master.ai`).
2. Gitea Actions must have published the first image tag (`gitea.flow-master.ai/shad/mission-control-demo:sha-…`) — `mc-deployment.yaml` pins that explicit SHA, not `:latest`.
Until the PR merges + DNS is added, the artifact is this repo + the open PR.
```bash ```bash
pnpm build pnpm build
# dist/ is the static site → ship to nginx referenced by the ops overlay # dist/ is the static site. The Dockerfile bakes it into a tiny nginx
# image. CI in .gitea/workflows/build.yml builds + publishes on push to main.
``` ```
## What's intentionally not here ## What's intentionally not here

View File

@ -114,7 +114,11 @@ await page.waitForTimeout(450);
logOk("tour advances", (await page.locator(".tour-title").first().innerText()).length > 0); logOk("tour advances", (await page.locator(".tour-title").first().innerText()).length > 0);
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
// Live-mode toggle (real network call to demo.flow-master.ai) // Live-mode toggle (real network call to demo.flow-master.ai via vite proxy)
const networkCalls = [];
page.on("request", (req) => {
if (req.url().includes("/api/ea2/work-items")) networkCalls.push(req.url());
});
const toggleBtn = page.locator(".mode-toggle").first(); const toggleBtn = page.locator(".mode-toggle").first();
await toggleBtn.click(); await toggleBtn.click();
const liveOk = await page.waitForFunction( const liveOk = await page.waitForFunction(
@ -126,6 +130,36 @@ if (liveOk) {
await page.waitForTimeout(400); await page.waitForTimeout(400);
await page.screenshot({ path: `${OUT}/09-live-mode.png` }); await page.screenshot({ path: `${OUT}/09-live-mode.png` });
logOk("Refresh button appears in live mode", await page.locator(".link-btn", { hasText: "Refresh" }).isVisible()); logOk("Refresh button appears in live mode", await page.locator(".link-btn", { hasText: "Refresh" }).isVisible());
// Refresh must trigger ANOTHER /api/ea2/work-items request (Oracle round 2 fix).
// Wait for the Refresh button to become enabled (initial fetch may still be settling).
await page.locator(".link-btn", { hasText: "Refresh" }).waitFor({ state: "visible" });
await page.waitForFunction(
() => {
const btns = Array.from(document.querySelectorAll(".link-btn"));
const btn = btns.find((b) => (b.textContent || "").includes("Refresh"));
return btn && !btn.disabled;
},
{ timeout: 10000 },
);
const before = networkCalls.length;
await page.locator(".link-btn", { hasText: "Refresh" }).click();
await page.waitForTimeout(2500);
logOk("Refresh re-fetches /api/ea2/work-items", networkCalls.length > before,
`before=${before} after=${networkCalls.length}`);
}
// LeftRail Confirm button: toast must name /api/runtime/transactions/{id}/actions
await page.locator(".mc-tab", { hasText: "Accounts Receivable" }).click();
await page.waitForTimeout(400);
const confirmBtn = page.locator(".agent-acts .btn-primary").first();
if (await confirmBtn.count() > 0) {
await confirmBtn.click();
await page.waitForTimeout(300);
const lastToast = (await page.locator(".toast-msg").last().innerText()).trim();
logOk("LeftRail Confirm toast names /api/runtime/transactions endpoint",
/\/api\/runtime\/transactions\/\{id\}\/actions/.test(lastToast),
`toast="${lastToast.slice(0, 80)}"`);
} }
// Telemetry honesty (check before leaving MC because RH has no telemetry strip) // Telemetry honesty (check before leaving MC because RH has no telemetry strip)

View File

@ -19,6 +19,7 @@ export default function App() {
const tourActive = useApp((s) => s.tour.active); const tourActive = useApp((s) => s.tour.active);
const mode = useApp((s) => s.mode); const mode = useApp((s) => s.mode);
const setMode = useApp((s) => s.setMode); const setMode = useApp((s) => s.setMode);
const refreshLive = useApp((s) => s.refreshLive);
const liveLoading = useApp((s) => s.liveLoading); const liveLoading = useApp((s) => s.liveLoading);
const liveFetchedAt = useApp((s) => s.liveFetchedAt); const liveFetchedAt = useApp((s) => s.liveFetchedAt);
@ -80,7 +81,7 @@ export default function App() {
{mode === "live" && liveAge && <span className="topbar-age">{liveAge}</span>} {mode === "live" && liveAge && <span className="topbar-age">{liveAge}</span>}
</button> </button>
{mode === "live" && ( {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 <Refresh size={12} /> Refresh
</button> </button>
)} )}

View File

@ -17,12 +17,13 @@ export default function CommandBar() {
const scenarios = useApp((s) => s.scenarios); const scenarios = useApp((s) => s.scenarios);
const mode = useApp((s) => s.mode); const mode = useApp((s) => s.mode);
const setMode = useApp((s) => s.setMode); const setMode = useApp((s) => s.setMode);
const refreshLive = useApp((s) => s.refreshLive);
const pushToast = useApp((s) => s.pushToast); const pushToast = useApp((s) => s.pushToast);
const sc = scenarioById(scenarioId); const sc = scenarioById(scenarioId);
const previewAction = (label: string) => { 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(() => { useEffect(() => {
@ -112,10 +113,17 @@ export default function CommandBar() {
)} )}
<Command.Group heading="Data mode"> <Command.Group heading="Data mode">
<Command.Item onSelect={() => { setMode("live"); close(); }}> {mode === "live" ? (
<Refresh size={13} /> {mode === "live" ? "Refresh live scenarios" : "Switch to LIVE mode (fetch demo.flow-master.ai)"} <Command.Item onSelect={() => { refreshLive(); close(); }}>
<span className="cmd-hint">{mode === "live" ? "re-fetch" : "in-browser"}</span> <Refresh size={13} /> Refresh live scenarios
</Command.Item> <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(); }}> <Command.Item onSelect={() => { setMode("snapshot"); close(); }}>
<Layers size={13} /> Switch to SNAPSHOT mode <Layers size={13} /> Switch to SNAPSHOT mode
<span className="cmd-hint">bundled JSON</span> <span className="cmd-hint">bundled JSON</span>

View File

@ -76,14 +76,14 @@ export default function LeftRail() {
<div className="agent-acts"> <div className="agent-acts">
<button <button
className="btn btn-primary" 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" title="Preview-only — wire to /api/runtime/transactions/{id}/actions to ship"
> >
<Check size={12} /> Confirm <span className="preview-marker">preview</span> <Check size={12} /> Confirm <span className="preview-marker">preview</span>
</button> </button>
<button <button
className="btn btn-ghost" className="btn btn-ghost btn-decline"
onClick={() => useApp.getState().pushToast("info", `Reject is preview-only.`)} 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" title="Preview-only — wire to /api/runtime/transactions/{id}/actions to ship"
> >
<Close size={12} /> Reject <Close size={12} /> Reject

View File

@ -12,9 +12,8 @@ export interface ApiConfig {
// In dev, route via vite proxy (same-origin) to dodge CORS; in production // 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 // the build is deployed at the same origin as the backend, so an empty
// baseUrl is correct. // baseUrl is correct.
const isDev = import.meta.env.DEV;
const DEFAULT_CONFIG: ApiConfig = { 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", email: import.meta.env.VITE_FM_EMAIL || "dev@flow-master.ai",
}; };

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); 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); 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[] } interface Enriched { bucket: CandidateBucket; graph: ProcessGraph; headlineRt: RuntimeTransaction | null; recent: RuntimeTransaction[] }
const enriched: Enriched[] = []; const TOP_N = 6;
for (const c of candidates.slice(0, 20)) { const enrichedRaw = await Promise.all(
if (signal?.aborted) break; candidates.slice(0, TOP_N).map(async (c): Promise<Enriched | null> => {
const graph = await api.graph(c.key, signal); if (signal?.aborted) return null;
if (!graph?.process_definition?.config?.nodes?.length) continue; const graph = await api.graph(c.key, signal);
const headlineCase = if (!graph?.process_definition?.config?.nodes?.length) return null;
c.cases.find((w) => w.status === "running") || const headlineCase =
c.cases.find((w) => w.status === "waiting_for_user") || c.cases.find((w) => w.status === "running") ||
c.cases.find((w) => w.status === "errored" || w.status === "failed") || c.cases.find((w) => w.status === "waiting_for_user") ||
c.cases[0]; c.cases.find((w) => w.status === "errored" || w.status === "failed") ||
const headlineRt = headlineCase?.transaction_id ? await api.transaction(headlineCase.transaction_id, signal) : null; c.cases[0];
const recent: RuntimeTransaction[] = []; const recentCandidates = c.cases
for (const w of c.cases.slice(0, 6)) { .filter((w) => w.transaction_id && w.transaction_id !== headlineCase?.transaction_id)
if (signal?.aborted) break; .slice(0, 3);
if (!w.transaction_id || w.transaction_id === headlineCase?.transaction_id) continue; const [headlineRt, ...recentResults] = await Promise.all([
const r = await api.transaction(w.transaction_id, signal); headlineCase?.transaction_id ? api.transaction(headlineCase.transaction_id, signal) : Promise.resolve(null),
if (r) recent.push(r); ...recentCandidates.map((w) => api.transaction(w.transaction_id, signal)),
if (recent.length >= 3) break; ]);
} const recent = recentResults.filter((r): r is RuntimeTransaction => r != null);
enriched.push({ bucket: c, graph, headlineRt, recent }); return { bucket: c, graph, headlineRt, recent };
} }),
);
const enriched: Enriched[] = enrichedRaw.filter((e): e is Enriched => e != null);
// Classify into families. // Classify into families.
const used = new Set<string>(); const used = new Set<string>();

View File

@ -12,6 +12,7 @@ export default function Landing() {
const scenarios = useApp((s) => s.scenarios); const scenarios = useApp((s) => s.scenarios);
const mode = useApp((s) => s.mode); const mode = useApp((s) => s.mode);
const setMode = useApp((s) => s.setMode); const setMode = useApp((s) => s.setMode);
const refreshLive = useApp((s) => s.refreshLive);
const liveLoading = useApp((s) => s.liveLoading); const liveLoading = useApp((s) => s.liveLoading);
const liveTotals = useApp((s) => s.liveTotals); const liveTotals = useApp((s) => s.liveTotals);
@ -61,12 +62,12 @@ export default function Landing() {
</button> </button>
<button <button
className={`btn btn-ghost btn-lg${mode === "live" ? " is-on" : ""}`} className={`btn btn-ghost btn-lg${mode === "live" ? " is-on" : ""}`}
onClick={() => setMode(mode === "live" ? "snapshot" : "live")} onClick={() => mode === "live" ? refreshLive() : setMode("live")}
disabled={liveLoading} 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} />} {liveLoading ? <span className="spin" /> : <Pulse size={13} />}
{mode === "live" ? "Live mode · on" : "Go live"} {mode === "live" ? "Live · refresh" : "Go live"}
</button> </button>
</div> </div>

77
src/state/store.test.ts Normal file
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);
});
});

View File

@ -28,6 +28,8 @@ interface AppState {
/** snapshot = bundled scenarios.json; live = in-browser API client */ /** snapshot = bundled scenarios.json; live = in-browser API client */
mode: DataMode; mode: DataMode;
setMode: (m: DataMode) => Promise<void>; setMode: (m: DataMode) => Promise<void>;
/** Force re-fetch of live scenarios. No-op in snapshot mode. */
refreshLive: () => Promise<void>;
liveLoading: boolean; liveLoading: boolean;
liveError: string | null; liveError: string | null;
@ -69,38 +71,56 @@ const initialScenario = SNAPSHOT_SCENARIOS[0];
let toastSeq = 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) => ({ export const useApp = create<AppState>((set, get) => ({
scene: "landing", scene: "landing",
setScene: (scene) => set({ scene }), setScene: (scene) => set({ scene }),
mode: "snapshot", mode: "snapshot",
setMode: async (mode) => { setMode: async (mode) => {
if (mode === get().mode) return;
if (mode === "snapshot") { if (mode === "snapshot") {
if (get().mode === "snapshot") return;
set({ mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS, liveError: null, liveLoading: false }); set({ mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS, liveError: null, liveLoading: false });
get().pushToast("info", "Switched to snapshot mode (bundled JSON)"); get().pushToast("info", "Switched to snapshot mode (bundled JSON)");
return; return;
} }
set({ mode: "live", liveLoading: true, liveError: null }); await runLiveFetch(set, get);
try { },
const ping = await api.ping(); refreshLive: async () => {
if (!ping.ok) throw new Error(ping.reason || "backend unreachable"); if (get().mode !== "live") return;
const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi(); await runLiveFetch(set, get);
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`);
}
}, },
liveLoading: false, liveLoading: false,