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
322 lines
10 KiB
TypeScript
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 ?? [],
|
|
};
|