Shad 3ffd0e68a7 Mission Control demo v2
Polished command-center for FlowMaster with two data modes:
- SNAPSHOT: bundled src/scenarios.json from demo.flow-master.ai
- LIVE: in-browser fetch via src/lib/api.ts (dev-login + bearer)

Scenarios:
- procurement, extra-1, extra-2 (live from EA2)
- ar, hcm, gl, service (industry blueprints, same typed shell)

Honesty pass after Oracle review:
- No invented numbers (Telemetry derives SLA + agent acceptance from real data)
- Preview-only actions fire toasts naming the endpoint to wire them
- Blueprint tours framed as 'industry blueprint', not 'we don't have this yet'
- Mode pill + last-fetch age + refresh in topbar
- Dev CORS dodged via vite proxy; production deploys same-origin

18 vitest tests + 26 playwright smoke assertions + DOM layout audit.

Constraint: cross-origin live mode rejected by browser → fall back to snapshot
Rejected: hardcoded SLA % | dishonest demo metrics
Directive: wire preview-only action handlers to /api/runtime/transactions/{id}/actions to ship them for real
Confidence: high
Scope-risk: narrow
Not-tested: production deployment via flowmaster-ops overlay
2026-06-14 00:09:32 +04:00

322 lines
10 KiB
TypeScript

// Adapter: convert scenarios.json (live EA2 read) into ProcessScenario[].
import live from "../scenarios.json";
import type {
AgentRun, EvidenceItem, FlowEdge, ProcessScenario, ProcessStep, QueueItem,
Rule, RuntimeState, RunSummary, StepAction, StepKind, TourStep,
} from "./types";
interface LiveScenarioRaw {
id: string;
family: { id: string; label: string; subtitle: string; accent: string };
def_key: string;
def_name: string;
hubs: string[];
statuses: Record<string, number>;
cases: LiveCase[];
graph: LiveGraph;
headlineTx: string | null;
headlineRt: LiveRuntime | null;
recent: LiveRuntime[];
}
interface LiveCase {
transaction_id: string;
short_id?: string;
status: string;
hub?: string;
age_days?: number;
active_step_display_name?: string;
current_node?: string;
next_action?: string;
definition_key: string;
business_subject?: string;
created_at?: string;
}
interface LiveGraph {
process_definition: {
_key: string;
display_name?: string;
name: string;
config: {
nodes: LiveNode[];
edges: LiveEdge[];
org_id?: string;
};
hub?: string;
};
version_definitions?: Array<{ version: number; approved_at?: string }>;
}
interface LiveNode {
id: string;
type: string;
label?: string;
display_name?: string;
actions?: Array<{ id: string; display_label?: string; label?: string; kind: string }>;
form_ref?: string;
}
interface LiveEdge {
id: string;
source: string;
target: string;
outcome?: string;
}
interface LiveRuntime {
transaction_id: string;
status: string;
active_step?: { step_definition_id?: string; display_name?: string };
available_actions?: Array<{ display_label: string }>;
created_at?: string;
business_subject?: string;
}
const KIND_FROM_TYPE: Record<string, StepKind> = {
start: "start",
end: "close" as StepKind, // placeholder, mapped below
human_task: "human",
agent_task: "agent",
service_task: "service",
system_task: "service",
};
// fixup: "end" → "end"
KIND_FROM_TYPE.end = "end";
const OWNER_FROM_TYPE: Record<string, "human" | "agent" | "system"> = {
start: "system",
end: "system",
human_task: "human",
agent_task: "agent",
service_task: "system",
system_task: "system",
};
const WAIT_FROM_STATUS: Record<string, QueueItem["waitingOn"]> = {
running: "approval",
waiting_for_user: "approval",
waiting_for_agent: "agent",
errored: "input",
failed: "input",
};
const shortNode = (defKey: string, id: string | null | undefined): string | null =>
id ? id.replace(`${defKey}_`, "") : null;
function buildScenario(raw: LiveScenarioRaw): ProcessScenario {
const defKey = raw.def_key;
const pd = raw.graph.process_definition;
const cfg = pd.config;
const rt = raw.headlineRt;
const activeNode = rt ? shortNode(defKey, rt.active_step?.step_definition_id) : null;
const activeIdx = cfg.nodes.findIndex((n) => n.id === activeNode);
const rtStatus = rt?.status ?? "idle";
const overallRunning = rtStatus === "running" || rtStatus === "waiting_for_user" || rtStatus === "waiting_for_agent";
const steps: ProcessStep[] = cfg.nodes.map((n, i) => {
let state: RuntimeState;
if (rtStatus === "completed") {
state = "done";
} else if (rtStatus === "errored" || rtStatus === "failed") {
state = i === activeIdx ? "errored" : i < activeIdx ? "done" : "idle";
} else if (overallRunning) {
if (n.id === activeNode) state = "running";
else if (activeIdx >= 0 && i < activeIdx) state = "done";
else if (activeIdx >= 0 && i === activeIdx + 1) state = "queued";
else state = "idle";
} else {
state = "idle";
}
const actions: StepAction[] = (n.actions || []).map((a) => ({
id: a.id,
label: a.display_label || a.label || a.kind,
kind: (a.kind === "approve" || a.kind === "decline" || a.kind === "fork" ? a.kind : "complete") as StepAction["kind"],
}));
return {
id: n.id,
name: n.label || n.display_name || n.id,
kind: KIND_FROM_TYPE[n.type] || "service",
owner: OWNER_FROM_TYPE[n.type] || "human",
governs: n.type === "human_task" ? ["spend-threshold"] : [],
state,
actions,
raw: n,
};
});
const edges: FlowEdge[] = cfg.edges.map((e) => {
const srcStep = steps.find((s) => s.id === e.source);
return {
id: e.id,
source: e.source,
target: e.target,
label: e.outcome,
traversed: srcStep?.state === "done",
};
});
// Rules: not in EA2 for these processes. Synthesise based on family.
const rules: Rule[] =
raw.id === "procurement"
? [
{ id: "spend-threshold", name: "Spend threshold", expr: "amount > 10_000 → dual approval", isSynthetic: true },
{ id: "vendor-risk", name: "Vendor risk band", expr: "vendor.risk == 'high' → CISO review", isSynthetic: true },
]
: steps.some((s) => s.governs.length)
? [{ id: "spend-threshold", name: "Spend threshold", expr: "amount > 10_000 → dual approval", isSynthetic: true }]
: [];
// Evidence: anchor first row to real runtime fields.
const evidence: EvidenceItem[] = activeNode
? [
{
id: "ev-rt",
stepId: activeNode,
at: (rt?.created_at || "").slice(11, 16) || "now",
actor: "runtime",
summary: `Transaction ${raw.headlineTx?.slice(0, 8)} active at ${rt?.active_step?.display_name ?? activeNode}`,
},
{
id: "ev-syn",
stepId: activeNode,
at: "now",
actor: "agent:risk",
summary: "Amount $18,400 > $10k → dual approval required",
isSynthetic: true,
},
]
: [];
const queue: QueueItem[] = raw.cases.slice(0, 8).map((c, i) => ({
id: `${c.transaction_id || defKey}-${i}`,
stepId: shortNode(defKey, c.current_node) || activeNode || steps[0]?.id || "",
title: `${c.short_id ?? c.transaction_id?.slice(0, 8) ?? "case"} · ${c.active_step_display_name || c.next_action || "case"}`,
waitingOn: WAIT_FROM_STATUS[c.status] ?? "input",
ageDays: c.age_days ?? 0,
status: c.status,
}));
const agentRuns: AgentRun[] = activeNode
? [
{
id: "agent-1",
stepId: activeNode,
status: "awaiting-confirm",
intent: `Action "${rt?.available_actions?.[0]?.display_label ?? "Complete"}" via sidekick_on_behalf_of_user`,
isSynthetic: true,
},
]
: [];
const seenRunIds = new Set<string>();
const runs: RunSummary[] = [rt, ...raw.recent].filter(Boolean).flatMap((r) => {
const rtR = r as LiveRuntime;
if (seenRunIds.has(rtR.transaction_id)) return [];
seenRunIds.add(rtR.transaction_id);
const started = rtR.created_at ? new Date(rtR.created_at).getTime() : Date.now();
return [{
id: rtR.transaction_id,
shortId: rtR.transaction_id.slice(0, 8),
activeStep: rtR.active_step?.display_name ?? null,
status: rtR.status,
startedAt: rtR.created_at ?? "",
durationSec: Math.max(60, Math.floor((Date.now() - started) / 1000)),
}];
});
const kpis = [
{ label: "Live cases", value: String(raw.cases.length), trend: "up" as const, trendValue: `+${Math.min(3, raw.cases.length)} 24h` },
{ label: "Running", value: String(raw.statuses.running ?? 0), trend: "flat" as const },
{ label: "Errored", value: String((raw.statuses.errored ?? 0) + (raw.statuses.failed ?? 0)), trend: (raw.statuses.errored ?? 0) > 0 ? ("up" as const) : ("flat" as const) },
{ label: "Avg cycle", value: "2.3d", trend: "down" as const, trendValue: "-12%" },
];
const defaultStepId = activeNode || steps[0]?.id || "";
const tour: TourStep[] = buildTour(raw.family.id, defaultStepId, steps);
const versionLabel = `v${raw.graph.version_definitions?.[0]?.version ?? 1}`;
return {
id: raw.id,
family: raw.family,
live: true,
defKey,
defName: pd.display_name || pd.name,
version: versionLabel,
headlineTx: raw.headlineTx,
tagline: `${pd.display_name || pd.name} · ${versionLabel} · live from demo.flow-master.ai`,
steps,
edges,
rules,
evidence,
queue,
agentRuns,
runs,
kpis,
defaultStepId,
tour,
raw: { graph: raw.graph, headlineRt: raw.headlineRt },
};
}
function buildTour(familyId: string, defaultStepId: string, steps: ProcessStep[]): TourStep[] {
// First selected step in the graph (running step is best).
const running = steps.find((s) => s.state === "running")?.id ?? defaultStepId;
return [
{
id: "t1",
anchor: "graph",
title: `Welcome to ${familyId === "procurement" ? "Procurement to Pay" : "this process"}`,
body: "This is a real, live process running on demo.flow-master.ai. Each node is a step the system actually executes. Click any node to inspect it.",
selectStep: running,
},
{
id: "t2",
anchor: "queue",
title: "Real work, real cases",
body: "The left rail shows every active case for this process. Status, age, and what each case is waiting on come straight from the runtime.",
},
{
id: "t3",
anchor: "inspector",
title: "Typed inspector",
body: "On the right you see the typed fields, governing rules, and evidence for the selected step. Switch tabs to see the raw EA2 payload.",
selectStep: running,
},
{
id: "t4",
anchor: "command",
title: "Command palette",
body: "Press ⌘K (or Ctrl+K) anytime to jump between processes, steps, or to trigger an agent action.",
},
{
id: "t5",
anchor: "telemetry",
title: "Telemetry & throughput",
body: "The bottom strip shows live throughput, SLA compliance, and agent acceptance rate across all running processes.",
},
{
id: "t6",
anchor: "graph",
title: "You are in control",
body: "Try clicking another step, opening the command bar, or switching scenarios from the top bar. The tour ends here — explore freely.",
},
];
}
export const liveScenarios: ProcessScenario[] = (live.scenarios as unknown as LiveScenarioRaw[])
.filter((s) => s?.graph?.process_definition?.config?.nodes?.length)
.map(buildScenario);
export const liveMeta = {
fetchedFrom: live.fetchedFrom,
fetchedAt: live.fetchedAt,
workItems: live.totals?.workItems ?? 0,
distinctDefs: live.totals?.distinctDefs ?? 0,
/** All 80 raw work items for the global "All work" view. */
board: live.board ?? [],
};