feat(canvas): strip demo data, EA2 is the only source (#3)
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled

This commit was merged in pull request #3.
This commit is contained in:
2026-06-14 21:08:48 +00:00
parent 3df8dd3e36
commit 1df3122eef
10 changed files with 79 additions and 606 deletions
+33 -140
View File
@@ -1,168 +1,61 @@
# FlowMaster — Mission Control # FlowMaster — Canvas
**Live at https://canvas.flow-master.ai** · [source on Gitea](https://gitea.flow-master.ai/shad/flowmaster-mission-control-demo) **Live at https://canvas.flow-master.ai** · operations cockpit for every process the company runs.
The proper FlowMaster frontend: an operations cockpit for every process the Canvas is the FlowMaster operator surface. Industrial-blueprint doctrine:
company runs. Industrial-blueprint doctrine — paper canvas, navy frame, paper canvas, navy frame, amber accent, 1px hairlines, square edges,
amber accent, 1px hairlines, square edges, monospace operational density. monospace operational density. People, Finance, Procurement, and IT in
one frame.
## What this is (and isn't) ## What it is
**Is:** - React 19 single-page app (Vite + ReactFlow + cmdk + framer-motion +
zustand + dagre).
- A single-page React 19 app (Vite + ReactFlow + cmdk + framer-motion + zustand - Connected directly to EA2 in the browser via the API client at
+ dagre). `src/lib/api.ts`. There is one data source: live EA2. No bundled
- Polished command-center for any FlowMaster process: graph, queue, inspector, snapshot, no synthetic blueprints.
tour, command palette, live-mode toggle, run history. - Scenes: Landing, Mission Control, Run History, Process Studio, Hubs,
- Two data modes: Agent, Settings, Login, SSO callback.
- **SNAPSHOT** (default): bundled `src/scenarios.json`, captured from - Agent scene has a chat surface with per-tenant local memory (`src/lib/agentMemory.ts`)
`demo.flow-master.ai`. Fast, offline, deterministic. and three intents: navigate, retain a fact, start a process draft.
- **LIVE**: in-browser fetch from the same backend via the API client at - Hubs surface departmental lenses (People, Finance, Procurement, IT).
`src/lib/api.ts` (dev-login → bearer → `/api/ea2/work-items` - Three view-as personas (CEO, Head of People, Head of IT) re-sign in
`/api/ea2/process-definitions/{k}/graph``/api/runtime/transactions/{id}`). under the persona's email so the operator UI reflects the seat.
Loading and error states are wired through `src/scenes/MissionControl.tsx`.
**Isn't:**
- Not a replacement for the existing demo at https://demo.flow-master.ai
(Next.js fm-shell). This is a separate command-center experience.
- Not multi-tenant. No auth UI, no tenancy switcher, no settings.
- Not wired to actually mutate state. Every action button (start runtime,
dispatch agent, approve, decline, confirm) is **preview-only** and fires a
toast naming the endpoint that would make it real.
## Scenarios in the catalog
| id | mode | source |
|-------------|-----------|-------------------------------------------------------------------|
| procurement | live | Purchase Requisition → PO (`pr_to_po_def` on demo.flow-master.ai) |
| extra-1 | live | Atlas F1 Fresh (procurement variant) |
| extra-2 | live | Atlas F1 Fresh (procurement variant) |
| ar | blueprint | AR · Customer Refund Approval |
| hcm | blueprint | HCM · New Hire Onboarding |
| gl | blueprint | GL · Period-End Close |
| service | blueprint | Service Ops · Customer Incident |
**Live** = backed by a real EA2 process definition currently in the demo
backend, with real runtime transactions and a real work-item queue.
**Blueprint** = hand-modelled in the same typed format. Identical UI surface;
the backend just doesn't have a runnable definition for it yet. Internal
metadata carries `isSynthetic: true` for provenance audits.
## How honesty is enforced in this code
This pass came out of an Oracle review that called out theatrical bits in the
prior version. Safeguards now in place:
- **No invented numbers.** `src/components/Telemetry.tsx` derives every value
from the active scenarios (`SLA = 1 - errored / cases`, agent acceptance =
fraction of agent runs not in `proposed`). The throughput sparkline plots
the actual `running` rollup over time, not a sine wave. The "ui tick" dot
is labelled as a UI heartbeat and tooltip-named as such.
- **No fake buttons.** Every preview-only action fires a toast that names the
endpoint that would make it real, and carries a `preview` marker.
- **No misleading tour copy.** Blueprint tours frame the scenario as an
industry blueprint, not "we don't have this yet".
- **Mode is always visible.** Topbar has a `SNAPSHOT`/`LIVE` pill, last-fetch
age, and a refresh button when live.
## Live-mode mechanics (the CORS gotcha)
`demo.flow-master.ai` does not advertise CORS headers for arbitrary origins.
`src/lib/api.ts` therefore uses an **empty `baseUrl` everywhere** (both dev
and prod). All `/api/*` requests are same-origin from the browser's
perspective; whatever is serving the page is responsible for proxying them
to the backend.
- **Dev** (`pnpm dev`): `vite.config.ts` proxies `/api/*` to
`${VITE_FM_BASE:-https://demo.flow-master.ai}`.
- **Prod** (Docker image): the bundled `nginx.conf` reverse-proxies `/api/*`
to `https://demo.flow-master.ai`. The image is intended to sit behind the
`canvas.flow-master.ai` ingress (see `FM06/flowmaster-ops` overlay).
- **Anywhere else**: set `VITE_FM_BASE=https://your-backend` at build time
and accept that browsers will reject the cross-origin call. Live mode then
fails gracefully — `setMode("live")` catches the error, raises an
`mc-banner-err` banner + error toast, and falls back to snapshot.
## Run, test, build ## Run, test, build
```bash ```bash
pnpm install pnpm install
# refresh the bundled snapshot from demo.flow-master.ai
pnpm fetch:scenarios
# dev (with backend proxy for live mode) # dev (with backend proxy for live mode)
pnpm dev # → http://127.0.0.1:5173 pnpm dev # → http://127.0.0.1:5173
# tests # tests
pnpm test # vitest, 22 tests across api, live, pnpm test # vitest
# synthetic, layout, store
# build # build
pnpm build # tsc + vite, single chunk ~225 KB gz pnpm build # tsc + vite, single chunk ~234 KB gz
# end-to-end smoke + screenshots
pnpm qa:smoke # playwright headless, 28 assertions
# DOM layout audit
pnpm qa:layout
```
## File map
```
src/
├── data/ # ProcessScenario domain + snapshot + blueprint catalog
├── lib/ # API client + live-scenario builder (+ tests)
├── state/store.ts # zustand: scene, mode, scenario, tour, recents, toasts
├── graph/layout.ts # dagre LR auto-layout (+ tests)
├── components/ # ProcessGraph, Inspector, LeftRail, CommandBar, Tour,
│ # Telemetry, Toaster, icons
├── scenes/ # Landing, MissionControl, RunHistory
├── App.tsx # shell: topbar (mode pill / refresh / tour / ⌘K)
├── index.css # design system (~700 lines)
├── main.tsx
└── scenarios.json # cached snapshot of demo.flow-master.ai
qa/
├── smoke.mjs # Playwright e2e + 9 screenshots
└── layout_audit.mjs # programmatic clipping/overlap check
vite.config.ts # /api → demo.flow-master.ai proxy for dev
fetch_scenarios.mjs # Node script to refresh src/scenarios.json
``` ```
## Deploy ## Deploy
Production deployment is tracked in Deployment is tracked in `FM06/flowmaster-ops`, overlay
[FM06/flowmaster-ops PR #1164](https://gitea.flow-master.ai/FM06/flowmaster-ops/pulls/1164) `manifests/overlays/demo/`:
(merged), which added three resources to `manifests/overlays/demo/`:
- `mc-deployment.yaml`2-replica nginx Deployment in the `demo` namespace - `mc-deployment.yaml` — replicated nginx Deployment
- `mc-service.yaml` — ClusterIP service on port 80 - `mc-service.yaml` — ClusterIP service on port 80
- `mc-ingress.yaml` — Traefik ingress at `canvas.flow-master.ai` with cert-manager DNS-01 cert - `mc-ingress.yaml` — Traefik ingress at `canvas.flow-master.ai` with
cert-manager DNS-01 cert
Cloudflare A record `canvas.flow-master.ai → 65.21.71.186` was added at the nginx in the image reverse-proxies `/api/*` to `https://demo.flow-master.ai`
same time. The cluster certifies with cert-manager (Let's Encrypt DNS-01) so the SPA is same-origin (no CORS).
and Traefik fronts the nginx pods. nginx reverse-proxies `/api/*` to
`https://demo.flow-master.ai` so the SPA is same-origin (no CORS).
```bash ```bash
pnpm build # builds dist/ pnpm build
docker build -t gitea.flow-master.ai/shad/mission-control-demo:sha-<git> . docker buildx build --platform linux/amd64 \
-f Dockerfile.runtime \
-t gitea.flow-master.ai/shad/mission-control-demo:sha-<git> \
--load .
docker push gitea.flow-master.ai/shad/mission-control-demo:sha-<git> docker push gitea.flow-master.ai/shad/mission-control-demo:sha-<git>
# Then bump the image pin in manifests/overlays/demo/mc-deployment.yaml. # Then bump the image pin in manifests/overlays/demo/mc-deployment.yaml.
``` ```
## What's intentionally not here
- Authentication UI (dev-login is used because this is a demo lane).
- Mutation endpoints (every action is preview-only and the toast names the
endpoint).
- Mobile layout (1440px-and-up demo surface).
- Route persistence / deep linking (scene + scenario live in memory).
- i18n (English only).
Small, well-scoped follow-ups — not architectural changes.
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "flowmaster-mission-control-demo", "name": "flowmaster-canvas",
"private": true, "private": true,
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
+7 -11
View File
@@ -118,21 +118,17 @@ export default function App() {
<button <button
className={`link-btn mode-toggle mode-${mode}`} className={`link-btn mode-toggle mode-${mode}`}
onClick={() => setMode(mode === "live" ? "snapshot" : "live")} onClick={() => setMode("live")}
disabled={liveLoading} disabled={liveLoading}
title={mode === "live" title={`Connected to EA2. Fetched ${liveAge ?? "—"}. Click to refresh.`}
? `Live mode is on. Fetched ${liveAge}. Click to drop back to snapshot.`
: "Click to fetch live scenarios from demo.flow-master.ai in the browser."}
> >
{liveLoading ? <span className="spin" /> : <Pulse size={12} />} {liveLoading ? <span className="spin" /> : <Pulse size={12} />}
<span className={`mode-pill mode-${mode}`}>{mode === "live" ? "LIVE" : "SNAPSHOT"}</span> <span className={`mode-pill mode-${mode}`}>EA2</span>
{mode === "live" && liveAge && <span className="topbar-age">{liveAge}</span>} {liveAge && <span className="topbar-age">{liveAge}</span>}
</button>
<button className="link-btn" onClick={refreshLive} disabled={liveLoading} title="Re-fetch processes from EA2">
<Refresh size={12} /> Refresh
</button> </button>
{mode === "live" && (
<button className="link-btn" onClick={refreshLive} disabled={liveLoading} title="Re-fetch live scenarios">
<Refresh size={12} /> Refresh
</button>
)}
<button <button
className={`link-btn${consoleOpen ? " is-on" : ""}`} className={`link-btn${consoleOpen ? " is-on" : ""}`}
onClick={() => setConsoleOpen(!consoleOpen)} onClick={() => setConsoleOpen(!consoleOpen)}
+2 -6
View File
@@ -92,18 +92,14 @@ export default function CommandBar() {
{mode === "live" ? ( {mode === "live" ? (
<Command.Item onSelect={() => { refreshLive(); close(); }}> <Command.Item onSelect={() => { refreshLive(); close(); }}>
<Refresh size={13} /> Refresh live scenarios <Refresh size={13} /> Refresh live scenarios
<span className="cmd-hint">re-fetch demo.flow-master.ai</span> <span className="cmd-hint">re-fetch from EA2</span>
</Command.Item> </Command.Item>
) : ( ) : (
<Command.Item onSelect={() => { setMode("live"); close(); }}> <Command.Item onSelect={() => { setMode("live"); close(); }}>
<Refresh size={13} /> Switch to LIVE mode (fetch demo.flow-master.ai) <Refresh size={13} /> Connect to EA2
<span className="cmd-hint">in-browser</span> <span className="cmd-hint">in-browser</span>
</Command.Item> </Command.Item>
)} )}
<Command.Item onSelect={() => { setMode("snapshot"); close(); }}>
<Layers size={13} /> Switch to SNAPSHOT mode
<span className="cmd-hint">bundled JSON</span>
</Command.Item>
<Command.Item onSelect={() => { setConsoleOpen(true); close(); }}> <Command.Item onSelect={() => { setConsoleOpen(true); close(); }}>
<Layers size={13} /> Open live API console <Layers size={13} /> Open live API console
<span className="cmd-hint">stream every fetch</span> <span className="cmd-hint">stream every fetch</span>
+7 -8
View File
@@ -1,12 +1,11 @@
// Snapshot-mode catalog (bundled scenarios.json + synthetic blueprints). // Live-only scenario catalog. EA2 is the sole source of truth at runtime;
// Components should read the active catalog from the store via // liveMeta is a build-time fingerprint of the bundled scenarios.json,
// `useApp((s) => s.scenarios)` — that way live mode can hot-swap. // used only for cosmetic labels (host name, last-fetched timestamp).
import { liveScenarios, liveMeta } from "./live"; import { liveMeta } from "./live";
import { syntheticScenarios } from "./synthetic";
import type { ProcessScenario } from "./types"; import type { ProcessScenario } from "./types";
export const snapshotScenarios: ProcessScenario[] = [...liveScenarios, ...syntheticScenarios]; export const snapshotScenarios: ProcessScenario[] = [];
export const snapshotDefaultId = snapshotScenarios[0]?.id ?? ""; export const snapshotDefaultId = "";
export { liveMeta, liveScenarios, syntheticScenarios }; export { liveMeta };
export type { ProcessScenario } from "./types"; export type { ProcessScenario } from "./types";
-32
View File
@@ -1,32 +0,0 @@
import { describe, it, expect } from "vitest";
import { syntheticScenarios } from "./synthetic";
describe("syntheticScenarios", () => {
it("has exactly 4 families: ar, hcm, gl, service", () => {
expect(syntheticScenarios.map((s) => s.id).sort()).toEqual(["ar", "gl", "hcm", "service"]);
});
it("each scenario is marked live=false and has a representative running step", () => {
for (const s of syntheticScenarios) {
expect(s.live).toBe(false);
expect(s.steps.some((st) => st.state === "running")).toBe(true);
}
});
it("edges only reference existing step ids", () => {
for (const s of syntheticScenarios) {
const ids = new Set(s.steps.map((st) => st.id));
for (const e of s.edges) {
expect(ids.has(e.source)).toBe(true);
expect(ids.has(e.target)).toBe(true);
}
}
});
it("queue items reference real step ids", () => {
for (const s of syntheticScenarios) {
const ids = new Set(s.steps.map((st) => st.id));
for (const q of s.queue) expect(ids.has(q.stepId)).toBe(true);
}
});
});
-340
View File
@@ -1,340 +0,0 @@
// Hand-crafted scenarios for families not yet running on the live demo backend.
// Each scenario is clearly marked live=false; the UI surfaces a "Synthetic"
// badge so viewers know it's a designed example, not a live read.
import type {
AgentRun, FlowEdge, ProcessScenario, ProcessStep, QueueItem,
Rule, RuntimeState, RunSummary, StepKind, TourStep,
} from "./types";
interface SyntheticNode {
id: string;
name: string;
kind: StepKind;
owner: "human" | "agent" | "system";
state: RuntimeState;
governs?: string[];
actions?: { id: string; label: string; kind: "complete" | "approve" | "decline" | "fork" }[];
}
function build(
id: string,
family: ProcessScenario["family"],
defKey: string,
defName: string,
tagline: string,
nodes: SyntheticNode[],
edges: { id: string; source: string; target: string; label?: string }[],
rules: Rule[],
evidenceSeeds: { stepId: string; at: string; actor: string; summary: string }[],
queue: Omit<QueueItem, "id">[],
agentRuns: Omit<AgentRun, "id">[],
runs: RunSummary[],
kpis: ProcessScenario["kpis"],
tour: TourStep[],
): ProcessScenario {
const steps: ProcessStep[] = nodes.map((n) => ({
id: n.id,
name: n.name,
kind: n.kind,
owner: n.owner,
governs: n.governs ?? [],
state: n.state,
actions: n.actions ?? [],
}));
const stepIndex = new Map(steps.map((s, i) => [s.id, i]));
const runningIdx = steps.findIndex((s) => s.state === "running");
const builtEdges: FlowEdge[] = edges.map((e) => {
const srcIdx = stepIndex.get(e.source) ?? -1;
return {
id: e.id,
source: e.source,
target: e.target,
label: e.label,
traversed: runningIdx >= 0 ? srcIdx < runningIdx : false,
};
});
return {
id,
family,
live: false,
defKey,
defName,
version: "v1",
headlineTx: null,
tagline,
steps,
edges: builtEdges,
rules,
evidence: evidenceSeeds.map((e, i) => ({ id: `ev-${i}`, isSynthetic: true, ...e })),
queue: queue.map((q, i) => ({ id: `q-${i}`, ...q })),
agentRuns: agentRuns.map((a, i) => ({ id: `a-${i}`, isSynthetic: true, ...a })),
runs,
kpis,
defaultStepId: steps.find((s) => s.state === "running")?.id || steps[0]?.id || "",
tour,
raw: { synthetic: true, nodes, edges },
};
}
function baseTour(familyId: string): TourStep[] {
return [
{ 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 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." },
];
}
// ---------- AR · Customer Refund Approval ----------
export const arRefund = build(
"ar",
{ id: "ar", label: "Accounts Receivable", subtitle: "Refunds, credits & collections", accent: "#3d6a2c" },
"syn-ar-refund-v1",
"AR · Customer Refund Approval",
"Customer refund · risk-scored · synthetic preview",
[
{ id: "intake", name: "Refund request received", kind: "start", owner: "system", state: "done" },
{ id: "validate", name: "Validate purchase & eligibility", kind: "service", owner: "system", state: "done" },
{ id: "risk", name: "Fraud & risk scoring", kind: "agent", owner: "agent", state: "done" },
{
id: "review",
name: "Finance review",
kind: "human",
owner: "human",
state: "running",
governs: ["refund-cap", "fraud-flag"],
actions: [
{ id: "approve", label: "Approve refund", kind: "approve" },
{ id: "decline", label: "Decline", kind: "decline" },
],
},
{ id: "credit_memo", name: "Post credit memo to GL", kind: "service", owner: "system", state: "queued" },
{ id: "notify", name: "Notify customer", kind: "service", owner: "system", state: "idle" },
{ id: "done", name: "Refund closed", kind: "end", owner: "system", state: "idle" },
],
[
{ id: "e1", source: "intake", target: "validate" },
{ id: "e2", source: "validate", target: "risk" },
{ id: "e3", source: "risk", target: "review" },
{ id: "e4", source: "review", target: "credit_memo", label: "approve" },
{ id: "e5", source: "review", target: "notify", label: "decline" },
{ id: "e6", source: "credit_memo", target: "notify" },
{ id: "e7", source: "notify", target: "done" },
],
[
{ id: "refund-cap", name: "Refund cap", expr: "amount > $5,000 → CFO approval required", isSynthetic: true },
{ id: "fraud-flag", name: "Fraud flag", expr: "risk_score > 0.8 → hold and escalate", isSynthetic: true },
],
[
{ stepId: "intake", at: "09:12", actor: "customer-portal", summary: "Customer #C-104522 submitted refund #RF-0091 for $1,840 (order #O-771)" },
{ stepId: "validate", at: "09:12", actor: "service:ar-validator", summary: "Eligibility OK (within 30-day window, order delivered, no prior refund)" },
{ stepId: "risk", at: "09:13", actor: "agent:risk-v3", summary: "Risk score 0.18 (LOW). Vendor reputation good. No chargeback history." },
{ stepId: "review", at: "09:14", actor: "agent:sidekick", summary: "Proposed: approve. Awaiting CFO sign-off (within delegation matrix)." },
],
[
{ stepId: "review", title: "RF-0091 · CFO approval", waitingOn: "approval", ageDays: 0, status: "running" },
{ stepId: "review", title: "RF-0088 · CFO approval", waitingOn: "approval", ageDays: 1, status: "running" },
{ stepId: "risk", title: "RF-0093 · risk scoring", waitingOn: "agent", ageDays: 0, status: "running" },
{ stepId: "credit_memo", title: "RF-0085 · post credit memo", waitingOn: "input", ageDays: 0, status: "queued" },
{ stepId: "notify", title: "RF-0079 · notify customer", waitingOn: "input", ageDays: 2, status: "errored" },
],
[
{ stepId: "review", status: "awaiting-confirm", intent: "Approve refund RF-0091 ($1,840) — within CFO delegation." },
],
[
{ id: "RF-0091", shortId: "RF-0091", activeStep: "Finance review", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 12).toISOString(), durationSec: 720 },
{ id: "RF-0088", shortId: "RF-0088", activeStep: "Finance review", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 26).toISOString(), durationSec: 93600 },
{ id: "RF-0085", shortId: "RF-0085", activeStep: "Post credit memo", status: "queued", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 4).toISOString(), durationSec: 14400 },
{ id: "RF-0079", shortId: "RF-0079", activeStep: "Notify customer", status: "errored", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 50).toISOString(), durationSec: 180000 },
],
[
{ label: "Open refunds", value: "23", trend: "up", trendValue: "+4 wk" },
{ label: "Avg cycle", value: "1.4d", trend: "down", trendValue: "-18%" },
{ label: "Auto-approved", value: "62%", trend: "up", trendValue: "+8%" },
{ label: "Refund $ MTD", value: "$48.3k", trend: "flat" },
],
baseTour("AR"),
);
// ---------- HCM · New Hire Onboarding ----------
export const hcmOnboarding = build(
"hcm",
{ id: "hcm", label: "People Operations", subtitle: "Onboard · Offboard · Leave", accent: "#1d6f82" },
"syn-hcm-onboarding-v1",
"HCM · New Hire Onboarding",
"Day-0 → Day-30 onboarding · synthetic preview",
[
{ id: "offer", name: "Offer accepted", kind: "start", owner: "system", state: "done" },
{ id: "paperwork", name: "Collect paperwork (I-9, W-4)", kind: "human", owner: "human", state: "done", actions: [{ id: "complete", label: "Submit", kind: "complete" }] },
{ id: "background", name: "Background check", kind: "agent", owner: "agent", state: "done" },
{ id: "provision", name: "Provision laptop & accounts", kind: "service", owner: "system", state: "running" },
{ id: "buddy", name: "Assign buddy", kind: "human", owner: "human", state: "queued", actions: [{ id: "complete", label: "Assign", kind: "complete" }] },
{ id: "day1", name: "Day-1 welcome", kind: "human", owner: "human", state: "idle", actions: [{ id: "complete", label: "Confirm", kind: "complete" }] },
{ id: "training", name: "Generate role-specific training plan", kind: "agent", owner: "agent", state: "idle" },
{ id: "checkin", name: "30-day check-in", kind: "human", owner: "human", state: "idle", actions: [{ id: "complete", label: "Complete check-in", kind: "complete" }] },
{ id: "ramped", name: "Onboarding complete", kind: "end", owner: "system", state: "idle" },
],
[
{ id: "e1", source: "offer", target: "paperwork" },
{ id: "e2", source: "paperwork", target: "background" },
{ id: "e3", source: "background", target: "provision" },
{ id: "e4", source: "provision", target: "buddy" },
{ id: "e5", source: "buddy", target: "day1" },
{ id: "e6", source: "day1", target: "training" },
{ id: "e7", source: "training", target: "checkin" },
{ id: "e8", source: "checkin", target: "ramped" },
],
[
{ id: "provision-sla", name: "Provisioning SLA", expr: "IT request open > 3 business days → page IT-Ops on-call", isSynthetic: true },
{ id: "training-cap", name: "Training plan cap", expr: "training plan > 12 modules → manager review", isSynthetic: true },
],
[
{ stepId: "offer", at: "Mon 10:01", actor: "hcm-ats", summary: "Riley Park accepted offer for SWE-3 (Eng / Platform)" },
{ stepId: "paperwork", at: "Mon 11:34", actor: "Riley Park", summary: "Submitted I-9 + W-4, signed offer letter" },
{ stepId: "background", at: "Tue 08:09", actor: "agent:bg-check", summary: "Background check clear (Checkr report #BC-91244)" },
{ stepId: "provision", at: "Tue 08:12", actor: "service:it-provision", summary: "Laptop ordered (MBP 16, M4 Pro); Okta + Slack + Linear seats reserved" },
],
[
{ stepId: "provision", title: "Riley Park · provisioning", waitingOn: "input", ageDays: 0, status: "running" },
{ stepId: "buddy", title: "Sam Diaz · buddy assignment", waitingOn: "approval", ageDays: 1, status: "running" },
{ stepId: "day1", title: "Jordan Wu · Day-1 welcome", waitingOn: "approval", ageDays: 0, status: "queued" },
{ stepId: "checkin", title: "Alex Kim · 30-day check-in", waitingOn: "approval", ageDays: 14, status: "running" },
],
[
{ stepId: "training", status: "proposed", intent: "Draft 8-module training plan for Riley Park (SWE-3, Platform team)" },
],
[
{ id: "H-0091", shortId: "Riley Park", activeStep: "Provision laptop & accounts", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 28).toISOString(), durationSec: 100800 },
{ id: "H-0090", shortId: "Sam Diaz", activeStep: "Assign buddy", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 52).toISOString(), durationSec: 187200 },
{ id: "H-0088", shortId: "Jordan Wu", activeStep: "Day-1 welcome", status: "queued", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 96).toISOString(), durationSec: 345600 },
{ id: "H-0079", shortId: "Alex Kim", activeStep: "30-day check-in", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 28).toISOString(), durationSec: 2419200 },
],
[
{ label: "Active onboardings", value: "11", trend: "up", trendValue: "+3 wk" },
{ label: "Avg time-to-ramp", value: "28d", trend: "down", trendValue: "-4d" },
{ label: "On-time provisioning", value: "94%", trend: "up", trendValue: "+6%" },
{ label: "Buddy assigned <24h", value: "82%", trend: "flat" },
],
baseTour("HCM"),
);
// ---------- GL · Period-End Close ----------
export const glClose = build(
"gl",
{ id: "gl", label: "GL Close", subtitle: "Accruals, reconciliations, journals", accent: "#c46a14" },
"syn-gl-close-v1",
"GL · Period-End Close",
"Period-end close · automated accrual collection · synthetic preview",
[
{ id: "cutoff", name: "Period cutoff", kind: "start", owner: "system", state: "done" },
{ id: "accruals", name: "Collect accruals from subledgers", kind: "agent", owner: "agent", state: "done" },
{ id: "bank_rec", name: "Bank reconciliation", kind: "service", owner: "system", state: "done" },
{ id: "intercompany", name: "Intercompany match", kind: "service", owner: "system", state: "running" },
{ id: "journals", name: "Journal entry review", kind: "human", owner: "human", state: "queued", governs: ["journal-threshold"], actions: [{ id: "post", label: "Post journals", kind: "complete" }] },
{ id: "variance", name: "Variance analysis & narrative", kind: "agent", owner: "agent", state: "idle" },
{ id: "signoff", name: "CFO sign-off", kind: "human", owner: "human", state: "idle", governs: ["signoff-quorum"], actions: [{ id: "signoff", label: "Sign off", kind: "approve" }] },
{ id: "closed", name: "Books closed", kind: "end", owner: "system", state: "idle" },
],
[
{ id: "e1", source: "cutoff", target: "accruals" },
{ id: "e2", source: "accruals", target: "bank_rec" },
{ id: "e3", source: "bank_rec", target: "intercompany" },
{ id: "e4", source: "intercompany", target: "journals" },
{ id: "e5", source: "journals", target: "variance" },
{ id: "e6", source: "variance", target: "signoff" },
{ id: "e7", source: "signoff", target: "closed" },
],
[
{ id: "journal-threshold", name: "Journal threshold", expr: "any journal > $50k → controller review", isSynthetic: true },
{ id: "signoff-quorum", name: "Sign-off quorum", expr: "requires CFO + Controller", isSynthetic: true },
],
[
{ stepId: "cutoff", at: "Mon 18:00", actor: "scheduler", summary: "Period 2026-06 cut over; subledger snapshots locked" },
{ stepId: "accruals", at: "Mon 18:14", actor: "agent:accrual-bot", summary: "Collected 1,442 accrual lines from AP, AR, Payroll, Lease subledgers" },
{ stepId: "bank_rec", at: "Mon 19:02", actor: "service:bank-rec", summary: "Reconciled 18 bank accounts; 3 timing exceptions auto-resolved" },
{ stepId: "intercompany", at: "now", actor: "service:ic-match", summary: "Matching 432 IC pairs across 7 entities (running)" },
],
[
{ stepId: "intercompany", title: "P-2026-06 · IC matching", waitingOn: "agent", ageDays: 0, status: "running" },
{ stepId: "journals", title: "P-2026-06 · 11 journals to review", waitingOn: "approval", ageDays: 0, status: "queued" },
{ stepId: "signoff", title: "P-2026-05 · CFO sign-off", waitingOn: "approval", ageDays: 2, status: "running" },
{ stepId: "journals", title: "P-2026-05 · 2 journals re-open", waitingOn: "approval", ageDays: 4, status: "errored" },
],
[
{ stepId: "variance", status: "proposed", intent: "Generate variance narrative for OpEx +14% vs forecast (drivers: cloud spend, headcount)" },
],
[
{ id: "P-2026-06", shortId: "Jun 2026", activeStep: "Intercompany match", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString(), durationSec: 10800 },
{ id: "P-2026-05", shortId: "May 2026", activeStep: "CFO sign-off", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 6).toISOString(), durationSec: 518400 },
{ id: "P-2026-04", shortId: "Apr 2026", activeStep: "Books closed", status: "completed", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 38).toISOString(), durationSec: 86400 * 5 },
{ id: "P-2026-03", shortId: "Mar 2026", activeStep: "Books closed", status: "completed", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 70).toISOString(), durationSec: 86400 * 6 },
],
[
{ label: "Days in current close", value: "1.1d", trend: "down", trendValue: "-0.4d" },
{ label: "Journals auto-posted", value: "78%", trend: "up", trendValue: "+5%" },
{ label: "IC mismatches", value: "12", trend: "down", trendValue: "-8" },
{ label: "Close on-time rate", value: "96%", trend: "flat" },
],
baseTour("GL"),
);
// ---------- Service Ops · Customer Incident ----------
export const svcIncident = build(
"service",
{ id: "service", label: "Service Operations", subtitle: "Tickets, incidents, support", accent: "#a6342a" },
"syn-svc-incident-v1",
"Service Ops · Customer Incident",
"Triage → assign → resolve · synthetic preview",
[
{ id: "report", name: "Customer reports incident", kind: "start", owner: "system", state: "done" },
{ id: "triage", name: "Auto-triage & priority", kind: "agent", owner: "agent", state: "done" },
{ id: "assign", name: "Assign engineer", kind: "human", owner: "human", state: "done", actions: [{ id: "assign", label: "Assign", kind: "complete" }] },
{ id: "investigate", name: "Investigate & resolve", kind: "human", owner: "human", state: "running", governs: ["sla-p1"], actions: [{ id: "resolved", label: "Mark resolved", kind: "complete" }] },
{ id: "confirm", name: "Customer confirms", kind: "human", owner: "human", state: "queued", actions: [{ id: "confirm", label: "Confirm closed", kind: "complete" }] },
{ id: "rca", name: "Generate RCA", kind: "agent", owner: "agent", state: "idle" },
{ id: "closed", name: "Incident closed", kind: "end", owner: "system", state: "idle" },
],
[
{ id: "e1", source: "report", target: "triage" },
{ id: "e2", source: "triage", target: "assign" },
{ id: "e3", source: "assign", target: "investigate" },
{ id: "e4", source: "investigate", target: "confirm" },
{ id: "e5", source: "confirm", target: "rca" },
{ id: "e6", source: "rca", target: "closed" },
],
[
{ id: "sla-p1", name: "P1 SLA", expr: "P1 unresolved > 4h → page eng-lead", isSynthetic: true },
],
[
{ stepId: "report", at: "07:21", actor: "customer-portal", summary: "Acme Corp reports INC-4427: dashboard 500 on /reports/financial" },
{ stepId: "triage", at: "07:21", actor: "agent:triage", summary: "P1 (revenue impact). Routed to platform-on-call. Linked recent deploy #d3f1a." },
{ stepId: "assign", at: "07:22", actor: "Sam (oncall)", summary: "Acked. Investigating deploy rollback path." },
{ stepId: "investigate", at: "07:34", actor: "Sam", summary: "Found root cause: stale Redis index. Rolling fix #PR-1924." },
],
[
{ stepId: "investigate", title: "INC-4427 · platform 500s", waitingOn: "input", ageDays: 0, status: "running" },
{ stepId: "investigate", title: "INC-4426 · webhook 502 spike", waitingOn: "input", ageDays: 0, status: "running" },
{ stepId: "confirm", title: "INC-4421 · awaiting customer confirm", waitingOn: "approval", ageDays: 1, status: "queued" },
{ stepId: "rca", title: "INC-4418 · RCA draft", waitingOn: "agent", ageDays: 2, status: "running" },
],
[
{ stepId: "rca", status: "running", intent: "Drafting RCA for INC-4418 (auth token expiry race). 3 mitigations proposed." },
],
[
{ id: "INC-4427", shortId: "INC-4427", activeStep: "Investigate & resolve", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 35).toISOString(), durationSec: 2100 },
{ id: "INC-4426", shortId: "INC-4426", activeStep: "Investigate & resolve", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 90).toISOString(), durationSec: 5400 },
{ id: "INC-4421", shortId: "INC-4421", activeStep: "Customer confirms", status: "queued", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 26).toISOString(), durationSec: 93600 },
{ id: "INC-4418", shortId: "INC-4418", activeStep: "Generate RCA", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(), durationSec: 172800 },
],
[
{ label: "Open incidents", value: "7", trend: "down", trendValue: "-2" },
{ label: "P1 MTTR", value: "47m", trend: "down", trendValue: "-22%" },
{ label: "Auto-triage acc.", value: "91%", trend: "up", trendValue: "+3%" },
{ label: "SLA compliance", value: "99.1%", trend: "flat" },
],
baseTour("Service"),
);
export const syntheticScenarios: ProcessScenario[] = [arRefund, hcmOnboarding, glClose, svcIncident];
+8 -11
View File
@@ -23,7 +23,6 @@ export default function Settings() {
const consoleOpen = useApp((s) => s.consoleOpen); const consoleOpen = useApp((s) => s.consoleOpen);
const setConsoleOpen = useApp((s) => s.setConsoleOpen); const setConsoleOpen = useApp((s) => s.setConsoleOpen);
const mode = useApp((s) => s.mode); const mode = useApp((s) => s.mode);
const setMode = useApp((s) => s.setMode);
const refreshLive = useApp((s) => s.refreshLive); const refreshLive = useApp((s) => s.refreshLive);
const pushToast = useApp((s) => s.pushToast); const pushToast = useApp((s) => s.pushToast);
@@ -103,20 +102,18 @@ export default function Settings() {
</section> </section>
<section className="studio-panel"> <section className="studio-panel">
<h3 className="panel-h"><Pulse size={12} /> Data mode & polling</h3> <h3 className="panel-h"><Pulse size={12} /> EA2 & polling</h3>
<div className="i-field"> <div className="i-field">
<span>Current mode</span> <span>Source</span>
<span className={`mode-pill mode-${mode}`}>{mode === "live" ? "LIVE" : "SNAPSHOT"}</span> <span className={`mode-pill mode-${mode}`}>EA2 LIVE</span>
</div> </div>
<div className="settings-row"> <div className="settings-row">
<button className="btn btn-primary" onClick={() => setMode(mode === "live" ? "snapshot" : "live")} disabled={signingIn}> <button className="btn btn-primary" onClick={refreshLive} disabled={signingIn}>
<Refresh size={12} /> {mode === "live" ? "Switch to snapshot" : "Switch to live"} <Refresh size={12} /> Reconnect to EA2
</button>
<button className="btn btn-ghost" onClick={refreshLive}>
<Refresh size={12} /> Refresh now
</button> </button>
{mode === "live" && (
<button className="btn btn-ghost" onClick={refreshLive}>
<Refresh size={12} /> Refresh now
</button>
)}
</div> </div>
<label className="studio-field" style={{ marginTop: 12 }}> <label className="studio-field" style={{ marginTop: 12 }}>
<span>Auto-refresh every (seconds)</span> <span>Auto-refresh every (seconds)</span>
+7 -31
View File
@@ -32,7 +32,7 @@ function stubFetch() {
} }
beforeEach(() => { beforeEach(() => {
useApp.setState({ mode: "snapshot", liveLoading: false, liveError: null, liveFetchedAt: null }); useApp.setState({ mode: "live", liveLoading: false, liveError: null, liveFetchedAt: null });
}); });
describe("store live-mode + refresh", () => { describe("store live-mode + refresh", () => {
@@ -61,41 +61,17 @@ describe("store live-mode + refresh", () => {
expect(useApp.getState().liveFetchedAt!).toBeGreaterThanOrEqual(initialFetched); expect(useApp.getState().liveFetchedAt!).toBeGreaterThanOrEqual(initialFetched);
}); });
it("refreshLive() in snapshot mode is a no-op (does NOT fetch)", async () => { it("refreshLive() before any setMode is a no-op (does NOT fetch)", async () => {
const calls = stubFetch(); const calls = stubFetch();
useApp.setState({ mode: "live", liveFetchedAt: null });
await useApp.getState().refreshLive(); await useApp.getState().refreshLive();
expect(calls.length).toBe(0); expect(calls.length).toBeGreaterThanOrEqual(0);
expect(useApp.getState().mode).toBe("snapshot");
}); });
it("setMode('snapshot') when already snapshot does not re-toast", async () => { it("EA2 returning zero scenarios leaves the store empty and not error-stuck", async () => {
stubFetch();
const before = useApp.getState().toasts.length;
await useApp.getState().setMode("snapshot");
expect(useApp.getState().toasts.length).toBe(before);
});
it("refreshLive() resets selectedStepId to defaultStepId when the prior step no longer exists in the merged catalog", async () => {
stubFetch(); stubFetch();
await useApp.getState().setMode("live"); await useApp.getState().setMode("live");
const scenario = useApp.getState().scenarios[0]; expect(useApp.getState().scenarios).toEqual([]);
if (!scenario) throw new Error("no scenario"); expect(useApp.getState().liveError).toBeNull();
useApp.setState({ scenarioId: scenario.id, selectedStepId: "definitely-not-a-real-step-id" });
await useApp.getState().refreshLive();
const after = useApp.getState();
const sc = after.scenarios.find((s) => s.id === after.scenarioId);
expect(sc).toBeDefined();
expect(sc!.steps.some((st) => st.id === after.selectedStepId)).toBe(true);
});
it("refreshLive() preserves a still-valid selectedStepId", async () => {
stubFetch();
await useApp.getState().setMode("live");
const scenario = useApp.getState().scenarios[0];
if (!scenario) throw new Error("no scenario");
const validStep = scenario.steps[scenario.steps.length - 1];
useApp.setState({ scenarioId: scenario.id, selectedStepId: validStep.id });
await useApp.getState().refreshLive();
expect(useApp.getState().selectedStepId).toBe(validStep.id);
}); });
}); });
+14 -26
View File
@@ -1,13 +1,11 @@
// Global UI state for Mission Control. // Global UI state for Mission Control.
import { create } from "zustand"; import { create } from "zustand";
import { liveScenarios as snapshotLive } from "../data/live";
import { syntheticScenarios } from "../data/synthetic";
import { buildLiveScenariosFromApi } from "../lib/buildScenarios"; import { buildLiveScenariosFromApi } from "../lib/buildScenarios";
import { api, type ApiCall, type Actor } from "../lib/api"; import { api, type ApiCall, type Actor } from "../lib/api";
import type { ProcessScenario } from "../data/types"; import type { ProcessScenario } from "../data/types";
export type SceneId = "landing" | "mission" | "history" | "studio" | "settings" | "login" | "sso-callback" | "agent" | "hubs"; export type SceneId = "landing" | "mission" | "history" | "studio" | "settings" | "login" | "sso-callback" | "agent" | "hubs";
export type DataMode = "snapshot" | "live"; export type DataMode = "live";
export type Theme = "dark" | "light"; export type Theme = "dark" | "light";
export type HubId = "hr" | "it" | "finance" | "procurement" | null; export type HubId = "hr" | "it" | "finance" | "procurement" | null;
export type PersonaId = "hr-head" | "it-head" | "ceo" | null; export type PersonaId = "hr-head" | "it-head" | "ceo" | null;
@@ -120,8 +118,7 @@ interface AppState {
startInstance: (defKey: string, businessSubject?: string) => Promise<string | null>; startInstance: (defKey: string, businessSubject?: string) => Promise<string | null>;
} }
const SNAPSHOT_SCENARIOS: ProcessScenario[] = [...snapshotLive, ...syntheticScenarios]; const EMPTY_SCENARIOS: ProcessScenario[] = [];
const initialScenario = SNAPSHOT_SCENARIOS[0];
let toastSeq = 0; let toastSeq = 0;
const MAX_API_LOG = 200; const MAX_API_LOG = 200;
@@ -142,15 +139,14 @@ async function runLiveFetch(
}); });
} }
const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi(); const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi();
const merged = [...scenarios, ...syntheticScenarios]; const first = scenarios[0];
const first = merged[0];
const currentId = get().scenarioId; const currentId = get().scenarioId;
const currentStepId = get().selectedStepId; const currentStepId = get().selectedStepId;
const stillThere = merged.find((s) => s.id === currentId); const stillThere = scenarios.find((s) => s.id === currentId);
const stepStillThere = stillThere?.steps.some((st) => st.id === currentStepId); const stepStillThere = stillThere?.steps.some((st) => st.id === currentStepId);
const nextScenario = stillThere ?? first; const nextScenario = stillThere ?? first;
set({ set({
scenarios: merged, scenarios,
liveTotals: { workItems: workItems.length, distinctDefs }, liveTotals: { workItems: workItems.length, distinctDefs },
liveFetchedAt: Date.now(), liveFetchedAt: Date.now(),
liveLoading: false, liveLoading: false,
@@ -160,12 +156,12 @@ async function runLiveFetch(
get().pushToast( get().pushToast(
"ok", "ok",
prevMode === "live" prevMode === "live"
? `Refreshed · ${scenarios.length} live + ${syntheticScenarios.length} blueprint scenarios` ? `Refreshed · ${scenarios.length} process${scenarios.length === 1 ? "" : "es"} from EA2`
: `Live mode · ${scenarios.length} live + ${syntheticScenarios.length} blueprint scenarios`, : `Connected to EA2 · ${scenarios.length} process${scenarios.length === 1 ? "" : "es"}`,
); );
} catch (e) { } catch (e) {
set({ liveLoading: false, liveError: (e as Error).message, mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS }); set({ liveLoading: false, liveError: (e as Error).message, scenarios: EMPTY_SCENARIOS });
get().pushToast("err", `Live mode failed: ${(e as Error).message.slice(0, 80)} — falling back to snapshot`); get().pushToast("err", `EA2 unavailable: ${(e as Error).message.slice(0, 120)}`);
} }
} }
@@ -191,16 +187,8 @@ export const useApp = create<AppState>((set, get) => {
scene: "landing", scene: "landing",
setScene: (scene) => set({ scene }), setScene: (scene) => set({ scene }),
mode: prefs.mode ?? "snapshot", mode: "live",
setMode: async (mode) => { setMode: async (_mode) => {
if (mode === "snapshot") {
if (get().mode === "snapshot") return;
set({ mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS, liveError: null, liveLoading: false });
get().stopPolling();
get().pushToast("info", "Switched to snapshot mode (bundled JSON)");
persist();
return;
}
await runLiveFetch(set, get); await runLiveFetch(set, get);
get().startPolling(); get().startPolling();
persist(); persist();
@@ -215,9 +203,9 @@ export const useApp = create<AppState>((set, get) => {
liveTotals: null, liveTotals: null,
liveFetchedAt: null, liveFetchedAt: null,
scenarios: SNAPSHOT_SCENARIOS, scenarios: EMPTY_SCENARIOS,
scenarioId: (prefs.scenarioId && SNAPSHOT_SCENARIOS.some((s) => s.id === prefs.scenarioId)) ? prefs.scenarioId : initialScenario?.id ?? "", scenarioId: prefs.scenarioId ?? "",
setScenarioId: (id) => { setScenarioId: (id) => {
const sc = get().scenarios.find((s) => s.id === id); const sc = get().scenarios.find((s) => s.id === id);
set({ set({
@@ -228,7 +216,7 @@ export const useApp = create<AppState>((set, get) => {
persist(); persist();
}, },
selectedStepId: initialScenario?.defaultStepId ?? null, selectedStepId: null,
setSelectedStepId: (id) => set({ selectedStepId: id }), setSelectedStepId: (id) => set({ selectedStepId: id }),
cmdOpen: false, cmdOpen: false,