feat(hubs): match dev.flow-master.ai workbench pattern + 4 named workflows

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.
This commit is contained in:
2026-06-14 13:19:49 +04:00
parent 8933148719
commit 665dcf488e
4 changed files with 199 additions and 47 deletions
+53 -5
View File
@@ -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 ===`);
+54
View File
@@ -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();
+15
View File
@@ -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; } }
+77 -42
View File
@@ -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<HubKey, HubSpec> = {
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<string | null>(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 (
<div className="hub-scene">
<header className="hub-head">
<div className="mc-hero-eyebrow"><Layers size={12} /> {spec.eyebrow}</div>
<div className="mc-hero-eyebrow"><Layers size={12} /> {spec.workbench}</div>
<h2 className="mc-hero-title">{spec.title}</h2>
<p className="hub-intro">{spec.intro}</p>
</header>
<section className="hub-quick">
<div className="hub-section-label">QUICK ACTIONS</div>
<section className="hub-commands">
<div className="hub-section-label">{spec.title.split(" ")[0].toUpperCase()} WORKFLOW COMMANDS</div>
<div className="hub-quick-grid">
{spec.quickActions.map((qa) => (
{spec.workflows.map((qa) => (
<button
key={qa.label}
className="hub-quick-card"
@@ -138,36 +155,54 @@ export default function Hub({ hub }: { hub: HubKey }) {
disabled={!flows}
>
<div className="hub-quick-title"><Pulse size={12} /> {qa.label}</div>
<div className="hub-quick-meta">Looks up "{qa.processHint}" in your catalogue</div>
<div className="hub-quick-meta">Open workflow</div>
</button>
))}
</div>
</section>
<section className="hub-catalogue">
<div className="hub-section-label">RELATED PROCESSES</div>
{flows === null && <div className="hub-empty">Loading catalogue</div>}
{flows !== null && visible.length === 0 && (
<div className="hub-empty">
No matching published processes yet. Open the <button className="link-inline" onClick={() => setScene("studio")}>Process Studio</button> to build one.
</div>
)}
<div className="hub-catalogue-grid">
{visible.map((f) => (
<div key={f._key} className="hub-flow-card">
<div className="hub-flow-title"><Branch size={11} /> {f.display_name}</div>
{f.description && <div className="hub-flow-desc">{f.description.slice(0, 160)}</div>}
<div className="hub-split">
<section className="hub-queue">
<div className="hub-section-label">{spec.title.split(" ")[0].toUpperCase()} QUEUE</div>
{flows === null && <div className="hub-empty">Loading</div>}
{flows !== null && visible.length === 0 && (
<div className="hub-empty">
Nothing in the queue yet. Open the <button className="link-inline" onClick={() => setScene("studio")}>Process Studio</button> to publish a process.
</div>
)}
<ul className="hub-queue-list">
{visible.map((f) => (
<li key={f._key}>
<button
className={`hub-queue-row ${f._key === selectedKey ? "active" : ""}`}
onClick={() => setSelectedKey(f._key)}
>
<Branch size={11} />
<span className="hub-queue-name">{f.display_name}</span>
</button>
</li>
))}
</ul>
</section>
<section className="hub-selected">
<div className="hub-section-label">SELECTED WORK</div>
{!selected && <div className="hub-empty">Select a row on the left.</div>}
{selected && (
<div className="hub-flow-card">
<div className="hub-flow-title"><Branch size={11} /> {selected.display_name}</div>
{selected.description && <div className="hub-flow-desc">{selected.description.slice(0, 320)}</div>}
<button
className="btn btn-secondary"
disabled={busy === f._key}
onClick={() => startInstance(f._key, "", f.display_name)}
className="btn btn-primary"
disabled={busy === selected._key}
onClick={() => startInstance(selected._key, "", selected.display_name)}
>
{busy === f._key ? "Starting…" : "Start an instance"}
{busy === selected._key ? "Starting…" : "Start an instance"}
</button>
</div>
))}
</div>
</section>
)}
</section>
</div>
</div>
);
}