From e29667b34815cf132de2de2aaaa6190e311dc2ef Mon Sep 17 00:00:00 2001 From: canvas-bot Date: Mon, 15 Jun 2026 22:16:42 +0400 Subject: [PATCH] fix(llm): tunnel fallback through nginx for same-origin (no CORS) Browser trace showed fallback-dev cascade failing with 'Failed to fetch' on the live site. dev.flow-master.ai OPTIONS preflight returns HTTP 400 from foreign origins and the POST response lacks Access-Control-Allow-Origin, so the browser blocks direct cross-origin calls. Add nginx location /api/llm-fallback/ that rewrites to /api/v1/llm/ and proxies to dev.flow-master.ai (same DNS resolver pattern as the demo proxy). Frontend now hits both gateways same-origin via canvas.flow-master.ai, no CORS preflight needed. The canvas-issued JWT is forwarded as-is and dev accepts it (verified by curl). --- nginx.conf | 22 ++++++ qa/_wave4_gaps.mjs | 163 +++++++++++++++++++++++++++++++++++++++++++ src/lib/llmClient.ts | 11 ++- 3 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 qa/_wave4_gaps.mjs diff --git a/nginx.conf b/nginx.conf index 6614f6e..9d4c54f 100644 --- a/nginx.conf +++ b/nginx.conf @@ -63,6 +63,28 @@ server { proxy_buffering off; } + # LLM fallback: tunnel to dev.flow-master.ai's working LLM gateway as + # a same-origin endpoint. demo's llm-integration-service returns 503 + # (no provider key); dev's returns real OpenAI-backed completions and + # accepts the canvas-issued JWT. Proxying same-origin sidesteps CORS + # preflight + missing Access-Control-Allow-Origin on dev. + location /api/llm-fallback/ { + set $upstream_dev "https://dev.flow-master.ai"; + rewrite ^/api/llm-fallback/(.*)$ /api/v1/llm/$1 break; + proxy_pass $upstream_dev; + proxy_set_header Host dev.flow-master.ai; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_ssl_server_name on; + proxy_ssl_name dev.flow-master.ai; + proxy_http_version 1.1; + proxy_connect_timeout 8s; + proxy_send_timeout 25s; + proxy_read_timeout 25s; + proxy_buffering off; + } + # SPA fallback to index.html for client-side routing. location / { try_files $uri $uri/ /index.html; diff --git a/qa/_wave4_gaps.mjs b/qa/_wave4_gaps.mjs new file mode 100644 index 0000000..11f1c76 --- /dev/null +++ b/qa/_wave4_gaps.mjs @@ -0,0 +1,163 @@ +// Wave 4: Oracle's gaps — real LLM answer, multi-persona, perf timings, visual proof. +import { chromium } from "playwright"; +import { writeFileSync, mkdirSync } from "fs"; + +const URL = "https://canvas.flow-master.ai/"; +const OUT = "/tmp/canvas-evidence/wave4"; +mkdirSync(OUT, { recursive: true }); + +const r = []; +const pass = (n, d = "") => { r.push({ n, ok: true, d }); console.log(`PASS ${n}${d ? " " + d : ""}`); }; +const fail = (n, d = "") => { r.push({ n, ok: false, d }); console.log(`FAIL ${n}${d ? " " + d : ""}`); }; + +const b = await chromium.launch({ headless: true }); + +async function login(personaName) { + const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } }); + const p = await ctx.newPage(); + const msgs = []; + const calls = []; + p.on("console", m => msgs.push({ type: m.type(), text: m.text().slice(0, 240) })); + p.on("response", res => { + if (res.url().includes("/api/")) calls.push({ status: res.status(), method: res.request().method(), url: res.url().replace(/https?:\/\/[^/]+/, "") }); + }); + await ctx.addInitScript(() => localStorage.setItem("fm.canvas.tour.seen", String(Date.now()))); + const t0 = Date.now(); + await p.goto(URL, { waitUntil: "domcontentloaded" }); + await p.waitForTimeout(2500); + await p.locator(".persona-chip").filter({ hasText: new RegExp(personaName) }).click(); + await p.waitForSelector(".topbar", { timeout: 30000 }); + return { ctx, p, msgs, calls, loginMs: Date.now() - t0 }; +} + +// ============= GAP 1: Real LLM-backed answer on canvas ============= +console.log("\n=== Gap 1: Real LLM-backed answer ==="); +{ + const { p, msgs, calls } = await login("Mariana"); + await p.waitForTimeout(2000); + await p.locator(".tab").filter({ hasText: /Assistant/i }).first().click(); + await p.waitForSelector(".agent-composer textarea", { timeout: 15000 }); + await p.waitForTimeout(2000); + const t0 = Date.now(); + await p.locator(".agent-composer textarea").first().fill("In one sentence, what does FlowMaster do?"); + await p.locator(".agent-composer textarea").first().press("Enter"); + await p.waitForTimeout(15000); + const elapsed = Date.now() - t0; + const replies = await p.locator(".agent-turn-body").allTextContents(); + const last = replies.slice(-1)[0] || ""; + console.log(` elapsed: ${elapsed}ms`); + console.log(` last reply: ${last.slice(0, 300)}`); + const fallback = /trouble|temporarily|fallback|can do for you immediately/i.test(last); + const gatewayOk = msgs.find(m => /\[llm\] (primary|fallback-dev) ok in \d+ms · provider=/.test(m.text)); + if (gatewayOk) console.log(` telemetry: ${gatewayOk.text.slice(0, 200)}`); + if (gatewayOk && !fallback && last.length > 30) pass("gap1_real_llm_answer", `${gatewayOk.text.match(/provider=\w+/)?.[0]} · ${last.length}ch`); + else fail("gap1_real_llm_answer", `fallback=${fallback}, gateway_ok=${!!gatewayOk}, len=${last.length}`); + await p.screenshot({ path: `${OUT}/g1-llm-answer.png`, fullPage: false }); + await p.context().close(); +} + +// ============= GAP 2: Multi-persona coverage ============= +console.log("\n=== Gap 2: Multi-persona coverage (Aisha + Rohan) ==="); +for (const persona of ["Aisha", "Rohan"]) { + console.log(` ${persona}:`); + const { p, calls } = await login(persona); + await p.waitForTimeout(2500); + const tabs = await p.locator(".tab").allTextContents(); + const has6 = ["Mission", "Approvals", "Runs", "Studio", "Chat", "Assistant"].every(t => tabs.some(x => x.includes(t))); + if (has6) pass(`gap2_${persona.toLowerCase()}_topbar_6_tabs`); + else fail(`gap2_${persona.toLowerCase()}_topbar_6_tabs`, `tabs=${tabs.join("|")}`); + await p.locator(".tab").filter({ hasText: /Studio/i }).first().click(); + await p.waitForTimeout(2000); + await p.evaluate(() => localStorage.removeItem("fm.mc.wizard.draft")); + await p.reload({ waitUntil: "domcontentloaded" }); + await p.waitForTimeout(2500); + await p.locator(".tab").filter({ hasText: /Studio/i }).first().click(); + await p.waitForSelector("textarea", { timeout: 10000 }); + await p.locator("textarea").first().fill(`${persona} test draft.`); + await p.locator("button.btn-primary").filter({ hasText: /draft/i }).first().click(); + await p.waitForTimeout(7000); + const fivexx = calls.filter(c => c.status >= 500 && !c.url.includes("/llm/")).length; + const post201 = calls.some(c => c.method === "POST" && c.url === "/api/ea2/flow" && c.status === 201); + if (post201 && fivexx === 0) pass(`gap2_${persona.toLowerCase()}_studio_no_5xx`); + else fail(`gap2_${persona.toLowerCase()}_studio_no_5xx`, `post201=${post201}, fivexx=${fivexx}`); + await p.locator(".tab").filter({ hasText: /Chat/i }).first().click(); + await p.waitForTimeout(2500); + const chatVisible = await p.locator(".chat-surface, .chat-list, .threadlist, .chat-empty, .chat-err").count(); + if (chatVisible > 0) pass(`gap2_${persona.toLowerCase()}_chat_surface_visible`); + else fail(`gap2_${persona.toLowerCase()}_chat_surface_visible`); + await p.screenshot({ path: `${OUT}/g2-${persona.toLowerCase()}-topbar.png`, clip: { x: 0, y: 0, width: 1440, height: 64 } }); + await p.context().close(); +} + +// ============= GAP 3: Perf timings (Mission + EA2 sync + Assistant) ============= +console.log("\n=== Gap 3: Performance timings ==="); +{ + const { p, calls, loginMs } = await login("Mariana"); + const t0 = Date.now(); + await p.waitForSelector(".mc, .mc-strip, .mc-hero", { timeout: 15000 }); + await p.waitForTimeout(3000); + const missionInitialMs = Date.now() - t0; + const ea2Calls = calls.filter(c => c.url.startsWith("/api/ea2/")); + console.log(` login: ${loginMs}ms`); + console.log(` Mission first paint: ${missionInitialMs}ms`); + console.log(` EA2 calls during initial sync: ${ea2Calls.length} (statuses: ${[...new Set(ea2Calls.map(c => c.status))].join(",")})`); + if (missionInitialMs < 12000) pass("gap3_mission_initial_paint_under_12s", `${missionInitialMs}ms`); + else fail("gap3_mission_initial_paint_under_12s", `${missionInitialMs}ms`); + await p.locator(".tab").filter({ hasText: /Assistant/i }).first().click(); + await p.waitForSelector(".agent-composer textarea", { timeout: 15000 }); + await p.waitForTimeout(1500); + const t1 = Date.now(); + await p.locator(".agent-composer textarea").first().fill("list processes"); + await p.locator(".agent-composer textarea").first().press("Enter"); + await p.waitForFunction(() => document.querySelectorAll(".agent-turn-body").length >= 3, { timeout: 20000 }); + const assistantListMs = Date.now() - t1; + console.log(` Assistant 'list processes' end-to-end: ${assistantListMs}ms`); + if (assistantListMs < 10000) pass("gap3_assistant_list_under_10s", `${assistantListMs}ms`); + else fail("gap3_assistant_list_under_10s", `${assistantListMs}ms`); + writeFileSync(`${OUT}/perf-timings.json`, JSON.stringify({ loginMs, missionInitialMs, assistantListMs, ea2Calls: ea2Calls.length }, null, 2)); + await p.context().close(); +} + +// ============= GAP 4: Topbar visual + responsive + focus ============= +console.log("\n=== Gap 4: Topbar visual + responsive + focus ==="); +for (const width of [1920, 1440, 1024, 768]) { + const ctx2 = await b.newContext({ viewport: { width, height: 900 } }); + const p = await ctx2.newPage(); + await ctx2.addInitScript(() => localStorage.setItem("fm.canvas.tour.seen", String(Date.now()))); + await p.goto(URL, { waitUntil: "domcontentloaded" }); + await p.waitForTimeout(2500); + await p.locator(".persona-chip").filter({ hasText: /Mariana/ }).click(); + await p.waitForSelector(".topbar", { timeout: 30000 }); + await p.waitForTimeout(1500); + const topbarBox = await p.locator(".topbar").boundingBox(); + const tabsOverflow = await p.evaluate(() => { + const tb = document.querySelector(".topbar"); + return tb ? { scrollWidth: tb.scrollWidth, clientWidth: tb.clientWidth, overflowed: tb.scrollWidth > tb.clientWidth } : null; + }); + console.log(` ${width}px: topbar h=${topbarBox?.height}px, overflowed=${tabsOverflow?.overflowed}`); + if (tabsOverflow && !tabsOverflow.overflowed) pass(`gap4_topbar_no_overflow_at_${width}`); + else fail(`gap4_topbar_no_overflow_at_${width}`, JSON.stringify(tabsOverflow)); + await p.screenshot({ path: `${OUT}/g4-topbar-${width}.png`, clip: { x: 0, y: 0, width, height: 80 } }); + await ctx2.close(); +} +{ + const { p } = await login("Mariana"); + await p.waitForTimeout(2000); + await p.keyboard.press("Tab"); + await p.keyboard.press("Tab"); + await p.keyboard.press("Tab"); + await p.waitForTimeout(300); + const focused = await p.evaluate(() => document.activeElement?.tagName); + console.log(` Tab focus reached: ${focused}`); + if (focused === "BUTTON" || focused === "A") pass("gap4_topbar_keyboard_reachable", focused); + else fail("gap4_topbar_keyboard_reachable", focused || "null"); + await p.screenshot({ path: `${OUT}/g4-keyboard-focus.png`, clip: { x: 0, y: 0, width: 1440, height: 80 } }); + await p.context().close(); +} + +await b.close(); + +const passed = r.filter(x => x.ok).length; +console.log(`\n=== ${passed}/${r.length} wave-4 gap checks passed ===`); +writeFileSync(`${OUT}/results.json`, JSON.stringify(r, null, 2)); +process.exit(passed === r.length ? 0 : 1); diff --git a/src/lib/llmClient.ts b/src/lib/llmClient.ts index 77513dc..25b2637 100644 --- a/src/lib/llmClient.ts +++ b/src/lib/llmClient.ts @@ -68,12 +68,12 @@ function extractText(body: unknown): string | null { const LLM_TIMEOUT_MS = 12_000; const LLM_GATEWAYS = [ - { name: "primary", base: "" }, - { name: "fallback-dev", base: "https://dev.flow-master.ai" }, + { name: "primary", path: "/api/v1/llm/generate" }, + { name: "fallback-dev", path: "/api/llm-fallback/generate" }, ]; async function callGateway( - baseUrl: string, + path: string, body: object, combinedSignal: AbortSignal, started: number, @@ -81,7 +81,7 @@ async function callGateway( ): Promise { const token = sessionStorage.getItem("fm.mc.token.v1") || ""; try { - const res = await fetch(`${baseUrl}/api/v1/llm/generate`, { + const res = await fetch(`${api.config.baseUrl}${path}`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify(body), @@ -143,8 +143,7 @@ export const llmClient = { try { let lastResult: LlmResponse | "retry" = "retry"; for (const gw of LLM_GATEWAYS) { - const base = gw.base || api.config.baseUrl; - lastResult = await callGateway(base, body, combinedSignal, started, gw.name); + lastResult = await callGateway(gw.path, body, combinedSignal, started, gw.name); if (lastResult !== "retry") return lastResult; } console.warn(`[llm] all gateways exhausted after ${Date.now() - started}ms`);