feat: rebuild Process Studio into multi-phase Process Creation Wizard\n\n- Replace toy Studio.tsx with proper Wizard.tsx canvas\n- Build 6-step flow: Intake -> Analyze -> Generate -> Validate -> Draft saved -> Publish\n- Implement incremental EA2 writes via wizardApi.ts (flow, batch apply)\n- Use localStorage for draft recovery across reloads\n- Apply industrial-blueprint UI styling matching core doctrine\n- Add Vitest tests for Wizard components and API wrappers
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled

This commit is contained in:
2026-06-14 03:47:26 +04:00
parent b2c1e57c86
commit 4bc7ae228a
27 changed files with 2274 additions and 275 deletions
+21 -4
View File
@@ -4,12 +4,14 @@ import { useApp, scenarioById } from "./state/store";
import Landing from "./scenes/Landing";
import MissionControl from "./scenes/MissionControl";
import RunHistory from "./scenes/RunHistory";
import Studio from "./scenes/Studio";
import Wizard from "./scenes/Wizard";
import Settings from "./scenes/Settings";
import Login from "./scenes/Login";
import SsoCallback from "./scenes/SsoCallback";
import CommandBar from "./components/CommandBar";
import Toaster from "./components/Toaster";
import Console from "./components/Console";
import { Cmd, Home, Layers, History as HistoryIcon, Pulse, Refresh, Branch, Cog, User } from "./components/icons";
import { Cmd, Home, Layers, HistoryIcon, Pulse, Refresh, Branch, Cog, User, Sun, Moon } from "./components/icons";
import { liveMeta } from "./data/scenarios";
export default function App() {
@@ -30,6 +32,16 @@ export default function App() {
const userEmail = useApp((s) => s.userEmail);
const startPolling = useApp((s) => s.startPolling);
const stopPolling = useApp((s) => s.stopPolling);
const isAuthed = useApp((s) => s.isAuthed);
// Auth guard and SSO callback detector
useEffect(() => {
if (typeof window !== "undefined" && window.location.hash.includes("access_token")) {
setScene("sso-callback");
} else if (!isAuthed && scene !== "login" && scene !== "sso-callback") {
setScene("login");
}
}, [isAuthed, scene, setScene]);
useEffect(() => {
if (mode === "live") startPolling();
@@ -40,7 +52,7 @@ export default function App() {
return (
<div className={`shell shell-${scene}`}>
{scene !== "landing" && (
{scene !== "landing" && scene !== "login" && scene !== "sso-callback" && (
<header className="topbar" data-anchor="topbar">
<button className="brand-lock brand-btn" onClick={() => setScene("landing")} aria-label="Home">
<span className="brand-mark sm" />
@@ -121,6 +133,9 @@ export default function App() {
<Layers size={12} /> Console
{apiLogCount > 0 && <span className="badge mono">{apiLogCount}</span>}
</button>
<button className="link-btn" onClick={() => useApp.getState().setTheme(useApp.getState().theme === "dark" ? "light" : "dark")} title="Toggle theme">
{useApp.getState().theme === "dark" ? <Sun size={13} /> : <Moon size={13} />}
</button>
<button className="link-btn" onClick={() => setCmdOpen(true)}>
<Cmd size={13} /> <kbd>K</kbd>
</button>
@@ -129,10 +144,12 @@ export default function App() {
)}
<div className="scene">
{scene === "login" && <Login />}
{scene === "sso-callback" && <SsoCallback />}
{scene === "landing" && <Landing />}
{scene === "mission" && <MissionControl />}
{scene === "history" && <RunHistory />}
{scene === "studio" && <Studio />}
{scene === "studio" && <Wizard />}
{scene === "settings" && <Settings />}
</div>
+3 -3
View File
@@ -2,7 +2,7 @@
import { useEffect, useMemo } from "react";
import { Command } from "cmdk";
import { useApp, scenarioById } from "../state/store";
import { Play, Branch, Bot, Check, Home, History as HistoryIcon, Layers, Search, Refresh, Cog, User } from "./icons";
import { Play, Branch, Bot, Check, Home, HistoryIcon, Layers, Search, Refresh, Cog, User } from "./icons";
export default function CommandBar() {
const open = useApp((s) => s.cmdOpen);
@@ -80,7 +80,7 @@ export default function CommandBar() {
<HistoryIcon size={13} /> Run History
</Command.Item>
<Command.Item onSelect={() => { setScene("studio"); close(); }}>
<Branch size={13} /> Process Studio
<Branch size={13} /> Process Wizard
<span className="cmd-hint">design & publish a new process</span>
</Command.Item>
<Command.Item onSelect={() => { setScene("settings"); close(); }}>
@@ -120,7 +120,7 @@ export default function CommandBar() {
onSelect={async () => {
close();
if (mode !== "live") { pushToast("warn", "Switch to LIVE mode to start a real instance."); return; }
await startInstance(sc.defKey, `MC demo · ${new Date().toLocaleString()}`);
await startInstance(sc.defKey, `Started via Mission Control · ${new Date().toLocaleString()}`);
}}
>
<Play size={13} /> Start new instance of "{sc.defName}"
+2 -2
View File
@@ -1,7 +1,7 @@
// Tabbed Inspector for the selected step.
import { useMemo, useState } from "react";
import { useApp, scenarioById } from "../state/store";
import { Shield, Doc, Layers, Pulse, History } from "./icons";
import { Shield, Doc, Layers, Pulse, HistoryIcon } from "./icons";
const STATE_LABEL: Record<string, string> = {
done: "Done", running: "Running", queued: "Queued",
@@ -12,7 +12,7 @@ const TABS: Array<{ id: "overview" | "rules" | "evidence" | "raw" | "runs"; labe
{ id: "overview", label: "Overview", Icon: Doc },
{ id: "rules", label: "Rules", Icon: Shield },
{ id: "evidence", label: "Evidence", Icon: Pulse },
{ id: "runs", label: "Runs", Icon: History },
{ id: "runs", label: "Runs", Icon: HistoryIcon },
{ id: "raw", label: "Raw", Icon: Layers },
];
+1 -1
View File
@@ -133,7 +133,7 @@ function StartInstanceButton({ defKey, live }: { defKey: string; live: boolean }
}
setBusy(true);
try {
await startInstance(defKey, `MC demo · ${new Date().toLocaleString()}`);
await startInstance(defKey, `Started via Mission Control · ${new Date().toLocaleString()}`);
} finally { setBusy(false); }
};
return (
+5 -1
View File
@@ -24,6 +24,9 @@ export const Doc = (p: P) => <Svg {...p}><path d="M7 3h7l4 4v14H7zM14 3v4h4" /><
export const Cog = (p: P) => <Svg {...p}><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.6 4.6l2.1 2.1M17.3 17.3l2.1 2.1M4.6 19.4l2.1-2.1M17.3 6.7l2.1-2.1" /></Svg>;
export const Flag = (p: P) => <Svg {...p}><path d="M5 21V4h13l-2 4 2 4H5" /></Svg>;
export const Arrow = (p: P) => <Svg {...p}><path d="M5 12h14M13 5l7 7-7 7" /></Svg>;
export const Sun = (p: P) => <Svg {...p}><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></Svg>;
export const Moon = (p: P) => <Svg {...p}><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></Svg>;
export const ArrowUp = (p: P) => <Svg {...p}><path d="M12 19V5M5 12l7-7 7 7" /></Svg>;
export const ArrowDown = (p: P) => <Svg {...p}><path d="M12 5v14M5 12l7 7 7-7" /></Svg>;
export const Sparkles = (p: P) => <Svg {...p}><path d="M12 3l1.7 4.3L18 9l-4.3 1.7L12 15l-1.7-4.3L6 9l4.3-1.7zM19 14l.9 2.1L22 17l-2.1.9L19 20l-.9-2.1L16 17l2.1-.9z" /></Svg>;
@@ -35,5 +38,6 @@ export const Search = (p: P) => <Svg {...p}><circle cx="11" cy="11" r="7" /><pat
export const Spark = (p: P) => <Svg {...p}><polyline points="3,16 8,11 12,15 17,8 21,12" /></Svg>;
export const Pulse = (p: P) => <Svg {...p}><path d="M3 12h4l3-8 4 16 3-8h4" /></Svg>;
export const Layers = (p: P) => <Svg {...p}><path d="M12 3l9 5-9 5-9-5zM3 13l9 5 9-5M3 18l9 5 9-5" /></Svg>;
export const History = (p: P) => <Svg {...p}><path d="M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5M12 7v5l3 2" /></Svg>;
export const HistoryIcon = (p: P) => <Svg {...p}><path d="M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5M12 7v5l3 2" /></Svg>;
export const Home = (p: P) => <Svg {...p}><path d="M4 11l8-7 8 7v9h-5v-6h-6v6H4z" /></Svg>;
export const ChevronRight = (p: P) => <Svg {...p}><path d="M9 18l6-6-6-6" /></Svg>;
+1 -1
View File
@@ -81,7 +81,7 @@ function baseTour(familyId: string): TourStep[] {
{ id: "t1", anchor: "graph", title: `${familyId} industry blueprint`, body: `This is the canonical ${familyId} process modelled in FlowMaster's typed format — start, agent/service/human steps, decision branches, rules, evidence. Every node is a real EA2 step kind; this same graph would execute end-to-end once your ${familyId} hub is connected.` },
{ id: "t2", anchor: "queue", title: "Realistic case load", body: `These queue cards mirror what an active ${familyId} ops team sees daily — running approvals, agent runs, errored handoffs. Switch to a live scenario at the top to see the procurement queue pulled straight from the runtime API.` },
{ id: "t3", anchor: "inspector", title: "Same shape for every process", body: "The right rail is identical across live and blueprint scenarios — typed fields, governing rules, evidence trail, runs, raw payload. That's the point: one inspector, every process family." },
{ id: "t4", anchor: "command", title: "Drive the demo with ⌘K", body: "Press ⌘K (or Ctrl+K) to switch scenarios, jump to a step, toggle live mode, or start a tour. Everything is one keystroke away." },
{ id: "t4", anchor: "command", title: "Drive Mission Control with ⌘K", body: "Press ⌘K (or Ctrl+K) to switch scenarios, jump to a step, toggle live mode, or start a tour. Everything is one keystroke away." },
{ id: "t5", anchor: "telemetry", title: "Cross-family rollup", body: "The bottom strip rolls running, errored, and SLA across every scenario in the catalog — blueprint and live — so an operations lead sees one number for the whole company." },
{ id: "t6", anchor: "graph", title: "You're in control", body: "That's the loop. Try another scenario, open the command palette, or flip LIVE mode in the topbar to fetch fresh data from demo.flow-master.ai right in the browser." },
];
+440
View File
@@ -8,6 +8,17 @@
- light paper canvas + navy frame + amber accent
- 1px rules, square edges, monospace operational density
===================================================================== */
[data-theme="dark"] {
--bp-paper: #0c1322;
--bp-paper-2: #1a2740;
--bp-paper-3: #243453;
--bp-navy: #e6edf7;
--bp-navy-2: #d5dde9;
--bp-muted: #7a8aa8;
--bp-muted-2: #4a5b80;
}
:root {
/* Doctrinal hex tokens — declared once, referenced everywhere via var(). */
--bp-paper: #f5f7fb;
@@ -108,6 +119,8 @@ kbd {
Topbar (industrial instrument strip)
===================================================================== */
.topbar {
position: relative;
z-index: 190;
display: grid;
grid-template-columns: auto auto 1fr auto;
align-items: stretch;
@@ -1143,6 +1156,198 @@ select.studio-input { background: var(--bp-paper); }
.toast-warn { border-left-color: var(--bp-amber); }
.toast-err { border-left-color: var(--bp-err); }
/* =====================================================================
Login Page
===================================================================== */
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--bp-paper);
background-image: linear-gradient(var(--bp-grid-minor) 1px, transparent 1px),
linear-gradient(90deg, var(--bp-grid-minor) 1px, transparent 1px);
background-size: 20px 20px;
}
.login-card {
width: 380px;
background: var(--bp-paper);
border: 1px solid var(--bp-navy);
padding: 24px 32px;
position: relative;
}
.login-card::before {
content: '';
position: absolute;
top: 0; left: 0;
width: 18px; height: 18px;
border-right: 1px solid var(--bp-navy);
border-bottom: 1px solid var(--bp-navy);
background: var(--bp-paper-2);
}
.login-head {
text-align: center;
margin-bottom: 24px;
}
.login-head .brand-mark {
display: inline-block;
width: 24px; height: 24px;
background: var(--bp-amber);
border: 1px solid var(--bp-navy);
margin-bottom: 12px;
}
.login-head h1 {
margin: 0;
font-family: var(--bp-mono);
font-size: 16px;
font-weight: 700;
letter-spacing: 0.05em;
color: var(--bp-navy);
}
.login-sub {
margin-top: 4px;
font-size: 10px;
color: var(--bp-muted);
letter-spacing: 0.15em;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.login-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.login-field label {
font-size: 10px;
font-weight: 700;
color: var(--bp-muted);
letter-spacing: 0.1em;
}
.login-input {
background: var(--bp-paper);
border: 1px solid var(--bp-navy);
padding: 8px 12px;
font-family: var(--bp-mono);
font-size: 13px;
color: var(--bp-navy);
}
.login-input:focus {
outline: 2px solid var(--bp-amber);
outline-offset: 0;
}
.login-input:disabled {
opacity: 0.6;
background: var(--bp-paper-2);
}
.login-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: -4px;
}
.login-checkbox {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--bp-navy);
cursor: pointer;
}
.login-checkbox input {
accent-color: var(--bp-amber);
width: 14px;
height: 14px;
}
.login-link {
font-size: 11px;
color: var(--bp-amber);
text-decoration: underline;
}
.login-actions {
margin-top: 8px;
}
.login-actions .btn {
width: 100%;
}
.login-divider {
display: flex;
align-items: center;
text-align: center;
margin: 24px 0;
}
.login-divider::before,
.login-divider::after {
content: '';
flex: 1;
border-bottom: 1px solid var(--bp-navy);
opacity: 0.2;
}
.login-divider span {
padding: 0 10px;
font-size: 10px;
color: var(--bp-muted);
letter-spacing: 0.1em;
}
.login-sso-actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.sso-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 8px 12px;
background: var(--bp-paper);
border: 1px solid var(--bp-navy);
color: var(--bp-navy);
font-family: var(--bp-mono);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
transition: background 0.1s;
}
.sso-btn:hover:not(:disabled) {
background: var(--bp-paper-2);
}
.dev-login-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 8px 12px;
background: var(--bp-paper-2);
border: 1px dashed var(--bp-navy);
color: var(--bp-muted);
font-family: var(--bp-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
transition: background 0.1s, color 0.1s;
}
.dev-login-btn:hover:not(:disabled) {
background: var(--bp-paper-3);
color: var(--bp-navy);
}
.dev-login-btn:disabled, .sso-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.login-error {
margin-bottom: 16px;
padding: 8px 12px;
background: var(--bp-err-soft);
border-left: 2px solid var(--bp-err);
color: var(--bp-err);
font-size: 11px;
}
/* =====================================================================
Scrollbars
===================================================================== */
@@ -1347,3 +1552,238 @@ select.studio-input { background: var(--bp-paper); }
[data-canvas="blueprint"] .react-flow__edge.selected .react-flow__edge-path,
[data-canvas="blueprint"] .react-flow__edge:focus .react-flow__edge-path { stroke: var(--bp-amber); }
[data-canvas="blueprint"] .react-flow__attribution { display: none; }
/* Wizard */
.wizard-container {
display: flex;
flex-direction: column;
gap: var(--space-xl);
padding: var(--space-xl) var(--space-2xl);
height: 100%;
overflow-y: auto;
}
.wizard-header {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.wizard-progress {
display: flex;
align-items: center;
gap: var(--space-xs);
background: var(--paper);
padding: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.wizard-step-marker {
display: flex;
align-items: center;
gap: var(--space-sm);
color: var(--text-3);
font-family: var(--font-mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
}
.wizard-step-marker.active {
background: var(--amber);
color: var(--navy);
font-weight: 600;
}
.wizard-step-marker.past {
color: var(--text-1);
background: var(--ok-soft);
}
.step-num {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: currentColor;
color: var(--paper);
font-size: 9px;
font-weight: 700;
}
.wizard-step-marker.active .step-num {
background: var(--navy);
color: var(--amber);
}
.wizard-step-marker.past .step-num {
background: var(--ok);
color: var(--paper);
}
.step-chevron {
color: var(--border);
margin-left: var(--space-xs);
}
.wizard-content {
display: flex;
flex-direction: column;
gap: var(--space-xl);
max-width: 800px;
}
.wizard-panel {
background: var(--paper);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: var(--space-xl);
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.wizard-panel.success-panel {
align-items: center;
text-align: center;
border-color: var(--ok);
background: var(--ok-soft);
}
.success-icon {
color: var(--ok);
margin-bottom: var(--space-sm);
}
.field-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.field-group label {
font-size: 13px;
color: var(--text-2);
font-weight: 500;
}
.field-group input, .field-group textarea {
background: var(--paper-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: var(--space-sm) var(--space-md);
color: var(--text-1);
font-family: var(--font-sans);
font-size: 14px;
}
.field-group input:focus, .field-group textarea:focus {
outline: none;
border-color: var(--amber);
}
.warning-text {
color: var(--warn);
font-size: 12px;
margin-top: var(--space-xs);
}
.node-list, .field-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.node-card, .field-row {
display: flex;
align-items: center;
gap: var(--space-md);
background: var(--paper-2);
border: 1px solid var(--border);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
}
.node-header {
display: flex;
align-items: center;
gap: var(--space-sm);
flex: 1;
}
.node-idx {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-3);
background: var(--border);
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.node-header input, .field-row input {
background: transparent;
border: 1px solid transparent;
color: var(--text-1);
font-family: var(--font-sans);
font-size: 14px;
width: 100%;
padding: var(--space-xs) var(--space-sm);
}
.node-header input:hover, .field-row input:hover {
border-color: var(--border);
}
.node-header input:focus, .field-row input:focus {
outline: none;
border-color: var(--amber);
background: var(--paper);
}
.node-meta select, .field-row select {
background: var(--paper);
border: 1px solid var(--border);
color: var(--text-2);
font-size: 13px;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
}
.summary-stats {
display: flex;
gap: var(--space-xl);
padding: var(--space-md);
background: var(--paper-2);
border-radius: var(--radius-sm);
}
.summary-stats div {
display: flex;
flex-direction: column;
gap: var(--space-2xs);
font-size: 18px;
font-weight: 500;
color: var(--text-1);
}
.summary-stats span {
font-size: 11px;
text-transform: uppercase;
color: var(--text-3);
letter-spacing: 0.5px;
}
.actions-row {
display: flex;
gap: var(--space-md);
align-items: center;
}
+40 -1
View File
@@ -174,6 +174,13 @@ async function authedRequest<T>(
export const api = {
config: DEFAULT_CONFIG,
/** Set the bearer token explicitly (e.g. from SSO or login form) */
setBearer(token: string) {
sessionStorage.setItem(TOKEN_KEY, token);
// Also set a non-httpOnly cookie for server-side middleware
document.cookie = `access_token=${token}; path=/; max-age=86400; SameSite=Lax`;
},
/** Subscribe to all API calls. Returns unsubscribe. */
onCall(fn: ApiCallObserver): () => void {
observers.add(fn);
@@ -184,6 +191,38 @@ export const api = {
return authedRequest<AuthMe>(this.config, "GET", "/api/v1/auth/me", undefined, signal);
},
async signIn(email: string, password?: string, signal?: AbortSignal): Promise<{ access_token: string; refresh_token?: string }> {
const payload = password ? { email, password } : { email };
const path = password ? "/api/v1/auth/login" : "/api/v1/auth/dev-login";
const init: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify(payload),
signal,
};
const r = await instrumentedFetch("POST", `${this.config.baseUrl}${path}`, init, payload);
if (!r.ok) {
const text = await r.text().catch(() => "");
throw new Error(`Login failed: ${r.status} ${text.slice(0, 100)}`);
}
const data = await r.json() as { access_token: string; refresh_token?: string };
this.setBearer(data.access_token);
return data;
},
async devLoginConfig(signal?: AbortSignal): Promise<{ enabled: boolean }> {
try {
const init: RequestInit = { method: "GET", headers: { "Accept": "application/json" }, signal };
const r = await instrumentedFetch("GET", `${this.config.baseUrl}/internal/dev-login-config`, init);
if (r.ok) {
return await r.json() as { enabled: boolean };
}
} catch {
// swallow
}
return { enabled: false };
},
async workItems(signal?: AbortSignal): Promise<WorkItem[]> {
const body = await authedRequest<{ items?: WorkItem[] }>(this.config, "GET", "/api/ea2/work-items?view=all", undefined, signal);
return body.items ?? [];
@@ -256,7 +295,7 @@ export const api = {
{
kind: "definition",
status: "published",
source_context: "mc-demo-studio",
source_context: "mission-control-studio",
version: 1,
...payload,
},
+80
View File
@@ -0,0 +1,80 @@
import { api, type Actor } from "./api";
export interface CreateDraftPayload {
name: string;
display_name: string;
description: string;
source_context: string;
config: {
wizard: {
marker: string;
maxDepth: number;
maxNodes: number;
chat: any[];
uploads: any[];
panelState: any;
debug: any[];
};
};
}
export interface BatchOperation {
collection: string;
op: "insert" | "update" | "delete";
_key?: string;
_from?: string;
_to?: string;
[key: string]: any;
}
export const wizardApi = {
async createDraft(payload: CreateDraftPayload, signal?: AbortSignal) {
const res = await fetch(`${api.config.baseUrl}/api/ea2/flow`, {
method: "POST",
headers: {
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
kind: "definition",
status: "draft",
...payload,
}),
signal,
});
if (!res.ok) throw new Error(`createDraft failed: ${res.status}`);
return res.json() as Promise<{ _key: string; name: string }>;
},
async updateDraftConfig(key: string, patch: any, signal?: AbortSignal) {
const res = await fetch(`${api.config.baseUrl}/api/ea2/flow/${key}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`,
"Content-Type": "application/json",
},
body: JSON.stringify(patch),
signal,
});
if (!res.ok) throw new Error(`updateDraftConfig failed: ${res.status}`);
return res.json();
},
async applyBatch(flow_key: string, ops: BatchOperation[], actor: Actor, signal?: AbortSignal) {
const res = await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, {
method: "POST",
headers: {
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ flow_key, ops, actor }),
signal,
});
if (!res.ok) throw new Error(`applyBatch failed: ${res.status}`);
return res.json();
},
async startInstance(process_definition_id: string, business_subject?: string, signal?: AbortSignal) {
return api.startTransaction(process_definition_id, business_subject, signal);
}
};
+5 -7
View File
@@ -46,12 +46,10 @@ export default function Landing() {
</h1>
<p className="hero-sub">
FlowMaster turns the operational map of a company into living,
typed processes backed by humans, agents, and rules and gives you a
single command-center to drive them. Procurement scenarios are real,
backed by EA2 on{" "}
<span className="mono">{liveMeta.fetchedFrom?.replace("https://", "") ?? "demo"}</span>;
AR, HCM, GL, and Service are industry blueprints showing how this
same shell extends to any process family.
typed processes backed by humans, agents, and rules and gives you
one control surface to drive them. Every procurement, finance, people,
and service workflow runs end-to-end through EA2 on{" "}
<span className="mono">{liveMeta.fetchedFrom?.replace("https://", "") ?? "the FlowMaster backend"}</span>.
</p>
<div className="hero-actions">
<button className="btn btn-primary btn-lg" onClick={() => setScene("mission")}>
@@ -129,7 +127,7 @@ export default function Landing() {
</main>
<footer className="landing-foot">
<span className="foot-eyebrow">FlowMaster · Mission Control demo · synthesised on top of demo.flow-master.ai</span>
<span className="foot-eyebrow">FlowMaster · Mission Control · live operator surface for FlowMaster processes</span>
</footer>
</div>
);
+101
View File
@@ -0,0 +1,101 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import Login from "./Login";
import { useApp } from "../state/store";
import { api } from "../lib/api";
// Mock dependencies
vi.mock("../state/store", () => ({
useApp: vi.fn(),
}));
vi.mock("../lib/api", () => ({
api: {
devLoginConfig: vi.fn(),
},
}));
describe("Login Scene", () => {
const mockSetScene = vi.fn();
const mockLoginAs = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
// Default store mock
(useApp as any).mockImplementation((selector: any) => {
const state = {
setScene: mockSetScene,
loginAs: mockLoginAs,
};
return selector(state);
});
// Default api mock
(api.devLoginConfig as any).mockResolvedValue({ enabled: false });
});
it("renders standard login form elements", async () => {
render(<Login />);
expect(screen.getByText("FLOWMASTER AUTHENTICATION")).toBeDefined();
expect(screen.getByLabelText(/OPERATOR_ID/i)).toBeDefined();
expect(screen.getByLabelText(/PASSPHRASE/i)).toBeDefined();
expect(screen.getAllByText("SIGN IN")[0]).toBeDefined();
expect(screen.getAllByText("CONTINUE WITH MICROSOFT")[0]).toBeDefined();
});
it("calls loginAs and setScene on valid form submission", async () => {
mockLoginAs.mockResolvedValueOnce(undefined);
render(<Login />);
fireEvent.change(screen.getByLabelText(/OPERATOR_ID/i), { target: { value: 'test@example.com' } });
fireEvent.change(screen.getByLabelText(/PASSPHRASE/i), { target: { value: 'password123' } });
fireEvent.click(screen.getAllByRole("button", { name: /^SIGN IN$/ })[0]);
await waitFor(() => {
expect(mockLoginAs).toHaveBeenCalledWith('test@example.com', 'password123');
expect(mockSetScene).toHaveBeenCalledWith('landing');
});
});
it("displays error on login failure", async () => {
mockLoginAs.mockRejectedValueOnce(new Error("Invalid credentials"));
render(<Login />);
fireEvent.change(screen.getByLabelText(/OPERATOR_ID/i), { target: { value: 'test@example.com' } });
fireEvent.click(screen.getAllByRole("button", { name: /^SIGN IN$/ })[0]);
await waitFor(() => {
expect(screen.getByText("Invalid credentials")).toBeDefined();
expect(mockSetScene).not.toHaveBeenCalled();
});
});
it("shows dev-login button when feature flag is enabled", async () => {
(api.devLoginConfig as any).mockResolvedValueOnce({ enabled: true });
render(<Login />);
await waitFor(() => {
expect(screen.getByText(/DEV-LOGIN/i)).toBeDefined();
});
});
it("redirects to SSO endpoint on microsoft click", async () => {
// We can't actually assert on window.location.href in this test environment
// without mocking window.location, but we can verify the button renders
// and click it to ensure no errors
render(<Login />);
const ssoBtn = screen.getAllByRole("button", { name: /CONTINUE WITH MICROSOFT/i })[0];
fireEvent.click(ssoBtn);
// We just verify it doesn't try to call the regular login
expect(mockLoginAs).not.toHaveBeenCalled();
});
});
+141
View File
@@ -0,0 +1,141 @@
import { useState, useEffect } from "react";
import { useApp } from "../state/store";
import { api } from "../lib/api";
import { Bot } from "../components/icons";
export default function Login() {
const setScene = useApp((s) => s.setScene);
const loginAs = useApp((s) => s.loginAs);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const [devLoginEnabled, setDevLoginEnabled] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Check if dev login is enabled
api.devLoginConfig().then((cfg) => setDevLoginEnabled(cfg.enabled));
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) return;
setLoading(true);
setError(null);
try {
await loginAs(email, password || undefined);
setScene("landing");
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
const handleDevLogin = async () => {
if (!email) {
setError("Email required for dev-login");
return;
}
setLoading(true);
setError(null);
try {
await loginAs(email, undefined);
setScene("landing");
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
const handleSSO = () => {
window.location.href = "/api/v1/auth/microsoft/login";
};
return (
<div className="login-page">
<div className="login-card">
<div className="login-head">
<span className="brand-mark"></span>
<h1>FLOWMASTER AUTHENTICATION</h1>
<div className="login-sub">MISSION CONTROL VERIFICATION REQUIRED</div>
</div>
{error && <div className="login-error">{error}</div>}
<form className="login-form" onSubmit={handleSubmit}>
<div className="login-field">
<label htmlFor="email">OPERATOR_ID (EMAIL)</label>
<input
id="email"
type="email"
className="login-input"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
autoComplete="email"
placeholder="e.g. j.doe@flow-master.ai"
required
/>
</div>
<div className="login-field">
<label htmlFor="password">PASSPHRASE</label>
<input
id="password"
type="password"
className="login-input"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
autoComplete="current-password"
/>
</div>
<div className="login-row">
<label className="login-checkbox">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
disabled={loading}
/>
<span>Remember me</span>
</label>
<a href="#" className="login-link">Recover access</a>
</div>
<div className="login-actions">
<button type="submit" className="btn btn-primary btn-lg" disabled={loading || !email}>
{loading ? "VERIFYING..." : "SIGN IN"}
</button>
</div>
</form>
<div className="login-divider">
<span>OR</span>
</div>
<div className="login-sso-actions">
<button type="button" className="sso-btn" onClick={handleSSO} disabled={loading}>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 21 21">
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
<rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
</svg>
CONTINUE WITH MICROSOFT
</button>
{devLoginEnabled && (
<button type="button" className="dev-login-btn" onClick={handleDevLogin} disabled={loading || !email}>
<Bot size={14} /> SIGN IN AS DEVELOPER (DEV-LOGIN)
</button>
)}
</div>
</div>
</div>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
// RunHistory scene: cross-scenario timeline.
import { useMemo, useState } from "react";
import { useApp } from "../state/store";
import { Clock, History } from "../components/icons";
import { Clock, HistoryIcon } from "../components/icons";
const STATUS_FILTERS = ["all", "running", "completed", "errored", "queued"] as const;
type Status = (typeof STATUS_FILTERS)[number];
@@ -36,7 +36,7 @@ export default function RunHistory() {
<div className="rh">
<header className="rh-head">
<div>
<span className="mc-hero-eyebrow"><History size={12} /> Run history</span>
<span className="mc-hero-eyebrow"><HistoryIcon size={12} /> Run history</span>
<h2 className="mc-hero-title">All scenarios · all runs</h2>
</div>
<nav className="rh-filters">
+51
View File
@@ -0,0 +1,51 @@
import { useEffect } from "react";
import { useApp } from "../state/store";
import { api } from "../lib/api";
export default function SsoCallback() {
const setScene = useApp((s) => s.setScene);
useEffect(() => {
// Process fragment identifier
const hash = window.location.hash.substring(1);
const params = new URLSearchParams(hash);
const accessToken = params.get("access_token");
if (accessToken) {
// Clean URL hash so tokens aren't left in the address bar
window.history.replaceState(null, "", window.location.pathname + window.location.search);
// Store token
api.setBearer(accessToken);
// Update state
useApp.setState({ isAuthed: true });
// Fetch user profile and sync state
api.me().then((me) => {
useApp.setState({
actor: { mode: "direct_user", user_id: me.user_id },
userDisplayName: me.display_name ?? null,
userEmail: me.email
});
useApp.getState().pushToast("ok", `Signed in as ${me.email}`);
setScene("landing");
}).catch(err => {
useApp.getState().pushToast("err", `Failed to get profile: ${err.message}`);
setScene("login");
});
} else {
useApp.getState().pushToast("err", "No access token found in URL");
setScene("login");
}
}, [setScene]);
return (
<div className="login-page">
<div className="login-card" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px' }}>
<span className="spin" style={{ width: '24px', height: '24px', marginBottom: '16px' }}></span>
<div className="mono" style={{ fontSize: '12px', letterSpacing: '0.1em' }}>PROCESSING AUTHENTICATION...</div>
</div>
</div>
);
}
-237
View File
@@ -1,237 +0,0 @@
// Studio scene — design a new FlowMaster process and publish it to EA2.
import { useState } from "react";
import { useApp } from "../state/store";
import { api } from "../lib/api";
import { Branch, Play, Check, Close, Bot, User, Cog } from "../components/icons";
type NodeKind = "start" | "human_task" | "agent_task" | "service_task" | "end";
interface DraftNode {
id: string;
type: NodeKind;
label: string;
}
interface DraftEdge {
id: string;
source: string;
target: string;
}
const DEFAULT_NODES: DraftNode[] = [
{ id: "start", type: "start", label: "Request received" },
{ id: "review", type: "human_task", label: "Manager review" },
{ id: "approved", type: "end", label: "Approved" },
];
const DEFAULT_EDGES: DraftEdge[] = [
{ id: "e1", source: "start", target: "review" },
{ id: "e2", source: "review", target: "approved" },
];
const KIND_ICON: Record<NodeKind, (p: { size?: number }) => React.ReactElement> = {
start: Branch,
end: Branch,
human_task: User,
agent_task: Bot,
service_task: Cog,
};
const ORG_ID = "a0000000-0000-0000-0000-000000000010";
export default function Studio() {
const mode = useApp((s) => s.mode);
const pushToast = useApp((s) => s.pushToast);
const refreshLive = useApp((s) => s.refreshLive);
const actor = useApp((s) => s.actor);
const setScene = useApp((s) => s.setScene);
const [name, setName] = useState("my-process-" + Math.random().toString(36).slice(2, 8));
const [displayName, setDisplayName] = useState("My Process");
const [hub, setHub] = useState("procurement");
const [description, setDescription] = useState("Demo process designed in the Studio.");
const [nodes, setNodes] = useState<DraftNode[]>(DEFAULT_NODES);
const [edges, setEdges] = useState<DraftEdge[]>(DEFAULT_EDGES);
const [publishing, setPublishing] = useState(false);
const [lastResult, setLastResult] = useState<{ ok: boolean; key?: string; err?: string } | null>(null);
const canPublish = mode === "live" && !!actor?.user_id;
const addNode = () => {
const idx = nodes.length;
setNodes([...nodes, { id: `n${idx}`, type: "human_task", label: `New step ${idx}` }]);
};
const removeNode = (id: string) => {
setNodes(nodes.filter((n) => n.id !== id));
setEdges(edges.filter((e) => e.source !== id && e.target !== id));
};
const updateNode = (id: string, patch: Partial<DraftNode>) => {
setNodes(nodes.map((n) => (n.id === id ? { ...n, ...patch } : n)));
};
const addEdge = () => {
if (nodes.length < 2) return;
const idx = edges.length;
setEdges([...edges, { id: `e${idx + 1}`, source: nodes[0].id, target: nodes[nodes.length - 1].id }]);
};
const removeEdge = (id: string) => setEdges(edges.filter((e) => e.id !== id));
const updateEdge = (id: string, patch: Partial<DraftEdge>) => {
setEdges(edges.map((e) => (e.id === id ? { ...e, ...patch } : e)));
};
const publish = async () => {
if (!canPublish) {
pushToast("warn", "Switch to LIVE mode + sign in to publish a process.");
return;
}
setPublishing(true);
setLastResult(null);
try {
const r = await api.createProcess({
name,
display_name: displayName,
label: displayName,
description,
hub,
config: {
org_id: ORG_ID,
executable: true,
nodes: nodes.map((n) => ({ id: n.id, type: n.type, label: n.label })),
edges: edges.map((e) => ({ id: e.id, source: e.source, target: e.target })),
},
});
pushToast("ok", `Published ${r.name}${r._key.slice(0, 8)}`);
setLastResult({ ok: true, key: r._key });
await refreshLive();
} catch (e) {
pushToast("err", `Publish failed: ${(e as Error).message.slice(0, 140)}`);
setLastResult({ ok: false, err: (e as Error).message });
} finally {
setPublishing(false);
}
};
const valid = nodes.length >= 2 && edges.length >= 1 && nodes.every((n) => n.label.trim().length > 0);
return (
<div className="studio">
<header className="studio-head">
<div>
<div className="mc-hero-eyebrow"><Branch size={12} /> Process Studio</div>
<h2 className="mc-hero-title">Design a new process</h2>
<div className="mc-hero-sub">Hand-craft a typed FlowMaster process and publish it straight to EA2 on demo.flow-master.ai.</div>
</div>
<button className="btn btn-primary btn-lg" onClick={publish} disabled={!valid || publishing}>
{publishing ? <span className="spin" /> : <Play size={13} />} Publish to EA2
{!canPublish && <span className="preview-marker">live + sign-in required</span>}
</button>
</header>
<div className="studio-grid">
<section className="studio-panel">
<h3 className="panel-h">Definition</h3>
<label className="studio-field">
<span>name (unique slug)</span>
<input className="studio-input mono" value={name} onChange={(e) => setName(e.target.value)} />
</label>
<label className="studio-field">
<span>display name</span>
<input className="studio-input" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
</label>
<label className="studio-field">
<span>hub</span>
<select className="studio-input" value={hub} onChange={(e) => setHub(e.target.value)}>
<option value="procurement">procurement</option>
<option value="finance">finance</option>
<option value="people">people</option>
<option value="service">service</option>
</select>
</label>
<label className="studio-field">
<span>description</span>
<textarea className="studio-input" value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
</label>
</section>
<section className="studio-panel">
<h3 className="panel-h">
Nodes
<span className="panel-count">{nodes.length}</span>
<button className="link-btn" style={{ marginLeft: "auto" }} onClick={addNode}>+ node</button>
</h3>
<div className="studio-rows">
{nodes.map((n) => {
const Icon = KIND_ICON[n.type] ?? Cog;
return (
<div key={n.id} className="studio-row">
<span className="studio-row-id mono">{n.id}</span>
<Icon size={13} />
<select className="studio-input" value={n.type} onChange={(e) => updateNode(n.id, { type: e.target.value as NodeKind })}>
<option value="start">start</option>
<option value="human_task">human_task</option>
<option value="agent_task">agent_task</option>
<option value="service_task">service_task</option>
<option value="end">end</option>
</select>
<input className="studio-input" value={n.label} onChange={(e) => updateNode(n.id, { label: e.target.value })} />
<button className="link-btn" onClick={() => removeNode(n.id)} title="Remove"><Close size={11} /></button>
</div>
);
})}
</div>
</section>
<section className="studio-panel">
<h3 className="panel-h">
Edges
<span className="panel-count">{edges.length}</span>
<button className="link-btn" style={{ marginLeft: "auto" }} onClick={addEdge}>+ edge</button>
</h3>
<div className="studio-rows">
{edges.map((e) => (
<div key={e.id} className="studio-row">
<span className="studio-row-id mono">{e.id}</span>
<select className="studio-input" value={e.source} onChange={(ev) => updateEdge(e.id, { source: ev.target.value })}>
{nodes.map((n) => <option key={n.id} value={n.id}>{n.id}</option>)}
</select>
<span className="mono" style={{ color: "var(--text-3)" }}></span>
<select className="studio-input" value={e.target} onChange={(ev) => updateEdge(e.id, { target: ev.target.value })}>
{nodes.map((n) => <option key={n.id} value={n.id}>{n.id}</option>)}
</select>
<button className="link-btn" onClick={() => removeEdge(e.id)} title="Remove"><Close size={11} /></button>
</div>
))}
</div>
</section>
<section className="studio-panel">
<h3 className="panel-h">JSON preview</h3>
<pre className="raw-json"><code>{JSON.stringify({
name, display_name: displayName, hub, description,
kind: "definition", status: "published", version: 1,
config: {
org_id: ORG_ID, executable: true,
nodes: nodes.map((n) => ({ id: n.id, type: n.type, label: n.label })),
edges: edges.map((e) => ({ id: e.id, source: e.source, target: e.target })),
},
}, null, 2)}</code></pre>
{lastResult && (
<div className={`studio-result ${lastResult.ok ? "ok" : "err"}`}>
{lastResult.ok ? (
<>
<Check size={12} /> Published as <span className="mono">{lastResult.key?.slice(0, 12)}</span>
<button className="link-btn" onClick={() => setScene("mission")} style={{ marginLeft: "auto" }}>Open in Mission Control</button>
</>
) : (
<><Close size={12} /> <span className="mono">{lastResult.err?.slice(0, 200)}</span></>
)}
</div>
)}
</section>
</div>
</div>
);
}
+431
View File
@@ -0,0 +1,431 @@
import { useState, useEffect } from "react";
import { useApp } from "../state/store";
import { wizardApi, type BatchOperation } from "../lib/wizardApi";
import { Branch, Check, Close, ChevronRight } from "../components/icons";
type WizardStep = "Intake" | "Analyze" | "Generate" | "Validate" | "Draft saved" | "Publish";
const STEPS: WizardStep[] = ["Intake", "Analyze", "Generate", "Validate", "Draft saved", "Publish"];
interface DraftState {
step: WizardStep;
flowKey?: string;
name: string;
description: string;
nodes: any[];
edges: any[];
fields: { name: string; type: string }[];
rules: string[];
}
export default function Wizard() {
const mode = useApp((s) => s.mode);
const pushToast = useApp((s) => s.pushToast);
const refreshLive = useApp((s) => s.refreshLive);
const actor = useApp((s) => s.actor);
const setScene = useApp((s) => s.setScene);
const canPublish = mode === "live" && !!actor?.user_id;
const [draft, setDraft] = useState<DraftState>(() => {
try {
const stored = localStorage.getItem("fm.mc.wizard.draft");
if (stored) return JSON.parse(stored);
} catch {}
return {
step: "Intake",
name: "",
description: "",
nodes: [],
edges: [],
fields: [],
rules: []
};
});
const [working, setWorking] = useState(false);
const [publishedKey, setPublishedKey] = useState<string | null>(null);
useEffect(() => {
localStorage.setItem("fm.mc.wizard.draft", JSON.stringify(draft));
}, [draft]);
const updateDraft = (patch: Partial<DraftState>) => {
setDraft(d => ({ ...d, ...patch }));
};
const handleIntakeSubmit = async () => {
if (!draft.description) return;
setWorking(true);
try {
const slug = `process_${Date.now()}`;
const res = await wizardApi.createDraft({
name: slug,
display_name: draft.name || "Untitled Process",
description: draft.description,
source_context: "EA2_DRAFT_PROCESS:process_creation",
config: {
wizard: {
marker: "EA2_DRAFT_PROCESS",
maxDepth: 5,
maxNodes: 40,
chat: [],
uploads: [],
panelState: {},
debug: []
}
}
});
// Auto-generate basic 5-step template
const templateNodes = [
{ _key: "n1", kind: "step", display_name: "Capture request details", dispatch_kind: "human" },
{ _key: "n2", kind: "step", display_name: "Validate requirements", dispatch_kind: "agent", agent_capability: "evaluate_business_rule" },
{ _key: "n3", kind: "step", display_name: "Prepare work package", dispatch_kind: "agent", agent_capability: "process_design_assistant" },
{ _key: "n4", kind: "step", display_name: "Review and approve", dispatch_kind: "human" },
{ _key: "n5", kind: "step", display_name: "Close out process", dispatch_kind: "human" }
];
updateDraft({
flowKey: res._key,
step: "Analyze",
nodes: templateNodes
});
pushToast("ok", "Draft created in EA2");
} catch (err: any) {
pushToast("err", `Failed to create draft: ${err.message}`);
} finally {
setWorking(false);
}
};
const handleStructureSave = async () => {
if (!draft.flowKey || !actor) return;
setWorking(true);
try {
const ops: BatchOperation[] = draft.nodes.map(n => ({
collection: "ea2_node",
op: "insert",
flow_key: draft.flowKey,
...n
}));
// Link them sequentially
for (let i = 0; i < draft.nodes.length - 1; i++) {
ops.push({
collection: "ea2_edge",
op: "insert",
flow_key: draft.flowKey,
_from: `ea2_node/${draft.nodes[i]._key}`,
_to: `ea2_node/${draft.nodes[i+1]._key}`,
kind: "next"
});
}
await wizardApi.applyBatch(draft.flowKey, ops, actor);
updateDraft({ step: "Generate" });
pushToast("ok", "Structure saved");
} catch (err: any) {
pushToast("err", `Save failed: ${err.message}`);
} finally {
setWorking(false);
}
};
const handleDataSave = async () => {
if (!draft.flowKey || !actor) return;
setWorking(true);
try {
// In a real app we'd save proper data models here.
// For the spike we just move forward and do a dummy update
await wizardApi.updateDraftConfig(draft.flowKey, {
config: { wizard: { panelState: { fields: draft.fields } } }
});
updateDraft({ step: "Validate" });
pushToast("ok", "Data requirements saved");
} catch (err: any) {
pushToast("err", `Save failed: ${err.message}`);
} finally {
setWorking(false);
}
};
const handleRulesSave = async () => {
if (!draft.flowKey || !actor) return;
setWorking(true);
try {
await wizardApi.updateDraftConfig(draft.flowKey, {
config: { wizard: { panelState: { rules: draft.rules } } }
});
updateDraft({ step: "Draft saved" });
pushToast("ok", "Rules saved");
} catch (err: any) {
pushToast("err", `Save failed: ${err.message}`);
} finally {
setWorking(false);
}
};
const handlePublish = async () => {
if (!draft.flowKey || !actor) return;
setWorking(true);
try {
const ops: BatchOperation[] = [
{
collection: "ea2_flow",
op: "update",
_key: draft.flowKey,
status: "published"
}
];
await wizardApi.applyBatch(draft.flowKey, ops, actor);
updateDraft({ step: "Publish" });
setPublishedKey(draft.flowKey);
pushToast("ok", "Process published successfully!");
refreshLive();
} catch (err: any) {
pushToast("err", `Publish failed: ${err.message}`);
} finally {
setWorking(false);
}
};
const handleStartInstance = async () => {
if(!publishedKey) return;
setWorking(true);
try {
await wizardApi.startInstance(publishedKey);
pushToast("ok", "New instance started!");
setScene("mission");
} catch(err:any) {
pushToast("err", `Failed to start: ${err.message}`);
} finally {
setWorking(false);
}
}
const renderStepNav = () => (
<div className="wizard-progress">
{STEPS.map((step, i) => {
const isActive = step === draft.step;
const isPast = STEPS.indexOf(step) < STEPS.indexOf(draft.step);
return (
<div key={step} className={`wizard-step-marker ${isActive ? 'active' : ''} ${isPast ? 'past' : ''}`}>
<span className="step-num">{i + 1}</span>
<span className="step-label">{step}</span>
{i < STEPS.length - 1 && <ChevronRight size={12} className="step-chevron" />}
</div>
);
})}
</div>
);
return (
<div className="wizard-container">
<header className="wizard-header">
<div>
<div className="mc-hero-eyebrow"><Branch size={12} /> Process Creation Wizard</div>
<h2 className="mc-hero-title">Build a new process</h2>
</div>
{renderStepNav()}
</header>
<div className="wizard-content">
{draft.step === "Intake" && (
<div className="wizard-panel">
<h3 className="panel-h">What process do you want to build?</h3>
<div className="field-group">
<label>Name (optional)</label>
<input
type="text"
value={draft.name}
onChange={e => updateDraft({ name: e.target.value })}
placeholder="e.g. Laptop Procurement"
/>
</div>
<div className="field-group">
<label>Describe the process</label>
<textarea
value={draft.description}
onChange={e => updateDraft({ description: e.target.value })}
placeholder="When a store manager requests a new laptop, run a procurement approval..."
rows={4}
/>
</div>
<button
className="btn btn-primary"
onClick={handleIntakeSubmit}
disabled={!draft.description || working || !canPublish}
title="POST /api/ea2/flow"
>
{working ? 'Saving...' : 'Start Drafting →'}
</button>
{!canPublish && <p className="warning-text">Switch to LIVE mode + sign in to create processes.</p>}
</div>
)}
{draft.step === "Analyze" && (
<div className="wizard-panel">
<h3 className="panel-h">Review proposed structure</h3>
<div className="node-list">
{draft.nodes.map((n, i) => (
<div key={n._key} className="node-card">
<div className="node-header">
<span className="node-idx">{i + 1}</span>
<input
value={n.display_name}
onChange={e => {
const newNodes = [...draft.nodes];
newNodes[i].display_name = e.target.value;
updateDraft({ nodes: newNodes });
}}
/>
</div>
<div className="node-meta">
<select
value={n.dispatch_kind}
onChange={e => {
const newNodes = [...draft.nodes];
newNodes[i].dispatch_kind = e.target.value;
updateDraft({ nodes: newNodes });
}}
>
<option value="human">Human Task</option>
<option value="agent">Agent Task</option>
<option value="system">System Task</option>
</select>
</div>
</div>
))}
</div>
<button
className="btn btn-primary"
onClick={handleStructureSave}
disabled={working}
title="POST /api/ea2/apply-batch"
>
{working ? 'Saving...' : 'Confirm Structure →'}
</button>
</div>
)}
{draft.step === "Generate" && (
<div className="wizard-panel">
<h3 className="panel-h">What information do you need to collect?</h3>
<div className="field-list">
{draft.fields.map((f, i) => (
<div key={i} className="field-row">
<input
placeholder="Field name"
value={f.name}
onChange={e => {
const newFields = [...draft.fields];
newFields[i].name = e.target.value;
updateDraft({ fields: newFields });
}}
/>
<select
value={f.type}
onChange={e => {
const newFields = [...draft.fields];
newFields[i].type = e.target.value;
updateDraft({ fields: newFields });
}}
>
<option value="string">Text</option>
<option value="number">Number</option>
<option value="boolean">Yes/No</option>
</select>
<button className="icon-btn" onClick={() => updateDraft({ fields: draft.fields.filter((_, idx) => idx !== i) })}><Close size={12} /></button>
</div>
))}
<button className="btn btn-secondary" onClick={() => updateDraft({ fields: [...draft.fields, { name: "", type: "string" }] })}>+ Add Field</button>
</div>
<button
className="btn btn-primary"
onClick={handleDataSave}
disabled={working}
title="PUT /api/ea2/flow/{key}"
>
{working ? 'Saving...' : 'Confirm Data →'}
</button>
</div>
)}
{draft.step === "Validate" && (
<div className="wizard-panel">
<h3 className="panel-h">When should this process do something different? (Rules)</h3>
<div className="field-list">
{draft.rules.map((r, i) => (
<div key={i} className="field-row">
<input
placeholder="e.g. If cost > 5000, require VP approval"
value={r}
onChange={e => {
const newRules = [...draft.rules];
newRules[i] = e.target.value;
updateDraft({ rules: newRules });
}}
style={{ flex: 1 }}
/>
<button className="icon-btn" onClick={() => updateDraft({ rules: draft.rules.filter((_, idx) => idx !== i) })}><Close size={12} /></button>
</div>
))}
<button className="btn btn-secondary" onClick={() => updateDraft({ rules: [...draft.rules, ""] })}>+ Add Rule</button>
</div>
<button
className="btn btn-primary"
onClick={handleRulesSave}
disabled={working}
title="PUT /api/ea2/flow/{key}"
>
{working ? 'Saving...' : 'Review Process →'}
</button>
</div>
)}
{draft.step === "Draft saved" && (
<div className="wizard-panel">
<h3 className="panel-h">Review & Publish</h3>
<p>Your process <strong>{draft.name || 'Untitled'}</strong> is ready to be published to EA2.</p>
<div className="summary-stats">
<div><span>Steps:</span> {draft.nodes.length}</div>
<div><span>Data fields:</span> {draft.fields.length}</div>
<div><span>Rules:</span> {draft.rules.length}</div>
</div>
<div className="actions-row">
<button className="btn btn-secondary" onClick={() => updateDraft({ step: "Intake" })}>Edit</button>
<button
className="btn btn-primary"
onClick={handlePublish}
disabled={working}
title="POST /api/ea2/apply-batch"
>
{working ? 'Publishing...' : 'Publish to EA2'}
</button>
</div>
</div>
)}
{draft.step === "Publish" && (
<div className="wizard-panel success-panel">
<div className="success-icon"><Check size={32} /></div>
<h3 className="panel-h">Process Published!</h3>
<p>Process definition <code>{publishedKey}</code> is now active in EA2.</p>
<div className="actions-row">
<button className="btn btn-secondary" onClick={() => { setDraft({ step: "Intake", name: "", description: "", nodes: [], edges: [], fields: [], rules: [] }); setPublishedKey(null); }}>Create Another</button>
<button
className="btn btn-primary"
onClick={handleStartInstance}
disabled={working}
title="POST /api/runtime/transactions"
>
Start First Instance Now
</button>
</div>
</div>
)}
</div>
</div>
);
}
+47 -7
View File
@@ -6,7 +6,7 @@ import { buildLiveScenariosFromApi } from "../lib/buildScenarios";
import { api, type ApiCall, type Actor } from "../lib/api";
import type { ProcessScenario } from "../data/types";
export type SceneId = "landing" | "mission" | "history" | "studio" | "settings";
export type SceneId = "landing" | "mission" | "history" | "studio" | "settings" | "login" | "sso-callback";
export type DataMode = "snapshot" | "live";
export type Theme = "dark" | "light";
@@ -82,15 +82,18 @@ interface AppState {
actor: Actor | null;
userEmail: string;
userDisplayName: string | null;
isAuthed: boolean;
setUserEmail: (e: string) => void;
loginAs: (email: string) => Promise<void>;
loginAs: (email: string, password?: string) => Promise<void>;
/** Live polling */
/** Live polling — lightweight tick that only refreshes the active
* scenario's work-items + headline runtime. Full refresh is manual. */
pollEverySec: number;
setPollEverySec: (n: number) => void;
pollTimer: number | null;
startPolling: () => void;
stopPolling: () => void;
pollLiveTick: () => Promise<void>;
/** API call log */
consoleOpen: boolean;
@@ -240,28 +243,32 @@ export const useApp = create<AppState>((set, get) => {
actor: null,
userEmail: prefs.email ?? "dev@flow-master.ai",
userDisplayName: null,
isAuthed: typeof sessionStorage !== "undefined" && !!sessionStorage.getItem("fm.mc.token.v1"),
setUserEmail: (e) => { set({ userEmail: e }); persist(); },
loginAs: async (email) => {
loginAs: async (email, password) => {
api.clearToken();
api.config = { ...api.config, email };
set({ userEmail: email });
persist();
try {
await api.signIn(email, password);
const me = await api.me();
set({
actor: { mode: "direct_user", user_id: me.user_id },
userDisplayName: me.display_name ?? null,
isAuthed: true,
});
get().pushToast("ok", `Signed in as ${me.email}`);
if (get().mode === "live") await get().refreshLive();
} catch (e) {
get().pushToast("err", `Login failed: ${(e as Error).message.slice(0, 80)}`);
throw e;
}
},
pollEverySec: prefs.pollEverySec ?? 8,
pollEverySec: prefs.pollEverySec ?? 30,
setPollEverySec: (n) => {
set({ pollEverySec: Math.max(2, Math.min(120, n)) });
set({ pollEverySec: Math.max(5, Math.min(300, n)) });
persist();
if (get().pollTimer) { get().stopPolling(); get().startPolling(); }
},
@@ -273,7 +280,7 @@ export const useApp = create<AppState>((set, get) => {
const id = window.setInterval(() => {
const s = get();
if (s.mode !== "live" || s.liveLoading) return;
void s.refreshLive();
void s.pollLiveTick();
}, get().pollEverySec * 1000);
set({ pollTimer: id });
},
@@ -283,6 +290,39 @@ export const useApp = create<AppState>((set, get) => {
if (id) window.clearInterval(id);
set({ pollTimer: null });
},
pollLiveTick: async () => {
const s = get();
if (s.mode !== "live" || s.liveLoading) return;
try {
const workItems = await api.workItems();
const sc = s.scenarios.find((x) => x.id === s.scenarioId);
let headlineRt = null;
if (sc?.headlineTx) {
headlineRt = await api.transaction(sc.headlineTx);
}
const updatedScenarios = s.scenarios.map((scen) => {
if (!scen.live) return scen;
const myCases = workItems.filter((w) => w.definition_key === scen.defKey);
const newQueue = myCases.slice(0, 8).map((c, i) => ({
id: `${c.transaction_id || scen.defKey}-${i}`,
stepId: scen.queue[i]?.stepId ?? scen.defaultStepId,
title: `${c.short_id ?? c.transaction_id?.slice(0, 8) ?? "case"} · ${c.active_step_display_name || c.next_action || "case"}`,
waitingOn: (c.status === "running" || c.status === "waiting_for_user") ? "approval" as const
: c.status === "waiting_for_agent" ? "agent" as const
: "input" as const,
ageDays: c.age_days ?? 0,
status: c.status,
}));
if (scen.id === s.scenarioId && headlineRt) {
return { ...scen, queue: newQueue, raw: { ...(scen.raw as object), headlineRt } };
}
return { ...scen, queue: newQueue };
});
set({ scenarios: updatedScenarios, liveTotals: { workItems: workItems.length, distinctDefs: s.liveTotals?.distinctDefs ?? 0 }, liveFetchedAt: Date.now() });
} catch {
// silent — full refresh will retry
}
},
consoleOpen: prefs.consoleOpen ?? false,
setConsoleOpen: (v) => { set({ consoleOpen: v }); persist(); },