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" && (
-