diff --git a/README.md b/README.md index 7e1588b..f615d8f 100644 --- a/README.md +++ b/README.md @@ -31,15 +31,15 @@ on Gitea. ## Scenarios in the catalog -| id | mode | source | -| ----------- | --------- | ------------------------------------------------------------------- | -| procurement | live | Purchase Requisition → PO (`pr_to_po_def` on demo.flow-master.ai) | -| extra-1 | live | Atlas F1 Fresh (procurement variant) | -| extra-2 | live | Atlas F1 Fresh (procurement variant) | -| ar | blueprint | AR · Customer Refund Approval | -| hcm | blueprint | HCM · New Hire Onboarding | -| gl | blueprint | GL · Period-End Close | -| service | blueprint | Service Ops · Customer Incident | +| id | mode | source | +|-------------|-----------|-------------------------------------------------------------------| +| procurement | live | Purchase Requisition → PO (`pr_to_po_def` on demo.flow-master.ai) | +| extra-1 | live | Atlas F1 Fresh (procurement variant) | +| extra-2 | live | Atlas F1 Fresh (procurement variant) | +| ar | blueprint | AR · Customer Refund Approval | +| hcm | blueprint | HCM · New Hire Onboarding | +| gl | blueprint | GL · Period-End Close | +| service | blueprint | Service Ops · Customer Incident | **Live** = backed by a real EA2 process definition currently in the demo 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) `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 - `${VITE_FM_BASE:-https://demo.flow-master.ai}` (see `vite.config.ts`). The - browser sees a same-origin request, no CORS check. `src/lib/api.ts` uses an - empty `baseUrl` in dev for this. -- In **production**: deploy the build at the same origin as the backend (or - behind a reverse proxy that passes `/api/*` through). Cross-origin - deployments fail gracefully — `setMode("live")` catches the error, surfaces - it in the topbar banner and a toast, and falls back to snapshot mode. +- **Dev** (`pnpm dev`): `vite.config.ts` proxies `/api/*` to + `${VITE_FM_BASE:-https://demo.flow-master.ai}`. +- **Prod** (Docker image): the bundled `nginx.conf` reverse-proxies `/api/*` + to `https://demo.flow-master.ai`. The image is intended to sit behind the + `mc.flow-master.ai` ingress (see `FM06/flowmaster-ops` overlay). +- **Anywhere else**: set `VITE_FM_BASE=https://your-backend` at build time + and accept that browsers will reject the cross-origin call. Live mode then + fails gracefully — `setMode("live")` catches the error, raises an + `mc-banner-err` banner + error toast, and falls back to snapshot. ## Run, test, build @@ -129,15 +134,25 @@ fetch_scenarios.mjs # Node script to refresh src/scenarios.json ## Deploy -Production deployment is tracked in `FM06/flowmaster-ops` under -`manifests/overlays/mc.flow-master.ai/`. A static nginx serves `dist/`; -ingress fronts it at `mc.flow-master.ai`. `/api/*` is reverse-proxied to the -same backend `demo.flow-master.ai` talks to — that's what makes live mode -work in production without CORS. +Production deployment is tracked in +[FM06/flowmaster-ops PR #1164](https://gitea.flow-master.ai/FM06/flowmaster-ops/pulls/1164), +which adds three resources to `manifests/overlays/demo/`: + +- `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 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 diff --git a/qa/smoke.mjs b/qa/smoke.mjs index c01ba23..3fa5859 100644 --- a/qa/smoke.mjs +++ b/qa/smoke.mjs @@ -114,7 +114,11 @@ await page.waitForTimeout(450); logOk("tour advances", (await page.locator(".tour-title").first().innerText()).length > 0); 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(); await toggleBtn.click(); const liveOk = await page.waitForFunction( @@ -126,6 +130,36 @@ if (liveOk) { await page.waitForTimeout(400); await page.screenshot({ path: `${OUT}/09-live-mode.png` }); 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) diff --git a/src/App.tsx b/src/App.tsx index 762b057..1e82ad4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 && {liveAge}} {mode === "live" && ( - )} diff --git a/src/components/CommandBar.tsx b/src/components/CommandBar.tsx index f5d4f34..45dc1d1 100644 --- a/src/components/CommandBar.tsx +++ b/src/components/CommandBar.tsx @@ -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() { )} - { setMode("live"); close(); }}> - {mode === "live" ? "Refresh live scenarios" : "Switch to LIVE mode (fetch demo.flow-master.ai)"} - {mode === "live" ? "re-fetch" : "in-browser"} - + {mode === "live" ? ( + { refreshLive(); close(); }}> + Refresh live scenarios + re-fetch demo.flow-master.ai + + ) : ( + { setMode("live"); close(); }}> + Switch to LIVE mode (fetch demo.flow-master.ai) + in-browser + + )} { setMode("snapshot"); close(); }}> Switch to SNAPSHOT mode bundled JSON diff --git a/src/components/LeftRail.tsx b/src/components/LeftRail.tsx index 60f0c67..da4e4c1 100644 --- a/src/components/LeftRail.tsx +++ b/src/components/LeftRail.tsx @@ -76,14 +76,14 @@ export default function LeftRail() {
diff --git a/src/state/store.test.ts b/src/state/store.test.ts new file mode 100644 index 0000000..9460d92 --- /dev/null +++ b/src/state/store.test.ts @@ -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(); + 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); + }); +}); diff --git a/src/state/store.ts b/src/state/store.ts index a801f1d..b12d27b 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -28,6 +28,8 @@ interface AppState { /** snapshot = bundled scenarios.json; live = in-browser API client */ mode: DataMode; setMode: (m: DataMode) => Promise; + /** Force re-fetch of live scenarios. No-op in snapshot mode. */ + refreshLive: () => Promise; liveLoading: boolean; liveError: string | null; @@ -69,38 +71,56 @@ const initialScenario = SNAPSHOT_SCENARIOS[0]; let toastSeq = 0; +async function runLiveFetch( + set: (partial: Partial) => 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((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,