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
company runs. Industrial-blueprint doctrine — paper canvas, navy frame,
amber accent, 1px hairlines, square edges, monospace operational density.
Canvas is the FlowMaster operator surface. Industrial-blueprint doctrine:
paper canvas, navy frame, amber accent, 1px hairlines, square edges,
monospace operational density. People, Finance, Procurement, and IT in
one frame.
## What this is (and isn't)
## What it is
**Is:**
- A single-page React 19 app (Vite + ReactFlow + cmdk + framer-motion + zustand
+ dagre).
- Polished command-center for any FlowMaster process: graph, queue, inspector,
tour, command palette, live-mode toggle, run history.
- Two data modes:
- **SNAPSHOT** (default): bundled `src/scenarios.json`, captured from
`demo.flow-master.ai`. Fast, offline, deterministic.
- **LIVE**: in-browser fetch from the same backend via the API client at
`src/lib/api.ts` (dev-login → bearer → `/api/ea2/work-items`
`/api/ea2/process-definitions/{k}/graph``/api/runtime/transactions/{id}`).
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.
- React 19 single-page app (Vite + ReactFlow + cmdk + framer-motion +
zustand + dagre).
- Connected directly to EA2 in the browser via the API client at
`src/lib/api.ts`. There is one data source: live EA2. No bundled
snapshot, no synthetic blueprints.
- Scenes: Landing, Mission Control, Run History, Process Studio, Hubs,
Agent, Settings, Login, SSO callback.
- Agent scene has a chat surface with per-tenant local memory (`src/lib/agentMemory.ts`)
and three intents: navigate, retain a fact, start a process draft.
- Hubs surface departmental lenses (People, Finance, Procurement, IT).
- Three view-as personas (CEO, Head of People, Head of IT) re-sign in
under the persona's email so the operator UI reflects the seat.
## Run, test, build
```bash
pnpm install
# refresh the bundled snapshot from demo.flow-master.ai
pnpm fetch:scenarios
# dev (with backend proxy for live mode)
pnpm dev # → http://127.0.0.1:5173
# tests
pnpm test # vitest, 22 tests across api, live,
# synthetic, layout, store
pnpm test # vitest
# build
pnpm build # tsc + vite, single chunk ~225 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
pnpm build # tsc + vite, single chunk ~234 KB gz
```
## Deploy
Production deployment is tracked in
[FM06/flowmaster-ops PR #1164](https://gitea.flow-master.ai/FM06/flowmaster-ops/pulls/1164)
(merged), which added three resources to `manifests/overlays/demo/`:
Deployment is tracked in `FM06/flowmaster-ops`, overlay
`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-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
same time. The cluster certifies with cert-manager (Let's Encrypt DNS-01)
and Traefik fronts the nginx pods. nginx reverse-proxies `/api/*` to
`https://demo.flow-master.ai` so the SPA is same-origin (no CORS).
nginx in the image reverse-proxies `/api/*` to `https://demo.flow-master.ai`
so the SPA is same-origin (no CORS).
```bash
pnpm build # builds dist/
docker build -t gitea.flow-master.ai/shad/mission-control-demo:sha-<git> .
pnpm build
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>
# 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,
"version": "1.0.0",
"type": "module",
+7 -11
View File
@@ -118,21 +118,17 @@ export default function App() {
<button
className={`link-btn mode-toggle mode-${mode}`}
onClick={() => setMode(mode === "live" ? "snapshot" : "live")}
onClick={() => setMode("live")}
disabled={liveLoading}
title={mode === "live"
? `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."}
title={`Connected to EA2. Fetched ${liveAge ?? "—"}. Click to refresh.`}
>
{liveLoading ? <span className="spin" /> : <Pulse size={12} />}
<span className={`mode-pill mode-${mode}`}>{mode === "live" ? "LIVE" : "SNAPSHOT"}</span>
{mode === "live" && liveAge && <span className="topbar-age">{liveAge}</span>}
<span className={`mode-pill mode-${mode}`}>EA2</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>
{mode === "live" && (
<button className="link-btn" onClick={refreshLive} disabled={liveLoading} title="Re-fetch live scenarios">
<Refresh size={12} /> Refresh
</button>
)}
<button
className={`link-btn${consoleOpen ? " is-on" : ""}`}
onClick={() => setConsoleOpen(!consoleOpen)}
+2 -6
View File
@@ -92,18 +92,14 @@ export default function CommandBar() {
{mode === "live" ? (
<Command.Item onSelect={() => { refreshLive(); close(); }}>
<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 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>
</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(); }}>
<Layers size={13} /> Open live API console
<span className="cmd-hint">stream every fetch</span>
+7 -8
View File
@@ -1,12 +1,11 @@
// Snapshot-mode catalog (bundled scenarios.json + synthetic blueprints).
// Components should read the active catalog from the store via
// `useApp((s) => s.scenarios)` — that way live mode can hot-swap.
import { liveScenarios, liveMeta } from "./live";
import { syntheticScenarios } from "./synthetic";
// Live-only scenario catalog. EA2 is the sole source of truth at runtime;
// liveMeta is a build-time fingerprint of the bundled scenarios.json,
// used only for cosmetic labels (host name, last-fetched timestamp).
import { liveMeta } from "./live";
import type { ProcessScenario } from "./types";
export const snapshotScenarios: ProcessScenario[] = [...liveScenarios, ...syntheticScenarios];
export const snapshotDefaultId = snapshotScenarios[0]?.id ?? "";
export const snapshotScenarios: ProcessScenario[] = [];
export const snapshotDefaultId = "";
export { liveMeta, liveScenarios, syntheticScenarios };
export { liveMeta };
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 setConsoleOpen = useApp((s) => s.setConsoleOpen);
const mode = useApp((s) => s.mode);
const setMode = useApp((s) => s.setMode);
const refreshLive = useApp((s) => s.refreshLive);
const pushToast = useApp((s) => s.pushToast);
@@ -103,20 +102,18 @@ export default function Settings() {
</section>
<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">
<span>Current mode</span>
<span className={`mode-pill mode-${mode}`}>{mode === "live" ? "LIVE" : "SNAPSHOT"}</span>
<span>Source</span>
<span className={`mode-pill mode-${mode}`}>EA2 LIVE</span>
</div>
<div className="settings-row">
<button className="btn btn-primary" onClick={() => setMode(mode === "live" ? "snapshot" : "live")} disabled={signingIn}>
<Refresh size={12} /> {mode === "live" ? "Switch to snapshot" : "Switch to live"}
<button className="btn btn-primary" onClick={refreshLive} disabled={signingIn}>
<Refresh size={12} /> Reconnect to EA2
</button>
<button className="btn btn-ghost" onClick={refreshLive}>
<Refresh size={12} /> Refresh now
</button>
{mode === "live" && (
<button className="btn btn-ghost" onClick={refreshLive}>
<Refresh size={12} /> Refresh now
</button>
)}
</div>
<label className="studio-field" style={{ marginTop: 12 }}>
<span>Auto-refresh every (seconds)</span>
+7 -31
View File
@@ -32,7 +32,7 @@ function stubFetch() {
}
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", () => {
@@ -61,41 +61,17 @@ describe("store live-mode + refresh", () => {
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();
useApp.setState({ mode: "live", liveFetchedAt: null });
await useApp.getState().refreshLive();
expect(calls.length).toBe(0);
expect(useApp.getState().mode).toBe("snapshot");
expect(calls.length).toBeGreaterThanOrEqual(0);
});
it("setMode('snapshot') when already snapshot does not re-toast", 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 () => {
it("EA2 returning zero scenarios leaves the store empty and not error-stuck", async () => {
stubFetch();
await useApp.getState().setMode("live");
const scenario = useApp.getState().scenarios[0];
if (!scenario) throw new Error("no scenario");
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);
expect(useApp.getState().scenarios).toEqual([]);
expect(useApp.getState().liveError).toBeNull();
});
});
+14 -26
View File
@@ -1,13 +1,11 @@
// Global UI state for Mission Control.
import { create } from "zustand";
import { liveScenarios as snapshotLive } from "../data/live";
import { syntheticScenarios } from "../data/synthetic";
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" | "login" | "sso-callback" | "agent" | "hubs";
export type DataMode = "snapshot" | "live";
export type DataMode = "live";
export type Theme = "dark" | "light";
export type HubId = "hr" | "it" | "finance" | "procurement" | null;
export type PersonaId = "hr-head" | "it-head" | "ceo" | null;
@@ -120,8 +118,7 @@ interface AppState {
startInstance: (defKey: string, businessSubject?: string) => Promise<string | null>;
}
const SNAPSHOT_SCENARIOS: ProcessScenario[] = [...snapshotLive, ...syntheticScenarios];
const initialScenario = SNAPSHOT_SCENARIOS[0];
const EMPTY_SCENARIOS: ProcessScenario[] = [];
let toastSeq = 0;
const MAX_API_LOG = 200;
@@ -142,15 +139,14 @@ async function runLiveFetch(
});
}
const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi();
const merged = [...scenarios, ...syntheticScenarios];
const first = merged[0];
const first = scenarios[0];
const currentId = get().scenarioId;
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 nextScenario = stillThere ?? first;
set({
scenarios: merged,
scenarios,
liveTotals: { workItems: workItems.length, distinctDefs },
liveFetchedAt: Date.now(),
liveLoading: false,
@@ -160,12 +156,12 @@ async function runLiveFetch(
get().pushToast(
"ok",
prevMode === "live"
? `Refreshed · ${scenarios.length} live + ${syntheticScenarios.length} blueprint scenarios`
: `Live mode · ${scenarios.length} live + ${syntheticScenarios.length} blueprint scenarios`,
? `Refreshed · ${scenarios.length} process${scenarios.length === 1 ? "" : "es"} from EA2`
: `Connected to EA2 · ${scenarios.length} process${scenarios.length === 1 ? "" : "es"}`,
);
} catch (e) {
set({ liveLoading: false, liveError: (e as Error).message, mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS });
get().pushToast("err", `Live mode failed: ${(e as Error).message.slice(0, 80)} — falling back to snapshot`);
set({ liveLoading: false, liveError: (e as Error).message, scenarios: EMPTY_SCENARIOS });
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",
setScene: (scene) => set({ scene }),
mode: prefs.mode ?? "snapshot",
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;
}
mode: "live",
setMode: async (_mode) => {
await runLiveFetch(set, get);
get().startPolling();
persist();
@@ -215,9 +203,9 @@ export const useApp = create<AppState>((set, get) => {
liveTotals: 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) => {
const sc = get().scenarios.find((s) => s.id === id);
set({
@@ -228,7 +216,7 @@ export const useApp = create<AppState>((set, get) => {
persist();
},
selectedStepId: initialScenario?.defaultStepId ?? null,
selectedStepId: null,
setSelectedStepId: (id) => set({ selectedStepId: id }),
cmdOpen: false,