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
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
// Tests the in-browser API client against a stubbed fetch.
|
||||
// We don't hit the real backend here — that's covered by the smoke test
|
||||
// (`pnpm qa:smoke` toggles live mode and asserts the network call resolves).
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { api } from "./api";
|
||||
|
||||
function mockFetch(handlers: Array<(url: string, init: RequestInit) => Response | undefined>) {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
calls.push({ url, init });
|
||||
for (const h of handlers) {
|
||||
const r = h(url, init);
|
||||
if (r) return r;
|
||||
}
|
||||
return new Response("not stubbed", { status: 500 });
|
||||
}) as unknown as typeof fetch;
|
||||
// sessionStorage stub
|
||||
const store = new Map<string, string>();
|
||||
globalThis.sessionStorage = {
|
||||
getItem: (k) => store.get(k) ?? null,
|
||||
setItem: (k, v) => { store.set(k, String(v)); },
|
||||
removeItem: (k) => { store.delete(k); },
|
||||
clear: () => store.clear(),
|
||||
key: () => null,
|
||||
length: 0,
|
||||
} as Storage;
|
||||
return calls;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// reset between tests
|
||||
api.clearToken();
|
||||
});
|
||||
|
||||
describe("api client", () => {
|
||||
it("ping() returns ok with email on success", async () => {
|
||||
mockFetch([
|
||||
(url) => url.endsWith("/api/v1/auth/dev-login")
|
||||
? new Response(JSON.stringify({ access_token: "T1" }), { status: 200, headers: { "Content-Type": "application/json" } })
|
||||
: undefined,
|
||||
(url) => url.endsWith("/api/v1/auth/me")
|
||||
? new Response(JSON.stringify({ user_id: "u1", tenant_id: "t1", email: "dev@flow-master.ai" }), { status: 200, headers: { "Content-Type": "application/json" } })
|
||||
: undefined,
|
||||
]);
|
||||
const r = await api.ping();
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.user).toBe("dev@flow-master.ai");
|
||||
});
|
||||
|
||||
it("ping() returns ok=false with reason when login fails", async () => {
|
||||
mockFetch([
|
||||
(url) => url.endsWith("/api/v1/auth/dev-login")
|
||||
? new Response("nope", { status: 401 })
|
||||
: undefined,
|
||||
]);
|
||||
const r = await api.ping();
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toMatch(/401/);
|
||||
});
|
||||
|
||||
it("workItems() returns the items array", async () => {
|
||||
mockFetch([
|
||||
(url) => url.endsWith("/api/v1/auth/dev-login")
|
||||
? new Response(JSON.stringify({ access_token: "T2" }), { status: 200 })
|
||||
: undefined,
|
||||
(url) => url.includes("/api/ea2/work-items")
|
||||
? new Response(JSON.stringify({ items: [{ transaction_id: "tx1", status: "running", definition_key: "k1" }] }), { status: 200 })
|
||||
: undefined,
|
||||
]);
|
||||
const items = await api.workItems();
|
||||
expect(items.length).toBe(1);
|
||||
expect(items[0].transaction_id).toBe("tx1");
|
||||
});
|
||||
|
||||
it("graph() returns null on 404 (typed-safe failure mode)", async () => {
|
||||
mockFetch([
|
||||
(url) => url.endsWith("/api/v1/auth/dev-login")
|
||||
? new Response(JSON.stringify({ access_token: "T3" }), { status: 200 })
|
||||
: undefined,
|
||||
(url) => url.includes("/api/ea2/process-definitions/")
|
||||
? new Response("not found", { status: 404 })
|
||||
: undefined,
|
||||
]);
|
||||
const g = await api.graph("does-not-exist");
|
||||
expect(g).toBeNull();
|
||||
});
|
||||
|
||||
it("re-logs in after 401 then succeeds", async () => {
|
||||
let metHits = 0;
|
||||
const calls = mockFetch([
|
||||
(url) => url.endsWith("/api/v1/auth/dev-login")
|
||||
? new Response(JSON.stringify({ access_token: `T${metHits + 1}` }), { status: 200 })
|
||||
: undefined,
|
||||
(url) => {
|
||||
if (url.endsWith("/api/v1/auth/me")) {
|
||||
metHits += 1;
|
||||
if (metHits === 1) return new Response("expired", { status: 401 });
|
||||
return new Response(JSON.stringify({ user_id: "u", tenant_id: "t", email: "dev@flow-master.ai" }), { status: 200 });
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
]);
|
||||
const r = await api.ping();
|
||||
expect(r.ok).toBe(true);
|
||||
expect(calls.filter((c) => c.url.endsWith("/dev-login")).length).toBe(2); // initial + retry login
|
||||
});
|
||||
});
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
// Real in-browser API client for demo.flow-master.ai.
|
||||
// - dev-login → bearer (cached in sessionStorage, refresh on 401)
|
||||
// - typed wrappers for the endpoints Mission Control needs
|
||||
// - all fetches honor an AbortSignal so unmounts cancel cleanly
|
||||
// - never throws into render: returns { ok, error } shapes
|
||||
|
||||
export interface ApiConfig {
|
||||
baseUrl: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// In dev, route via vite proxy (same-origin) to dodge CORS; in production
|
||||
// the build is deployed at the same origin as the backend, so an empty
|
||||
// baseUrl is correct.
|
||||
const isDev = import.meta.env.DEV;
|
||||
const DEFAULT_CONFIG: ApiConfig = {
|
||||
baseUrl: import.meta.env.VITE_FM_BASE || (isDev ? "" : "https://demo.flow-master.ai"),
|
||||
email: import.meta.env.VITE_FM_EMAIL || "dev@flow-master.ai",
|
||||
};
|
||||
|
||||
const TOKEN_KEY = "fm.mc.token.v1";
|
||||
|
||||
let inflightLogin: Promise<string> | null = null;
|
||||
|
||||
async function login(cfg: ApiConfig, signal?: AbortSignal): Promise<string> {
|
||||
if (inflightLogin) return inflightLogin;
|
||||
inflightLogin = (async () => {
|
||||
const r = await fetch(`${cfg.baseUrl}/api/v1/auth/dev-login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: cfg.email }),
|
||||
signal,
|
||||
});
|
||||
if (!r.ok) throw new Error(`dev-login ${r.status}`);
|
||||
const body = (await r.json()) as { access_token: string };
|
||||
sessionStorage.setItem(TOKEN_KEY, body.access_token);
|
||||
return body.access_token;
|
||||
})();
|
||||
try {
|
||||
return await inflightLogin;
|
||||
} finally {
|
||||
inflightLogin = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function withAuth<T>(
|
||||
cfg: ApiConfig,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
let token = sessionStorage.getItem(TOKEN_KEY);
|
||||
if (!token) token = await login(cfg, signal);
|
||||
const doFetch = async () =>
|
||||
fetch(`${cfg.baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers || {}),
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal,
|
||||
});
|
||||
let r = await doFetch();
|
||||
if (r.status === 401) {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
token = await login(cfg, signal);
|
||||
r = await doFetch();
|
||||
}
|
||||
if (!r.ok) {
|
||||
const text = await r.text().catch(() => "");
|
||||
throw new Error(`${path} → ${r.status} ${text.slice(0, 120)}`);
|
||||
}
|
||||
return (await r.json()) as T;
|
||||
}
|
||||
|
||||
export interface WorkItem {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface ProcessGraph {
|
||||
process_definition: {
|
||||
_key: string;
|
||||
name: string;
|
||||
display_name?: string;
|
||||
hub?: string;
|
||||
config: { nodes: unknown[]; edges: unknown[]; org_id?: string };
|
||||
};
|
||||
version_definitions?: Array<{ version: number; approved_at?: string }>;
|
||||
}
|
||||
|
||||
export interface RuntimeTransaction {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface AuthMe {
|
||||
user_id: string;
|
||||
tenant_id: string;
|
||||
email: string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
config: DEFAULT_CONFIG,
|
||||
|
||||
async me(signal?: AbortSignal): Promise<AuthMe> {
|
||||
return withAuth<AuthMe>(this.config, "/api/v1/auth/me", {}, signal);
|
||||
},
|
||||
|
||||
async workItems(signal?: AbortSignal): Promise<WorkItem[]> {
|
||||
const body = await withAuth<{ items?: WorkItem[] }>(
|
||||
this.config,
|
||||
"/api/ea2/work-items?view=all",
|
||||
{},
|
||||
signal,
|
||||
);
|
||||
return body.items ?? [];
|
||||
},
|
||||
|
||||
async graph(defKey: string, signal?: AbortSignal): Promise<ProcessGraph | null> {
|
||||
try {
|
||||
return await withAuth<ProcessGraph>(
|
||||
this.config,
|
||||
`/api/ea2/process-definitions/${defKey}/graph`,
|
||||
{},
|
||||
signal,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async transaction(txId: string, signal?: AbortSignal): Promise<RuntimeTransaction | null> {
|
||||
try {
|
||||
return await withAuth<RuntimeTransaction>(
|
||||
this.config,
|
||||
`/api/runtime/transactions/${txId}`,
|
||||
{},
|
||||
signal,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Probe whether the backend is reachable. Never throws. */
|
||||
async ping(signal?: AbortSignal): Promise<{ ok: boolean; reason?: string; user?: string }> {
|
||||
try {
|
||||
const me = await this.me(signal);
|
||||
return { ok: true, user: me.email };
|
||||
} catch (e) {
|
||||
return { ok: false, reason: (e as Error).message };
|
||||
}
|
||||
},
|
||||
|
||||
clearToken() {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
// In-browser equivalent of fetch_scenarios.mjs:
|
||||
// pulls live work items → groups by definition_key → fetches each graph and
|
||||
// representative runtime → maps into ProcessScenario[] using the same shape
|
||||
// as the bundled snapshot loader (`data/live.ts`).
|
||||
import { api, type WorkItem, type ProcessGraph, type RuntimeTransaction } from "./api";
|
||||
import type {
|
||||
AgentRun, EvidenceItem, FlowEdge, ProcessScenario, ProcessStep,
|
||||
QueueItem, Rule, RuntimeState, RunSummary, StepAction, StepKind, TourStep,
|
||||
} from "../data/types";
|
||||
|
||||
const KIND_FROM_TYPE: Record<string, StepKind> = {
|
||||
start: "start",
|
||||
end: "end",
|
||||
human_task: "human",
|
||||
agent_task: "agent",
|
||||
service_task: "service",
|
||||
system_task: "service",
|
||||
};
|
||||
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;
|
||||
|
||||
interface LiveNode {
|
||||
id: string;
|
||||
type: string;
|
||||
label?: string;
|
||||
display_name?: string;
|
||||
actions?: Array<{ id: string; display_label?: string; label?: string; kind: string }>;
|
||||
}
|
||||
interface LiveEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
outcome?: string;
|
||||
}
|
||||
|
||||
function buildScenarioFromGraph(
|
||||
family: ProcessScenario["family"],
|
||||
defKey: string,
|
||||
graph: ProcessGraph,
|
||||
cases: WorkItem[],
|
||||
headlineRt: RuntimeTransaction | null,
|
||||
recent: RuntimeTransaction[],
|
||||
): ProcessScenario {
|
||||
const pd = graph.process_definition;
|
||||
const cfg = pd.config as { nodes: LiveNode[]; edges: LiveEdge[] };
|
||||
const activeNode = headlineRt ? shortNode(defKey, headlineRt.active_step?.step_definition_id) : null;
|
||||
const activeIdx = cfg.nodes.findIndex((n) => n.id === activeNode);
|
||||
const rtStatus = headlineRt?.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 stepById = new Map(steps.map((s) => [s.id, s]));
|
||||
const edges: FlowEdge[] = cfg.edges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
label: e.outcome,
|
||||
traversed: stepById.get(e.source)?.state === "done",
|
||||
}));
|
||||
|
||||
const rules: Rule[] = steps.some((s) => s.governs.length)
|
||||
? [{ id: "spend-threshold", name: "Spend threshold", expr: "amount > 10_000 → dual approval", isSynthetic: true }]
|
||||
: [];
|
||||
|
||||
const evidence: EvidenceItem[] = activeNode
|
||||
? [
|
||||
{
|
||||
id: "ev-rt",
|
||||
stepId: activeNode,
|
||||
at: (headlineRt?.created_at || "").slice(11, 16) || "now",
|
||||
actor: "runtime",
|
||||
summary: `Transaction ${headlineRt?.transaction_id?.slice(0, 8)} active at ${headlineRt?.active_step?.display_name ?? activeNode}`,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const queue: QueueItem[] = 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 "${headlineRt?.available_actions?.[0]?.display_label ?? "Complete"}" via sidekick_on_behalf_of_user`,
|
||||
isSynthetic: true,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const seenRunIds = new Set<string>();
|
||||
const runs: RunSummary[] = [headlineRt, ...recent].filter(Boolean).flatMap((rt) => {
|
||||
const r = rt as RuntimeTransaction;
|
||||
if (seenRunIds.has(r.transaction_id)) return [];
|
||||
seenRunIds.add(r.transaction_id);
|
||||
const started = r.created_at ? new Date(r.created_at).getTime() : Date.now();
|
||||
return [{
|
||||
id: r.transaction_id,
|
||||
shortId: r.transaction_id.slice(0, 8),
|
||||
activeStep: r.active_step?.display_name ?? null,
|
||||
status: r.status,
|
||||
startedAt: r.created_at ?? "",
|
||||
durationSec: Math.max(60, Math.floor((Date.now() - started) / 1000)),
|
||||
}];
|
||||
});
|
||||
|
||||
const statuses: Record<string, number> = {};
|
||||
for (const c of cases) statuses[c.status] = (statuses[c.status] || 0) + 1;
|
||||
|
||||
const kpis = [
|
||||
{ label: "Live cases", value: String(cases.length), trend: "up" as const, trendValue: `${Math.min(3, cases.length)} now` },
|
||||
{ label: "Running", value: String(statuses.running ?? 0), trend: "flat" as const },
|
||||
{ label: "Errored", value: String((statuses.errored ?? 0) + (statuses.failed ?? 0)), trend: (statuses.errored ?? 0) > 0 ? ("up" as const) : ("flat" as const) },
|
||||
{ label: "Avg cycle", value: avgCycle(cases), trend: "down" as const, trendValue: "" },
|
||||
];
|
||||
|
||||
const defaultStepId = activeNode || steps[0]?.id || "";
|
||||
const versionLabel = `v${graph.version_definitions?.[0]?.version ?? 1}`;
|
||||
|
||||
return {
|
||||
id: family.id,
|
||||
family,
|
||||
live: true,
|
||||
defKey,
|
||||
defName: pd.display_name || pd.name,
|
||||
version: versionLabel,
|
||||
headlineTx: headlineRt?.transaction_id ?? null,
|
||||
tagline: `${pd.display_name || pd.name} · ${versionLabel} · live from demo.flow-master.ai`,
|
||||
steps,
|
||||
edges,
|
||||
rules,
|
||||
evidence,
|
||||
queue,
|
||||
agentRuns,
|
||||
runs,
|
||||
kpis,
|
||||
defaultStepId,
|
||||
tour: buildTour(family.id, defaultStepId),
|
||||
raw: { graph, headlineRt },
|
||||
};
|
||||
}
|
||||
|
||||
function avgCycle(cases: WorkItem[]): string {
|
||||
const ages = cases.map((c) => c.age_days ?? 0).filter((a) => a > 0);
|
||||
if (!ages.length) return "—";
|
||||
const avg = ages.reduce((s, a) => s + a, 0) / ages.length;
|
||||
return `${avg.toFixed(1)}d`;
|
||||
}
|
||||
|
||||
function buildTour(familyId: string, defaultStepId: string): TourStep[] {
|
||||
return [
|
||||
{ id: "t1", anchor: "graph", title: "Live process at a glance", body: "This graph is the real, executing definition for this scenario. Each node maps to a runtime step the backend is driving.", selectStep: defaultStepId },
|
||||
{ id: "t2", anchor: "queue", title: "Real cases, real states", body: "The left rail mirrors the live EA2 work-item board for this definition. Counts, statuses, and ages come straight from the runtime API." },
|
||||
{ id: "t3", anchor: "inspector", title: "Typed inspector", body: "Click any step to see its typed fields, governing rules, and evidence trail. The Raw tab gives you the EA2 payload verbatim." },
|
||||
{ id: "t4", anchor: "command", title: "⌘K command palette", body: `Press ⌘K to jump between scenarios, steps, or open the guided tour for ${familyId}.` },
|
||||
{ id: "t5", anchor: "telemetry", title: "Throughput rollup", body: "The bottom strip rolls up running/errored cases across every scenario you have open." },
|
||||
{ id: "t6", anchor: "graph", title: "Explore", body: "That's the full loop. Switch scenarios at the top, or open ⌘K to navigate freely." },
|
||||
];
|
||||
}
|
||||
|
||||
interface CandidateBucket {
|
||||
key: string;
|
||||
cases: WorkItem[];
|
||||
statuses: Record<string, number>;
|
||||
hubs: Set<string>;
|
||||
}
|
||||
|
||||
const FAMILIES = [
|
||||
{ id: "procurement", label: "Procurement to Pay", subtitle: "Requisition → PO → 3-way match", accent: "#3b82f6", re: /procure|purchas|pr_to_po|atlas|requisition|po\b/i },
|
||||
{ id: "ar", label: "Accounts Receivable", subtitle: "Refunds, credits & collections", accent: "#10b981", re: /refund|credit|collect|receivable|invoice/i },
|
||||
{ id: "hcm", label: "People Operations", subtitle: "Onboard · Offboard · Leave", accent: "#a855f7", re: /onboard|offboard|hire|hcm|employee|leave|payroll|hr\b/i },
|
||||
{ id: "gl", label: "GL Close", subtitle: "Accruals, reconciliations, journals", accent: "#f59e0b", re: /close|ledger|journal|accrual|reconcil|gl\b/i },
|
||||
{ id: "service", label: "Service Operations", subtitle: "Tickets, incidents, support", accent: "#ef4444", re: /ticket|incident|support|service|case\b/i },
|
||||
];
|
||||
|
||||
export async function buildLiveScenariosFromApi(signal?: AbortSignal): Promise<{ scenarios: ProcessScenario[]; workItems: WorkItem[]; distinctDefs: number }> {
|
||||
const workItems = await api.workItems(signal);
|
||||
|
||||
// Bucket by definition_key with meta.
|
||||
const byDef = new Map<string, CandidateBucket>();
|
||||
for (const w of workItems) {
|
||||
const k = w.definition_key;
|
||||
if (!k) continue;
|
||||
if (!byDef.has(k)) byDef.set(k, { key: k, cases: [], statuses: {}, hubs: new Set() });
|
||||
const b = byDef.get(k)!;
|
||||
b.cases.push(w);
|
||||
b.statuses[w.status] = (b.statuses[w.status] || 0) + 1;
|
||||
if (w.hub) b.hubs.add(w.hub);
|
||||
}
|
||||
|
||||
// Candidates with ≥ 2 cases OR 1 running.
|
||||
const candidates = [...byDef.values()].filter((d) => d.cases.length >= 2 || (d.statuses.running ?? 0) >= 1);
|
||||
candidates.sort((a, b) => b.cases.length - a.cases.length);
|
||||
|
||||
// For each candidate fetch graph + a couple runtimes.
|
||||
interface Enriched { bucket: CandidateBucket; graph: ProcessGraph; headlineRt: RuntimeTransaction | null; recent: RuntimeTransaction[] }
|
||||
const enriched: Enriched[] = [];
|
||||
for (const c of candidates.slice(0, 20)) {
|
||||
if (signal?.aborted) break;
|
||||
const graph = await api.graph(c.key, signal);
|
||||
if (!graph?.process_definition?.config?.nodes?.length) continue;
|
||||
const headlineCase =
|
||||
c.cases.find((w) => w.status === "running") ||
|
||||
c.cases.find((w) => w.status === "waiting_for_user") ||
|
||||
c.cases.find((w) => w.status === "errored" || w.status === "failed") ||
|
||||
c.cases[0];
|
||||
const headlineRt = headlineCase?.transaction_id ? await api.transaction(headlineCase.transaction_id, signal) : null;
|
||||
const recent: RuntimeTransaction[] = [];
|
||||
for (const w of c.cases.slice(0, 6)) {
|
||||
if (signal?.aborted) break;
|
||||
if (!w.transaction_id || w.transaction_id === headlineCase?.transaction_id) continue;
|
||||
const r = await api.transaction(w.transaction_id, signal);
|
||||
if (r) recent.push(r);
|
||||
if (recent.length >= 3) break;
|
||||
}
|
||||
enriched.push({ bucket: c, graph, headlineRt, recent });
|
||||
}
|
||||
|
||||
// Classify into families.
|
||||
const used = new Set<string>();
|
||||
const scenarios: ProcessScenario[] = [];
|
||||
for (const fam of FAMILIES) {
|
||||
const pick = enriched
|
||||
.filter((e) => !used.has(e.bucket.key) && fam.re.test(`${e.graph.process_definition.display_name ?? e.graph.process_definition.name} ${[...e.bucket.hubs].join(" ")}`))
|
||||
.sort((a, b) => b.bucket.cases.length - a.bucket.cases.length)[0];
|
||||
if (!pick) continue;
|
||||
used.add(pick.bucket.key);
|
||||
scenarios.push(
|
||||
buildScenarioFromGraph(
|
||||
{ id: fam.id, label: fam.label, subtitle: fam.subtitle, accent: fam.accent },
|
||||
pick.bucket.key,
|
||||
pick.graph,
|
||||
pick.bucket.cases,
|
||||
pick.headlineRt,
|
||||
pick.recent,
|
||||
),
|
||||
);
|
||||
}
|
||||
// Top-up with remaining largest buckets if we have fewer than 4.
|
||||
const leftovers = enriched.filter((e) => !used.has(e.bucket.key)).sort((a, b) => b.bucket.cases.length - a.bucket.cases.length);
|
||||
while (scenarios.length < 4 && leftovers.length) {
|
||||
const e = leftovers.shift()!;
|
||||
scenarios.push(
|
||||
buildScenarioFromGraph(
|
||||
{
|
||||
id: `extra-${scenarios.length}`,
|
||||
label: e.graph.process_definition.display_name || e.graph.process_definition.name,
|
||||
subtitle: `${e.bucket.cases.length} live cases`,
|
||||
accent: "#64748b",
|
||||
},
|
||||
e.bucket.key,
|
||||
e.graph,
|
||||
e.bucket.cases,
|
||||
e.headlineRt,
|
||||
e.recent,
|
||||
),
|
||||
);
|
||||
used.add(e.bucket.key);
|
||||
}
|
||||
return { scenarios, workItems, distinctDefs: byDef.size };
|
||||
}
|
||||
Reference in New Issue
Block a user