From 665dcf488e5d073db3b77a6374a724cf75fcf4c3 Mon Sep 17 00:00:00 2001 From: shad Date: Sun, 14 Jun 2026 13:19:49 +0400 Subject: [PATCH] feat(hubs): match dev.flow-master.ai workbench pattern + 4 named workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inspected dev.flow-master.ai/dashboard/hubs/{procurement,hr,it} via real dev-login. Real hub structure is: Workbench title + Workflow Commands (4 named buttons) + Queue (left) + Selected Work (right). - Procurement: New purchase request / Vendor approval / PO change-cancel / Three-way match - HR: Employee onboarding / Off-boarding / Sick leave / Payroll management - IT: Access request / Equipment request / Service ticket / User off-boarding Hub.tsx rewritten: header + Workflow Commands grid + queue/selected split pane that matches dev's 'Queue' + 'Selected work' layout. Oracle gap #3 (hubs not feature parity) — partial close. Approvals queue and document browser still outstanding but the workbench shape now mirrors dev. --- qa/full_dogfood.mjs | 58 ++++++++++++++++++-- qa/probe_dev_site.mjs | 54 +++++++++++++++++++ src/index.css | 15 ++++++ src/scenes/Hub.tsx | 119 +++++++++++++++++++++++++++--------------- 4 files changed, 199 insertions(+), 47 deletions(-) create mode 100644 qa/probe_dev_site.mjs diff --git a/qa/full_dogfood.mjs b/qa/full_dogfood.mjs index cad8f7d..c5090b5 100644 --- a/qa/full_dogfood.mjs +++ b/qa/full_dogfood.mjs @@ -35,7 +35,7 @@ record("ceo_persona_dev_login_lands_on_landing", onLanding === 1); // 3. Landing chips include every new surface const hubChips = await p.locator(".hub-chip").allTextContents(); -const expected = ["Procurement Hub", "People Hub", "IT Hub", "Attendance Map", "Talk to Pi", "Team Chat", "What is FlowMaster?"]; +const expected = ["Procurement Hub", "People Hub", "IT Hub", "Attendance Map", "Command Assistant", "Team Chat", "What is FlowMaster?"]; const missing = expected.filter((e) => !hubChips.some((h) => h.includes(e))); record("landing_exposes_all_hubs_and_extras", missing.length === 0, missing.length ? `missing: ${missing.join(", ")}` : "all 7 present"); @@ -55,13 +55,13 @@ const markers = await p.locator(".leaflet-marker-icon").count(); record("geo_attendance_renders_tiles", tiles > 10, `tiles=${tiles}`); record("geo_attendance_renders_eight_markers", markers === 8, `markers=${markers}`); -// 6. Pi agent: tool list + list_processes round-trip +// 6. Command Assistant: tool list + list_processes round-trip await p.locator(".tab", { hasText: /Home/ }).first().click(); await p.waitForTimeout(800); -await p.locator(".hub-chip", { hasText: /Talk to Pi/ }).click(); +await p.locator(".hub-chip", { hasText: /Command Assistant/ }).click(); await p.waitForTimeout(1200); const toolNames = await p.locator(".agent-tool-name").allTextContents(); -record("pi_agent_shows_five_tools", toolNames.length === 5, toolNames.join(", ")); +record("assistant_shows_five_tools", toolNames.length === 5, toolNames.join(", ")); const beforeCount = await p.locator(".agent-turn-agent .agent-turn-body").count(); await p.locator(".agent-composer textarea").fill("list processes"); await p.locator(".agent-composer button", { hasText: /Send/i }).click(); @@ -72,7 +72,7 @@ await p.waitForFunction( ).catch(() => {}); await p.waitForTimeout(500); const lastReply = (await p.locator(".agent-turn-agent .agent-turn-body").last().textContent()) || ""; -record("pi_agent_list_processes_returns_real_ea2_data", lastReply.includes("•") || lastReply.toLowerCase().includes("no published"), lastReply.slice(0, 80)); +record("assistant_list_processes_returns_real_ea2_data", lastReply.includes("•") || lastReply.toLowerCase().includes("no published"), lastReply.slice(0, 80)); // 7. Procurement hub returns real catalogue await p.locator(".tab", { hasText: /Home/ }).first().click(); @@ -112,6 +112,54 @@ for (const [h, check] of Object.entries(securityChecks)) { record(`security_header_${h.replace(/-/g, "_")}`, !!v && check(v), v ? v.slice(0, 60) : "missing"); } +// 11. NEGATIVE assertions: no dev artefacts anywhere a buyer would look. +// Oracle remediation gap #4 — these must stay green after every change. +async function pageBodyText() { + return (await p.locator("body").innerText()).toLowerCase(); +} +async function neg(name, scene, banned) { + await p.locator(".tab", { hasText: /Home/i }).first().click().catch(() => {}); + await p.waitForTimeout(400); + if (scene === "landing") { + // already there + } else if (scene === "agent-list") { + await p.locator(".hub-chip", { hasText: /Command Assistant/i }).click(); + await p.waitForTimeout(800); + const before = await p.locator(".agent-turn-agent .agent-turn-body").count(); + await p.locator(".agent-composer textarea").fill("list processes"); + await p.locator(".agent-composer button", { hasText: /Send/i }).click(); + await p.waitForFunction((n) => document.querySelectorAll(".agent-turn-agent .agent-turn-body").length > n, before, { timeout: 8000 }).catch(() => {}); + await p.waitForTimeout(500); + } else { + await p.locator(".hub-chip", { hasText: new RegExp(scene, "i") }).click(); + await p.waitForTimeout(1500); + } + const body = await pageBodyText(); + const hits = banned.filter((b) => body.includes(b.toLowerCase())); + record(`negative_${name}_no_dev_artefacts`, hits.length === 0, hits.length ? `LEAKED: ${hits.join(", ")}` : "clean"); +} +const BANNED = ["rv test flow", "rv_flow", "atlas f1", "atlas-f1-fresh", "mcp sdx smoke", "codex test", "sidekick", "orphan rv_", " orphan "]; +await neg("landing", "landing", BANNED); +await neg("procurement_hub", "Procurement Hub", BANNED); +await neg("people_hub", "People Hub", BANNED); +await neg("it_hub", "IT Hub", BANNED); +await neg("agent_list_processes", "agent-list", BANNED); + +// 12. NEGATIVE: no raw 32-char hex IDs in user-facing toasts/UI. +// Acceptable in dev console / debug only. +await p.locator(".tab", { hasText: /Home/i }).first().click().catch(() => {}); +await p.waitForTimeout(300); +const visibleText = await pageBodyText(); +const hexLeaks = visibleText.match(/[a-f0-9]{32}/g) || []; +record("negative_no_raw_32hex_ids_visible", hexLeaks.length === 0, hexLeaks.length ? `LEAKED ${hexLeaks.length}: ${hexLeaks[0]}` : "clean"); + +// 13. NEGATIVE: agent welcome must not claim to be an "AI agent". +await p.locator(".hub-chip", { hasText: /Command Assistant/i }).click(); +await p.waitForTimeout(800); +const welcome = (await p.locator(".agent-turn-agent .agent-turn-body").first().textContent()) || ""; +const honestyOk = !/\bai agent\b/i.test(welcome) || /command assistant/i.test(welcome); +record("agent_welcome_does_not_overclaim_ai", honestyOk, welcome.slice(0, 80)); + // Summarise const fails = results.filter((r) => !r.ok); console.log(`\n=== ${results.length - fails.length}/${results.length} passed ===`); diff --git a/qa/probe_dev_site.mjs b/qa/probe_dev_site.mjs new file mode 100644 index 0000000..7136d03 --- /dev/null +++ b/qa/probe_dev_site.mjs @@ -0,0 +1,54 @@ +// Inventory dev.flow-master.ai surfaces so canvas can be compared against the +// real product, not built from memory. Oracle gap #5. +import { chromium } from "playwright"; +const b = await chromium.launch({ headless: true }); +const p = await (await b.newContext({ viewport: { width: 1440, height: 900 } })).newPage(); + +// Try to use the dev site's own dev-login affordance via the UI. +try { + await p.goto("https://dev.flow-master.ai/login", { waitUntil: "networkidle" }); + await p.waitForTimeout(800); + const buttons = await p.locator("button").allTextContents(); + console.log("[login page buttons]:", buttons.map((b) => b.trim().slice(0, 50)).filter(Boolean)); + const devBtn = p.locator("button", { hasText: /sign in as developer|dev login|developer/i }).first(); + if (await devBtn.count()) { + const emailInput = p.locator("input[type='email']").first(); + if (await emailInput.count()) await emailInput.fill("dev@flow-master.ai"); + await devBtn.click(); + await p.waitForTimeout(3000); + console.log("[dev-login via UI] url now:", p.url()); + } else { + console.log("[no dev-login button found on dev]"); + } +} catch (e) { + console.log("[dev-login UI flow failed]", e.message); +} + +async function tryUrl(url, label) { + try { + const r = await p.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 }); + await p.waitForTimeout(1200); + const title = await p.title(); + const links = await p.locator("a").evaluateAll((els) => + els.map((e) => ({ href: e.getAttribute("href") || "", text: (e.textContent || "").trim().slice(0, 50) })) + .filter((l) => l.href && l.href.startsWith("/") && l.text) + .slice(0, 50) + ); + const headings = await p.locator("h1, h2, h3").allTextContents(); + console.log(`\n=== ${label} (${url}) status=${r?.status()} title="${title}" ===`); + console.log("headings:", headings.slice(0, 12).map((h) => h.trim()).filter(Boolean)); + const uniqueLinks = [...new Map(links.map((l) => [l.href, l])).values()].slice(0, 25); + console.log("nav links:", uniqueLinks.map((l) => `${l.text} (${l.href})`).join(" | ")); + } catch (e) { + console.log(`\n=== ${label} (${url}) FAILED: ${e.message.slice(0, 100)} ===`); + } +} + +await tryUrl("https://dev.flow-master.ai/", "dev root"); +await tryUrl("https://dev.flow-master.ai/dashboard", "dev dashboard"); +await tryUrl("https://dev.flow-master.ai/dashboard/hubs/procurement", "procurement hub"); +await tryUrl("https://dev.flow-master.ai/dashboard/hubs/hr", "HR hub"); +await tryUrl("https://dev.flow-master.ai/dashboard/hubs/it", "IT hub"); +await tryUrl("https://dev.flow-master.ai/dashboard/geo-attendance", "geo attendance"); +await tryUrl("https://dev.flow-master.ai/dashboard/process-creation-wizard", "wizard"); +await b.close(); diff --git a/src/index.css b/src/index.css index 3ad0ef9..3154be3 100644 --- a/src/index.css +++ b/src/index.css @@ -1994,3 +1994,18 @@ select.studio-input { background: var(--bp-paper); } .explainer-foot { margin-top: 32px; padding-top: 16px; border-top: 1px solid color-mix(in srgb, var(--bp-navy) 25%, transparent); } .explainer-foot-label { font-family: var(--bp-mono); font-size: 10px; letter-spacing: 0.08em; color: var(--bp-muted); } .explainer-foot p { font-size: 11px; color: var(--bp-muted); margin-top: 4px; } + +.hub-commands { margin-bottom: 24px; } +.hub-split { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.hub-queue, .hub-selected { border: 1px solid var(--bp-navy); padding: 16px; background: var(--bp-paper); display: flex; flex-direction: column; gap: 10px; } +.hub-queue-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; } +.hub-queue-row { + width: 100%; text-align: left; padding: 8px 10px; + background: var(--bp-paper); border: 1px solid color-mix(in srgb, var(--bp-navy) 20%, transparent); + color: var(--bp-navy); font-size: 12px; cursor: pointer; + display: flex; gap: 8px; align-items: center; +} +.hub-queue-row:hover { background: color-mix(in srgb, var(--bp-amber) 15%, var(--bp-paper)); } +.hub-queue-row.active { background: color-mix(in srgb, var(--bp-amber) 30%, var(--bp-paper)); border-color: var(--bp-navy); } +.hub-queue-name { flex: 1; } +@media (max-width: 900px) { .hub-split { grid-template-columns: 1fr; } } diff --git a/src/scenes/Hub.tsx b/src/scenes/Hub.tsx index f58c463..eeac20d 100644 --- a/src/scenes/Hub.tsx +++ b/src/scenes/Hub.tsx @@ -7,46 +7,58 @@ import { Branch, Layers, Pulse } from "../components/icons"; export type HubKey = "procurement" | "hr" | "it"; +interface HubWorkflow { + label: string; + subject: string; + processHint: string; +} + interface HubSpec { title: string; - eyebrow: string; + workbench: string; intro: string; match: RegExp; - quickActions: { label: string; subject: string; processHint: string }[]; + workflows: HubWorkflow[]; } const HUBS: Record = { procurement: { - title: "Procurement Hub", - eyebrow: "Purchase requests, vendor approvals, three-way match", + title: "Procurement workbench", + workbench: "Purchase requests, vendor approvals, three-way match", intro: "Everything a store manager needs to buy something — from a laptop to a forklift — and watch it move through the approval chain.", match: /procure|purchase|vendor|\bpo\b|\bp2p\b|requisition|payment|invoice/i, - quickActions: [ - { label: "Request a new laptop", subject: "store-204-laptop", processHint: "procurement" }, - { label: "Submit a quote for review", subject: "vendor-quote-Q3", processHint: "procurement" }, + workflows: [ + { label: "New purchase request", subject: "store-204-laptop", processHint: "procurement" }, + { label: "Vendor approval", subject: "vendor-quote-Q3", processHint: "vendor" }, + { label: "PO change / cancel", subject: "po-change-may", processHint: "po" }, + { label: "Three-way match", subject: "match-batch-mar", processHint: "match" }, ], }, hr: { - title: "People Hub", - eyebrow: "Hiring, onboarding, leave, performance", + title: "HR workbench", + workbench: "Hiring, onboarding, leave, payroll", intro: "All people movements run here. Start an onboarding for a new hire, file a leave request, or kick off a review cycle.", match: /onboard|offboard|leave|\bhr\b|hiring|payroll|employee|people operations/i, - quickActions: [ - { label: "Onboard a new hire", subject: "new-hire-2026Q3", processHint: "onboard" }, - { label: "Request annual leave", subject: "leave-7d-may", processHint: "leave" }, + workflows: [ + { label: "Employee onboarding", subject: "new-hire-2026Q3", processHint: "onboard" }, + { label: "Off-boarding", subject: "leaver-2026Q3", processHint: "offboard" }, + { label: "Sick leave", subject: "sick-leave-may", processHint: "leave" }, + { label: "Payroll management", subject: "payroll-Q3-2026", processHint: "payroll" }, ], }, it: { - title: "IT Hub", - eyebrow: "Tickets, access requests, incident response", + title: "IT workbench", + workbench: "Access requests, equipment, service tickets, off-boarding", intro: "When something breaks, request access, or you need a new account — file it here and route it to the right on-call.", match: /\bit\b|ticket|incident|service operations|access request|sap access|laptop request|hardware request|software request|password reset|account provisioning/i, - quickActions: [ - { label: "Open an incident", subject: "incident-store-204", processHint: "service" }, - { label: "Request system access", subject: "access-sap-RW", processHint: "access" }, + workflows: [ + { label: "Access request", subject: "access-sap-RW", processHint: "access" }, + { label: "Equipment request", subject: "store-204-laptop", processHint: "equipment" }, + { label: "Service ticket", subject: "incident-store-204", processHint: "service" }, + { label: "User off-boarding", subject: "user-leaver-jun", processHint: "offboard" }, ], }, }; @@ -92,6 +104,11 @@ export default function Hub({ hub }: { hub: HubKey }) { const matching = (flows || []).filter((f) => spec.match.test(`${f.display_name} ${f.description || ""} ${f.name || ""}`)).slice(0, 8); const fallback = (flows || []).slice(0, 6); const visible = matching.length > 0 ? matching : fallback; + const [selectedKey, setSelectedKey] = useState(null); + useEffect(() => { + if (!selectedKey && visible.length > 0) setSelectedKey(visible[0]._key); + }, [visible, selectedKey]); + const selected = visible.find((f) => f._key === selectedKey) || null; const startInstance = async (flowKey: string, subject: string, label: string) => { setBusy(flowKey); @@ -122,15 +139,15 @@ export default function Hub({ hub }: { hub: HubKey }) { return (
-
{spec.eyebrow}
+
{spec.workbench}

{spec.title}

{spec.intro}

-
-
QUICK ACTIONS
+
+
{spec.title.split(" ")[0].toUpperCase()} WORKFLOW COMMANDS
- {spec.quickActions.map((qa) => ( + {spec.workflows.map((qa) => ( ))}
-
-
RELATED PROCESSES
- {flows === null &&
Loading catalogue…
} - {flows !== null && visible.length === 0 && ( -
- No matching published processes yet. Open the to build one. -
- )} -
- {visible.map((f) => ( -
-
{f.display_name}
- {f.description &&
{f.description.slice(0, 160)}
} +
+
+
{spec.title.split(" ")[0].toUpperCase()} QUEUE
+ {flows === null &&
Loading…
} + {flows !== null && visible.length === 0 && ( +
+ Nothing in the queue yet. Open the to publish a process. +
+ )} +
    + {visible.map((f) => ( +
  • + +
  • + ))} +
+
+ +
+
SELECTED WORK
+ {!selected &&
Select a row on the left.
} + {selected && ( +
+
{selected.display_name}
+ {selected.description &&
{selected.description.slice(0, 320)}
}
- ))} -
-
+ )} +
+
); }