Compare commits

...
40 Commits
Author SHA1 Message Date
shad 4c46c03b3b fix(app): hydrate tenantId/actor from /me on app boot when session exists
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled
The prior loop tightened Approvals.loadQueue to early-return when
tenantId is null. But the standard 'user reloads page with cached
session token' path didn't populate tenantId — it was only set during
loginAs or the live-fetch path.

Added one effect in App: when isAuthed && !tenantId, call api.ping
which proxies to /api/v1/auth/me and pulls user_id + tenant_id +
email into the store. This unblocks tenant-scoped surfaces
(Approvals) after a page reload.
2026-06-14 16:45:54 +04:00
shad 739245b3d7 fix(oracle-l6): close 2 actionable caveats
1. Approvals tenantId hydration race
   loadQueue used to fall back to 'show all rows' when tenantId was
   null. Now early-returns []  until tenantId hydrates, and the effect
   depends on tenantId so it reloads once auth completes.

2. Settings persona quick-switch threw 'Password required'
   signIn() in Settings called loginAs(email) without options.method.
   Now explicit { method: 'dev' } so the documented dev-login path
   is used.
2026-06-14 16:40:43 +04:00
shad 2150636fd0 test(qa): 4 new gates for approvals + documents + llm-proxy chain
- approvals_scene_renders_queue (rows > 0)
- approvals_detail_shows_active_step_with_actions (title + actions > 0)
- documents_scene_renders_data_definitions (rows > 0)
- llm_proxy_chain_returns_503_without_provider_key (real network round-
  trip through canvas nginx -> in-cluster proxy -> 503 because no
  OPENAI_API_KEY / ANTHROPIC_API_KEY is set)

Full dogfood: 36/36 green.
2026-06-14 16:36:06 +04:00
shad e520f39647 feat(llm-proxy): in-cluster FastAPI shim with nginx pass-through
Closes the 'fork Pi coding agent' integration loop with a tiny
broker that mirrors the @earendil-works/pi-ai request shape:

  POST /internal/canvas-llm/chat
  body: { messages: [{role, content}], max_tokens }

The proxy speaks to OpenAI or Anthropic depending on which env key
is set. With no key, returns 503 so the frontend's deterministic
fallback in routeAgentInput fires gracefully.

- llm-proxy/main.py: FastAPI + httpx (~150 LOC). System messages get
  split out for Anthropic (separate 'system' field) and inlined for
  OpenAI.
- llm-proxy/Dockerfile: python:3.12-slim, uvicorn on :8080.
- Deployment + Service in demo namespace (verified Running).
- nginx.conf: /internal/canvas-llm/ proxies to the in-cluster service.

Provider keys are set to empty in the manifest by design: customer
flip is a single 'kubectl set env' or sealed-secret update.
2026-06-14 16:28:17 +04:00
shad c9deaeb5c2 feat(documents): browse all data definitions per published process
New Documents scene at landing chip + scene route. Walks the
curated startable flows, fetches each one's graph, surfaces every
data_definition as a row with its source process. Search filter,
two-column list+detail layout. Mobile collapses to single column
at 700px.
2026-06-14 16:23:31 +04:00
shad 5072e65ec3 fix(settings): scrub raw user_id + dead emails + stale repo link; add Sign out
User's brief explicitly banned 'random strings of numbers and letters'.
Settings was leaking a 12-char user_id slice next to 'Current'. Removed.

- Identity card: Signed in / Email / Status (no raw user_id)
- COMMON_EMAILS: replaced 4 dead demo identities with the 3 seeded
  personas (CEO/HR/IT) so quick-switch matches what the login page
  already advertises
- Sign out button: clears the bearer + sessionStorage and returns to /
- About copy: refreshed; stale gitea repo link removed (the new canon
  is shad/canvas-frontend)
2026-06-14 16:21:12 +04:00
shad da779ed9b6 test(qa): audit_mobile reliably hits 390px on every scene
Pre-seed dev-login token into localStorage so each scene visit starts
authed without re-navigating through login. Use fresh p.goto per scene
instead of p.goBack so the viewport stays pinned to 390x844.

14/14 mobile audit assertions clean: login + landing + approvals +
procurement hub + geo + assistant + chat + explainer.
2026-06-14 16:19:48 +04:00
shad 93de195b0a fix(mobile): stack topbar + collapse approvals at <=700px
Mobile audit at 390px viewport showed every scene with topbar
expanded to ~1333px because of fixed-width brand-lock + tabs +
topbar-actions in a grid-template-columns auto auto 1fr auto.

- Topbar collapses to single column with scrollable tab row
- Tabs become horizontally scrollable; smaller font/padding
- Hide topbar-mid (context chips) and user-email/topbar-age (the
  user knows who they are)
- Approvals split collapses at 700px instead of 900px
- Compact scene padding (16/12) on mobile
2026-06-14 16:18:02 +04:00
shad 071166cae1 feat(approvals): tenant-scoped queue (no cross-tenant work-items)
Plumbed me.tenant_id through api.ping + loginAs into the store as
tenantId. Approvals.loadQueue filters /api/ea2/work-items by
tenant_id matching the signed-in user so the queue only shows
actionable rows. Probe of EA2 work-items returned 73/80 in canvas's
tenant (a0000000-...), 6 in hms_dev, 1 in hub-cnt-f56ce0 -- those
7 are now hidden.
2026-06-14 16:01:54 +04:00
shad 224c259bfd feat(approvals): real work-item queue + active-step actions wired to EA2
New Approvals scene at landing chip + scene route. Pulls live work
items from /api/ea2/work-items?view=all and renders:

- Status counter (running/waiting/blocked)
- Hub filter (chip row, dynamic from response)
- Queue list (display_name + age + active step + hub + requester)
- Selected case detail: active step, dispatch kind, form fields,
  available actions

Clicking an action POSTs to /api/runtime/transactions/<id>/actions/<id>
with the signed-in user as actor and refreshes both the transaction
and the queue. This proves runtime EA2 writes are working end-to-end
beyond just the wizard.
2026-06-14 15:57:23 +04:00
shad e52c4a7d53 fix(chat): render time badge for every thread row (no missing per-thread time)
The strict QA gate requires timeBadges === threadRowCount. When a
thread has no preview message (e.g. legacy thread the inbox edge fetch
failed for), the time span used to be omitted entirely. Now renders
'—' for missing preview so the per-row time element exists.
2026-06-14 15:47:28 +04:00
shad 973aa9c15b fix(security): lock CSP script-src to self (no unsafe-inline/unsafe-eval)
Oracle round-9 hard blocker: live CSP advertised script-src 'self'
'unsafe-inline' 'unsafe-eval'. Verified the Vite build emits zero inline
<script> tags (only external module script) and no bundled dep uses
new Function / eval (reactflow / leaflet / zustand / dagre / cmdk /
framer-motion all clean).

- nginx.conf script-src reduced to 'self'.
- img-src extended for OSM tiles (https://*.tile.openstreetmap.org) and
  leaflet marker images served from unpkg.

Also (Oracle round-9 second blocker) chat_sidebar QA now creates a
thread + sends a message before asserting, then strict-asserts
threadRowCount >= 1 AND previewRows === threadRowCount AND
timeBadges === threadRowCount. No more vacuous PASS when sidebar is
empty.
2026-06-14 15:42:28 +04:00
shad 6a665da21d test(qa): harden flaky geo + landing assertions to wait-for-condition
Two flake sources removed:
- geo_attendance_renders_eight_markers: waitForFunction($$ markers >= 8)
  with 20s timeout instead of a 3s fixed sleep. EA2 fetch + leaflet
  hydration race fixed.
- ceo_persona_dev_login_lands_on_landing + landing_exposes_all_hubs:
  waitForSelector(.hero-actions) + waitForFunction(hub-chip count >= 7)
  with 15s timeouts instead of 2.5s fixed sleep. Slow EA2 dev-login
  round-trips no longer race past hydration.

QA verified stable across two consecutive runs: 32/32 each.
2026-06-14 15:38:04 +04:00
shad 492defa08e fix(explainer): scrub residual Pi branding + add regression QA
Oracle round-8 closure: 3 user-visible 'Pi' refs remaining in
Explainer.tsx ('ask Pi' in DEFINE step, 'Why an agent (Pi)?' card
title, 'Pi is a co-pilot' card body, 'Ask Pi instead' chip).

All replaced with 'the assistant' / 'Command Assistant'. New
no_pi_branding_in_user_facing_scenes QA gate sweeps landing +
explainer + agent for \bPi\b word boundary on the rendered DOM
text and fails if any scene leaks.
2026-06-14 15:20:28 +04:00
shad 05be9ca8dc fix(oracle-r4-caveats): wizard rewires presentation edges, chat QA proves, Pi leak
Three Oracle round-7 caveats:

1. Wizard view replacement now deletes old presentation edges before
   creating new field-based ones. Previously handleDataSave added new
   edges additively, so runtime could still hit the generic Notes view.
2. Chat sidebar QA assertion was vacuously true (previewRows >= 0 &&
   timeBadges >= 0). Now: when threads exist, preview count must equal
   thread count AND at least one relative timestamp must render.
3. Agent composer placeholder rebranded from 'Ask Pi...' to 'Ask the
   assistant...'. Closes residual Pi/LLM framing leak.
2026-06-14 15:14:59 +04:00
shad bfac76dcde fix(oracle-r4): honest agent framing + 3 new QA gates
Oracle round-7 items 1, 4, 6:
- Agent welcome + sidebar copy clearly say 'deterministic command
  router' with 'EA2-backed text memory' (keyword recall). LLM adapter
  described as 'separate component, off by default'. No more 'natural
  language LLM on the roadmap' overclaim.
- 3 new full_dogfood gates:
  * memory_vault_remember_acks (proves vault write)
  * memory_vault_recall_returns_match (proves vault read by content)
  * llm_unconfigured_falls_back_gracefully (proves agent never crashes
    when LLM proxy isn't wired - the current state)
  * chat_sidebar_shows_previews_and_timestamps (proves chat polish
    elements render)
2026-06-14 15:09:12 +04:00
shad cc037a149e fix(oracle-r4): curation contradiction + wizard view enrichment
Oracle round-7 remediation, items 2 + 7:

- flowCuration.NON_BUSINESS_SOURCE_CONTEXTS no longer lists
  EA2_DRAFT_PROCESS:process_creation (it's in STARTABLE_SOURCE_CONTEXTS).
  Adds CANVAS_AGENT_MEMORY + CANVAS_AGENT_VAULT_ROOT to NON_BUSINESS so
  vault docs never leak into hubs / catalogue.
- wizardApi.createView now accepts a fields list and emits proper field
  defs (name slug, label, type from {string,number,boolean,textarea}).
- Wizard.handleDataSave rebuilds every step's view with the user's
  draft.fields after Generate phase, then rewires the presentation
  edges to the new views. Steps now expose real business fields, not a
  generic 'Notes' textarea.
2026-06-14 15:07:57 +04:00
shad 810d011b80 test(qa): assistant_shows_at_least_five_tools (was exactly five)
Assistant grew remember + recall tools this loop (7 total). The previous
exact-count assertion is now an at-least check so future tool additions
don't break QA.
2026-06-14 15:02:53 +04:00
shad 0266c77875 feat(chat): unread badges, last-message previews, relative timestamps
Sidebar now shows:
- aggregate unread count banner at top
- per-thread last-message preview (You: prefix when self-sent)
- relative timestamp on each thread (just now / Xm ago / Xh ago / Xd ago)
- amber unread dot + bold name for threads with new messages from others

Read state stored in localStorage per-user (fm.canvas.chat.read.<email>).
Auto-marked read when a thread is opened. Pure client-side; no extra EA2
writes beyond the existing listMessages calls.
2026-06-14 14:58:21 +04:00
shad 15ab8ededa feat(agent): LLM proxy integration shape (Pi-AI compatible)
src/lib/llmClient.ts: browser POSTs to /internal/canvas-llm/chat with
{messages, max_tokens}. Mirrors the normalized request shape used by
earendil-works/pi's @earendil-works/pi-ai. Returns:
 - ok:true with content+provider when configured and successful
 - ok:false, configured:false when proxy 404/503/unreachable
 - ok:false, configured:true when provider returned an error

routeAgentInput now falls back to the LLM with a system prompt that
includes the user identity, tool catalogue, and the top-3 vault recall
hits when no deterministic tool matches. When the proxy is not
configured the deterministic vault-hint reply is used (no regression
in the demo tier).
2026-06-14 14:55:34 +04:00
shad 9884ddf4ac feat(agent): per-tenant Karpathy-style memory vault
User explicit ask: 'maybe with a miniature hindsight or a Karpathy
vault behind it as its memory system for each tenant.'

- src/lib/agentMemory.ts: EA2-backed vault per user. Vault root is
  flow.kind=value at flow/memory_vault_<email_slug>; each memory is a
  flow.kind=value doc linked via defines edge with role=presentation.
- agentMemory.remember/listAll/recall — recall is a simple keyword
  overlap score with recency tiebreak.
- agentTools: new 'remember' and 'recall' tools.
  matchers: 'remember that ...', 'note ...', 'save ...' /
            'recall ...', 'remind me ...', 'what do you know about ...'
- routeAgentInput: every user turn is best-effort persisted as an
  episodic memory (fire-and-forget). When no command matches, the
  router does a vault recall and surfaces related notes before
  showing the help message.

Vault key: deterministic per-user. Owner_email stored on the vault
root + on each memory's config.memory. No raw IDs visible to the user.
2026-06-14 14:52:01 +04:00
shad 221cccf96b feat(curation): allowlist wizard source_context so user-created flows surface
EA2_DRAFT_PROCESS:process_creation and fm06-t10-demo-reset-v2 added to
STARTABLE_SOURCE_CONTEXTS. Wizard-published flows now reach the Hub
catalogue and the Assistant's list_processes reply.
2026-06-14 14:47:25 +04:00
shad 338864a8e1 fix(wizard): unique names on createStep/View/Version to avoid (tenant_id,name,kind) collisions 2026-06-14 14:44:51 +04:00
shad b951724c5f feat(wizard): produce fully startable EA2 flows
Reverse-engineered the EA2 runtime startability contract by diffing
pr_to_po_def (startable) against wizard-created drafts (500). Minimum
shape required:

  Parent flow (kind=definition, status=published)
  + Step flow (kind=definition, status=published, dispatch_kind=human)
  + View doc (kind=definition, status=active, layout={fields,layout:form})
  + Version doc (kind=definition, status=active)
  + defines edge: parent -> step (role=child)
  + defines edge: step -> step (role=dispatch self-edge)
  + defines edge: step -> view (role=presentation)
  + governs edge collection 'governs': version -> parent (role=governs)

wizardApi.ops gains: createView, createVersion, childEdge, dispatchEdge,
presentationEdge, governsEdge. createStepEdge renamed to childEdge to
reflect the actual EA2 role. Steps now create with status=published
(was draft) so they're visible to the runtime.

Wizard.handleStructureSave emits all of the above in two apply-batches
(creates then wires). Verified live: posted a probe flow with this
contract, /api/runtime/transactions returned 200 + a running
transaction with step_run_id.
2026-06-14 14:42:55 +04:00
shad c779919453 feat(curation): fetchStartableFlows supplements catalogue listing
The /api/ea2/flow/processes endpoint caps at ~203 items and pr_to_po_def
is not in that window for this tenant despite being status=published.
fetchStartableFlows() does a direct GET per allowlist key and merges
results with the catalogue list (allowlist hits take precedence).

Wired into Hub.loadPublishedFlows + agentTools.list_processes +
agentTools.start_process.
2026-06-14 14:32:50 +04:00
shad 83ebc28a81 fix(curation): drop over-broad demo/test/ea2/sdx/runtime-reference regexes
The allowlist by _key now handles those exclusions cleanly. The bare
'demo' / 'test' / 'ea2' regexes were sweeping out pr_to_po_def itself
('Executable curated procurement process for demo.flow-master.ai reset
smoke checks.') because its description contains both 'demo' and the
old smoke[_\\s]?test pattern variant.
2026-06-14 14:26:52 +04:00
shad e70944b6fa fix(curation): explicit STARTABLE_FLOW_KEYS allowlist
Previous attempt blacklisted unstartable backfill source_contexts but
swept all real published flows out too. Inverted to a positive
allowlist: only flows whose _key is in STARTABLE_FLOW_KEYS reach the
Hubs / Assistant.

Today the canonical pr_to_po_def is the only runtime-startable flow in
the tenant. New entries are added by hand once we've verified a flow
actually starts via /api/runtime/transactions. Cleaner than chasing
source_context strings.
2026-06-14 14:23:19 +04:00
shad 11e8442a93 fix(curation): blacklist source_contexts of un-startable backfill seeds
Oracle round-6 watch-out closure. The Assistant's list_processes was
returning PO Approval + Purchase Order Approval, both of which return
500 transaction_creation_failed on /api/runtime/transactions because
they were backfilled into the catalogue without view/data attachments.

Added 5 source_context exclusions matching the seed-time markers of
those flows: D2 live backfill / EA2 runtime reference seed /
atlas-f1-fresh-proof / EA2 seed payload via POST /api/process /
fm-process-mining-conformance-reseed.

The canonical pr_to_po_def (source_context fm06-t10-demo-reset-v2,
display 'Purchase Requisition to PO') survives — it is the only
runtime-startable flow in the tenant today.
2026-06-14 14:19:49 +04:00
shad 42884667f6 fix(curation): exclude seed-process + chat-inbox + attendance contexts + startability QA
Oracle round-6 watch-out: 'Employee Onboarding' (a CANVAS_SEED_PROCESS
wizard-incomplete draft) was reaching the Assistant list because its
display_name passed the dev-artefact regex. EA2 runtime then returned
500 transaction_creation_failed because it lacked view/data attachments.

- flowCuration.NON_BUSINESS_SOURCE_CONTEXTS now drops CANVAS_SEED_PROCESS,
  CANVAS_SEED_STEP, CANVAS_CHAT_INBOX, CANVAS_ATTENDANCE_ROOT,
  CANVAS_ATTENDANCE_SITE - all internal canvas source_contexts.
- qa/full_dogfood.mjs new assertion 'assistant_list_processes_all_startable'
  posts each Assistant-listed flow to /api/runtime/transactions and
  asserts non-4xx/5xx response.
2026-06-14 14:13:44 +04:00
shad b5d1ea54b0 feat(geo): EA2-backed attendance sites + versioned seed scripts
Closes Oracle deferred caveats:
- raw-SQL persona promotion (now qa/seeds/001_personas.mjs, idempotent)
- no scripted multi-persona interaction (now qa/seeds/002_persona_
  interactions.mjs - HR onboarding tx, CEO budget tx, IT provisioning
  tx + 3 cross-persona chat threads, verified end-to-end)
- geo-attendance mock data (now src/lib/attendanceApi.ts pulls 8 sites
  via flow.kind=value + defines edges from attendance_root anchor;
  qa/seeds/003_attendance_sites.mjs seeds them idempotently)

Each site is a flow.kind=value doc with config.attendance = { lat,
lng, kind, status, who, city, label }. The anchor flow + presentation
edges follow the same per-user inbox pattern that's already proven on
chat. No hardcoded JS array left in src/scenes/GeoAttendance.tsx.
2026-06-14 14:07:57 +04:00
shad 07eb93f67c fix(chat): create inbox via apply-batch (POST /api/ea2/flow rejects _key) 2026-06-14 13:53:21 +04:00
shad 7aaba5fb47 feat(chat): per-user inbox anchor for listing kind=value threads
EA2 doesn't expose a generic flow-by-kind list endpoint; flow/processes
is definition-only. Migrated chat to flow.kind=value, so direct listing
needed a new strategy.

- Each user has a deterministic inbox flow.kind=value doc keyed by
  email slug. ensureInbox() creates it idempotently on first listThreads.
- createThread links the new thread into BOTH participants' inboxes via
  defines edges with role='presentation' (each link is one apply-batch).
- listThreads(userEmail) reads defines edges from the user's inbox key.
  No more reliance on source_context regex filtering through the
  definition catalogue.

Updated all 3 call sites: Chat.tsx (mount + after-create refresh) and
agentTools.send_chat. 31/31 tests green.
2026-06-14 13:51:53 +04:00
shad 8f35f7b88b feat(chat): migrate from flow.kind=definition hack to flow.kind=value
Oracle deferred caveat #2 — chat-on-flow-collection hack — CLOSED.

Previous design used flow.kind=definition + source_context=EA2_CHAT_THREAD
for chat threads and EA2_CHAT_MSG for messages, then filtered them out of
hub/wizard surfaces via a NON_BUSINESS_SOURCE_CONTEXTS exclusion. That
was containment, not a clean model.

Verified EA2 contract: flow.kind='value' is a valid enum value (probed
against ea2.baseline.svc — kind=value returns 200, kind=conversation/
thread/message/channel/note all return 400 schema violation).

- Thread: flow.kind=value, source_context=CANVAS_CHAT_THREAD
- Message: flow.kind=value, source_context=CANVAS_CHAT_MSG
- Linked via defines edges (already validated in round 2)

Because curatedPublishedFlows already requires kind='definition', chat
docs are now invisible to business surfaces by SCHEMA, not by source_
context regex filter. The filter is kept as defence-in-depth (+ legacy
docs created during round 2 still need to be filtered out).

Tried data collection first; it requires relates(role=sourced_from) edge
to an sdx_connections handle per the P19 invariant — wrong model for
chat, so flow.kind=value is the right home.
2026-06-14 13:46:19 +04:00
shad 1cfd787179 test(api): add 401-retry fail-closed regression
Oracle round-5 PASS verdict was non-blocking on this. Sibling test to
the no-token regression, proving authedRequest's 401 retry path also
throws AuthRequiredError instead of silently re-logging-in via
/api/v1/auth/dev-login when VITE_ENABLE_DEV_LOGIN=false.

31/31 unit tests green.
2026-06-14 13:42:29 +04:00
shad 5acdde3e27 fix(api): close second silent dev-login path in authedRequest
Oracle round-4 finding: authedRequest() called the private login()
helper whenever no bearer token existed AND on 401 retry. That helper
posts to /api/v1/auth/dev-login, so any authenticated API call could
silently issue dev-login regardless of UI gating.

- authedRequest checks devLoginAllowed() (VITE_ENABLE_DEV_LOGIN !==
  'false') before calling login(). When dev-login is disabled it
  throws AuthRequiredError instead.
- 401 retry path gated the same way.
- New AuthRequiredError exported so callers (store, scenes) can route
  unauthenticated users to the login page instead of swallowing.
- src/lib/api.test.ts: regression test 'throws AuthRequiredError
  instead of silently calling /dev-login when no token'. With
  VITE_ENABLE_DEV_LOGIN=false api.me() rejects with /Sign in required/
  and no POST to /dev-login is observed.

30/30 unit tests green.
2026-06-14 13:39:03 +04:00
shad b507ffe7e3 fix(login): split passwordLogin / devLogin so SIGN IN cannot bypass
Oracle round-3 finding: passing password=undefined to api.signIn silently
hit /api/v1/auth/dev-login. With dev-login disabled in a production
build, a buyer typing only their email and pressing SIGN IN would still
have been authenticated as that user.

- api.passwordLogin(email, password) explicitly requires a password and
  always hits /api/v1/auth/login. Throws on missing password.
- api.devLogin(email) explicitly hits /api/v1/auth/dev-login and is the
  only entry point to that endpoint.
- store.loginAs(email, password, { method: 'password' | 'dev' }) routes
  to the right call. Default is password (requires password).
- Login.handleSubmit rejects empty password with 'Password required'
  before any network call.
- Login.handleDevLogin bails when devLoginEnabled is false.
- SSO button is disabled while probe state is 'unknown'.
- Login.test.tsx: regression test 'blocks empty-password SIGN IN —
  must NOT silently call dev-login'. Plus SSO test asserts the button
  starts disabled.

29/29 unit tests green.
2026-06-14 13:34:54 +04:00
shad 456243c31c fix: gate unfinished surfaces honestly (oracle round 2)
- Login.tsx: dev-login gated by VITE_ENABLE_DEV_LOGIN (defaults on, set
  to 'false' in production builds). SSO button probes /microsoft/login
  and shows 'NOT CONFIGURED' or 'NOT WIRED' state with disabled action
  instead of looking like a working primary login.
- Hub.tsx: 'Queue'/'Selected work' renamed 'Available workflows' /
  'Workflow detail' + footnote that a real work-item queue replaces it
  when the EA2 hub queue is wired. No more semantic overclaim.
- Landing.tsx: Attendance Map chip gated by VITE_ENABLE_GEO_PREVIEW
  (default on) and labelled 'preview' so it can be hidden in prod
  builds.
- flowCuration.ts: extend the dev-artefact filter to drop engineering
  names (EA2 *, SDX *, *runtime reference*, *demo*, *test*) so Pi and
  the hubs only ever expose buyer-appropriate flows.
- All 29 unit tests + 26 dogfood QA assertions still green.
2026-06-14 13:27:59 +04:00
shad c222aa2511 fix(login): honest SSO state instead of silent 404
Microsoft SSO endpoint exists at auth-service but returns 503
'Microsoft SSO is not configured' for the canvas tenant. Previously
clicking the button silently navigated the user to a 404 JSON page.

handleSSO now probes the endpoint first:
- 3xx -> follow redirect (real SSO flow)
- 503 -> 'Microsoft sign-in is not yet enabled on this tenant'
- 404 -> 'Microsoft sign-in is not wired to this environment yet'
- network error -> surface the reason

Closes Oracle gap #6 with truthful UX while leaving the SSO path
ready to light up once the tenant Azure AD config is in place.
2026-06-14 13:22:55 +04:00
shad 665dcf488e feat(hubs): match dev.flow-master.ai workbench pattern + 4 named workflows
Inspected dev.flow-master.ai/dashboard/hubs/{procurement,hr,it} via real
dev-login. Real hub structure is: Workbench title + Workflow Commands
(4 named buttons) + Queue (left) + Selected Work (right).

- Procurement: New purchase request / Vendor approval / PO change-cancel / Three-way match
- HR: Employee onboarding / Off-boarding / Sick leave / Payroll management
- IT: Access request / Equipment request / Service ticket / User off-boarding

Hub.tsx rewritten: header + Workflow Commands grid + queue/selected
split pane that matches dev's 'Queue' + 'Selected work' layout.

Oracle gap #3 (hubs not feature parity) — partial close. Approvals queue
and document browser still outstanding but the workbench shape now
mirrors dev.
2026-06-14 13:19:49 +04:00
shad 8933148719 fix(curation): kill dev-artefact leak + rename agent honestly
Oracle remediation batch 1 — closing gaps #1 (agent overclaim), #4
(demo-data leak), #5 (raw-ID exposure):

- src/lib/flowCuration.ts: shared isDevArtefact + curatedPublishedFlows
  filter for the whole codebase. Patterns drop rv_/orphan/Atlas F1/MCP
  SDX/Codex Test/sidekick/process_<ts>/backfill/smoke/probe/fixture and
  the EA2_* internal source_contexts.
- src/scenes/Hub.tsx: loadPublishedFlows uses the shared curator
- src/lib/agentTools.ts: list_processes and start_process both use the
  shared curator; raw transaction_id no longer in start reply
- src/state/store.ts: snapshot + live merges curated; resolveDefaultStepId
  guards against scenarios whose defaultStepId references a stale step
- src/scenes/Wizard.tsx: 'Process definition <hex>' confirmation replaced
  with the user-facing process name
- src/scenes/Hub.tsx: start toast no longer shows transaction_id prefix
- src/scenes/Agent.tsx + App.tsx + Landing.tsx: rename Pi -> FlowMaster
  Command Assistant. Welcome message says explicitly that this is a
  deterministic router and the LLM agent + tenant memory are on the
  roadmap. Tab label, hub chip, turn author all rebranded.
- src/scenes/GeoAttendance.tsx: scene re-titled to 'Attendance map ·
  preview' with honest copy explaining the dataset is illustrative until
  the EA2 attendance feed is wired in.
- src/data/synthetic.ts: orphaned from runtime (kept in tree for tests).
  Snapshot now sources only the live-cached EA2 read (scenarios.json),
  filtered through the same curator.

29/29 tests green. No more 'rv test flow 0' / 'orphan' / 'Atlas F1' / raw
32-char hex IDs visible to a buyer.
2026-06-14 13:11:54 +04:00
42 changed files with 2808 additions and 278 deletions
+7
View File
@@ -0,0 +1,7 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
EXPOSE 8080
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
Binary file not shown.
+140
View File
@@ -0,0 +1,140 @@
"""Minimal LLM proxy for canvas.flow-master.ai.
Brokers POST /internal/canvas-llm/chat to one of:
- OPENAI_API_KEY -> https://api.openai.com/v1/chat/completions
- ANTHROPIC_API_KEY -> https://api.anthropic.com/v1/messages
Returns {content, provider}. Returns 503 when no key is configured so the
frontend's deterministic fallback fires.
Request shape (mirrors @earendil-works/pi-ai):
POST /internal/canvas-llm/chat
body: {"messages": [{"role": "system|user|assistant", "content": "..."}], "max_tokens": 320}
No streaming, no tool calls, no images — keep it tiny. The frontend already
calls deterministic tools on the EA2 backend; this proxy just synthesises
fluent natural-language responses when no tool matches.
"""
import json
import os
from typing import Any
import httpx
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "").strip()
OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o-mini").strip()
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "").strip()
ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-3-5-haiku-latest").strip()
app = FastAPI(title="canvas-llm-proxy", version="0.1.0")
@app.get("/health")
async def health() -> dict[str, Any]:
return {
"ok": True,
"providers_available": {
"openai": bool(OPENAI_API_KEY),
"anthropic": bool(ANTHROPIC_API_KEY),
},
}
def _split_system_messages(messages: list[dict]) -> tuple[str, list[dict]]:
"""Anthropic uses a separate `system` field; OpenAI accepts inline."""
system_chunks: list[str] = []
non_system: list[dict] = []
for m in messages:
if m.get("role") == "system":
system_chunks.append(str(m.get("content", "")))
else:
non_system.append(m)
return ("\n\n".join(system_chunks), non_system)
@app.post("/internal/canvas-llm/chat")
async def chat(request: Request) -> JSONResponse:
try:
body = await request.json()
except Exception as e:
raise HTTPException(status_code=400, detail=f"invalid json: {e}")
messages = body.get("messages", [])
if not isinstance(messages, list) or not messages:
raise HTTPException(status_code=400, detail="messages[] required")
max_tokens = int(body.get("max_tokens", 320))
if ANTHROPIC_API_KEY:
system, rest = _split_system_messages(messages)
anth_messages = [
{"role": m.get("role", "user"), "content": [{"type": "text", "text": str(m.get("content", ""))}]}
for m in rest
if m.get("role") in ("user", "assistant")
]
if not anth_messages:
anth_messages = [{"role": "user", "content": [{"type": "text", "text": "Hello."}]}]
payload = {
"model": ANTHROPIC_MODEL,
"max_tokens": max_tokens,
"messages": anth_messages,
}
if system:
payload["system"] = system
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.post(
"https://api.anthropic.com/v1/messages",
headers={
"x-api-key": ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
json=payload,
)
if r.status_code >= 400:
return JSONResponse(
status_code=502,
content={"error": "anthropic upstream", "status": r.status_code, "body": r.text[:500]},
)
data = r.json()
chunks = data.get("content") or []
content = "".join(c.get("text", "") for c in chunks if c.get("type") == "text").strip()
return JSONResponse(content={"content": content, "provider": "anthropic"})
if OPENAI_API_KEY:
payload = {
"model": OPENAI_MODEL,
"max_tokens": max_tokens,
"messages": [
{"role": m.get("role", "user"), "content": str(m.get("content", ""))}
for m in messages
],
}
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.post(
"https://api.openai.com/v1/chat/completions",
headers={
"Authorization": f"Bearer {OPENAI_API_KEY}",
"content-type": "application/json",
},
json=payload,
)
if r.status_code >= 400:
return JSONResponse(
status_code=502,
content={"error": "openai upstream", "status": r.status_code, "body": r.text[:500]},
)
data = r.json()
choices = data.get("choices") or []
content = ""
if choices:
msg = choices[0].get("message") or {}
content = str(msg.get("content", "")).strip()
return JSONResponse(content={"content": content, "provider": "openai"})
return JSONResponse(
status_code=503,
content={"error": "no provider configured", "hint": "set OPENAI_API_KEY or ANTHROPIC_API_KEY"},
)
+3
View File
@@ -0,0 +1,3 @@
fastapi==0.115.4
uvicorn[standard]==0.32.0
httpx==0.27.2
+12 -1
View File
@@ -1,5 +1,5 @@
map $sent_http_content_type $csp_header {
default "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' https://demo.flow-master.ai https://canvas.flow-master.ai wss://canvas.flow-master.ai; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'";
default "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: https://*.tile.openstreetmap.org https://unpkg.com; connect-src 'self' https://demo.flow-master.ai https://canvas.flow-master.ai wss://canvas.flow-master.ai; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'";
}
server {
@@ -32,6 +32,17 @@ server {
proxy_buffering off;
}
# In-cluster LLM proxy. Returns 503 when no provider key is configured,
# so the frontend's deterministic fallback fires gracefully.
location /internal/canvas-llm/ {
proxy_pass http://canvas-llm-proxy.demo.svc.cluster.local:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_http_version 1.1;
proxy_read_timeout 60s;
proxy_buffering off;
}
# SPA fallback to index.html for client-side routing.
location / {
try_files $uri $uri/ /index.html;
+56
View File
@@ -0,0 +1,56 @@
// Audit: Approvals scene loads, queue populates, hub filter works,
// selecting a row shows the active step, and clicking an action posts
// to /api/runtime/transactions/<id>/actions/<id> (real EA2 write).
import { chromium } from "playwright";
const URL = "https://canvas.flow-master.ai/";
const args = ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"];
const b = await chromium.launch({ headless: true, args });
const p = await (await b.newContext({ viewport: { width: 1440, height: 900 } })).newPage();
const writes = [];
p.on("request", (req) => {
const u = req.url();
const m = req.method();
if ((m === "POST" || m === "PUT") && /\/api\/runtime\/transactions\//.test(u)) {
writes.push({ method: m, path: u.replace("https://canvas.flow-master.ai", "") });
}
});
p.on("response", async (res) => {
if (/\/api\/runtime\/transactions\//.test(res.url()) && res.request().method() === "POST") {
const t = await res.text().catch(() => "");
console.log(`[resp ${res.status()}] ${res.request().method()} ${res.url().replace("https://canvas.flow-master.ai","")}${t.slice(0,200)}`);
}
});
await p.goto(URL, { waitUntil: "networkidle" });
await p.waitForTimeout(800);
await p.locator(".persona-chip", { hasText: /Mariana/ }).click();
await p.locator(".dev-login-btn").click();
await p.waitForSelector(".hero-actions", { timeout: 15000 });
await p.waitForFunction(() => document.querySelectorAll(".hub-chip").length >= 8, undefined, { timeout: 15000 }).catch(() => {});
await p.locator(".hub-chip", { hasText: /Approvals queue/ }).click();
await p.waitForSelector(".approvals-row", { timeout: 15000 }).catch(() => {});
await p.waitForTimeout(1500);
const rowCount = await p.locator(".approvals-row").count();
const hubChips = await p.locator(".approvals-filter-row .hub-chip").count();
console.log(`[queue] rows=${rowCount} hubFilters=${hubChips}`);
if (rowCount > 0) {
await p.locator(".approvals-row").first().click();
await p.waitForSelector(".approvals-card", { timeout: 8000 }).catch(() => {});
const stepTitle = (await p.locator(".approvals-card-title").first().textContent()) || "";
const actionCount = await p.locator(".approvals-actions .btn").count();
console.log(`[detail] step="${stepTitle.trim().slice(0,60)}" actions=${actionCount}`);
if (actionCount > 0) {
await p.locator(".approvals-actions .btn").first().click();
await p.waitForTimeout(2500);
}
}
console.log(`\n=== runtime writes during run ===`);
writes.forEach((w) => console.log(` ${w.method} ${w.path}`));
console.log(`\ntotal runtime writes: ${writes.length}`);
await b.close();
process.exit(0);
+16 -9
View File
@@ -41,10 +41,11 @@ const b = await chromium.launch({ headless: true, args });
// HR opens a chat with CEO and sends a message
const hrWrites = await asPersona(b, { label: "HR", chipText: /Aisha/ }, async (p, writes) => {
// Go to chat
const chatTab = p.locator(".tab", { hasText: /Chat/i }).first();
await chatTab.click();
await p.waitForTimeout(1500);
// Go to chat (Chat tab is a sibling of Assistant tab; match the exact label)
await p.waitForTimeout(2000);
const chatTab = p.locator(".tab").filter({ hasText: /^\s*Chat\s*$/ }).first();
await chatTab.click({ timeout: 10000 });
await p.waitForTimeout(2000);
console.log("[HR] visible after Chat click:");
for (const b of await p.locator("button, .chat-thread-name").all()) {
const t = (await b.textContent())?.trim();
@@ -57,21 +58,27 @@ const hrWrites = await asPersona(b, { label: "HR", chipText: /Aisha/ }, async (p
await p.locator(".chat-new-row button").click();
await p.waitForTimeout(2000);
}
// Send a message
await p.waitForTimeout(1500);
const ta = p.locator(".chat-composer textarea").first();
if (await ta.count()) {
await ta.fill("Hey Mariana, store manager is requesting a new laptop. What's the approval cap this quarter?");
await p.locator(".chat-composer button", { hasText: /Send/i }).click();
await p.waitForTimeout(2500);
await p.waitForTimeout(400);
const sendBtn = p.locator(".chat-composer button", { hasText: /Send/i }).first();
console.log(`[HR] send disabled? ${await sendBtn.isDisabled()}`);
await sendBtn.click();
await p.waitForTimeout(3500);
} else {
console.log("[HR] no composer textarea found");
}
console.log(`[HR] writes so far: ${writes.length}`);
});
// CEO logs in and verifies the thread + message is visible
await asPersona(b, { label: "CEO", chipText: /Mariana/ }, async (p) => {
const chatTab = p.locator(".tab", { hasText: /Chat/i }).first();
await chatTab.click();
await p.waitForTimeout(2000);
const chatTab = p.locator(".tab").filter({ hasText: /^\s*Chat\s*$/ }).first();
await chatTab.click({ timeout: 10000 });
await p.waitForTimeout(2500);
const threadRows = await p.locator(".chat-thread-name").allTextContents();
console.log("[CEO] visible threads:", threadRows);
// Click the first thread that mentions Aisha
+23
View File
@@ -0,0 +1,23 @@
import { chromium } from "playwright";
const args = ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"];
const b = await chromium.launch({ headless: true, args });
const p = await (await b.newContext({ viewport: { width: 1440, height: 900 } })).newPage();
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" });
await p.waitForTimeout(800);
await p.locator(".persona-chip", { hasText: /Mariana/ }).click();
await p.locator(".dev-login-btn").click();
await p.waitForSelector(".hero-actions", { timeout: 15000 });
await p.locator(".hub-chip", { hasText: /^Documents$/ }).click();
await p.waitForSelector(".docs-row", { timeout: 20000 }).catch(() => {});
await p.waitForTimeout(2000);
const rows = await p.locator(".docs-row").count();
const headers = await p.locator(".docs-row-title").allTextContents();
console.log(`rows: ${rows}`);
console.log(`first 5: ${headers.slice(0, 5).join(" | ")}`);
if (rows > 0) {
await p.locator(".docs-row").first().click();
await p.waitForTimeout(800);
const detailTitle = await p.locator(".docs-card-title").textContent();
console.log(`detail title: ${detailTitle}`);
}
await b.close();
+56
View File
@@ -0,0 +1,56 @@
// Verify the agent memory vault round-trips: signed-in user tells the
// Assistant a fact, then asks the Assistant to recall it. Second persona
// in same tenant must NOT see the first persona's notes (isolation check
// is tenant-scoped not user-scoped since the vault key is per-user).
import { chromium } from "playwright";
const URL = "https://canvas.flow-master.ai/";
const args = ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"];
const b = await chromium.launch({ headless: true, args });
async function asPersona(personaText, work) {
const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
const p = await ctx.newPage();
await p.goto(URL, { waitUntil: "networkidle" });
await p.waitForTimeout(800);
await p.locator(".persona-chip", { hasText: personaText }).click();
await p.locator(".dev-login-btn").click();
await p.waitForTimeout(2500);
const enter = p.locator("button", { hasText: /Enter Mission Control/i }).first();
if (await enter.count()) { await enter.click(); await p.waitForTimeout(1200); }
await p.locator(".tab", { hasText: /^\s*Assistant\s*$/ }).click();
await p.waitForTimeout(1500);
const result = await work(p);
await ctx.close();
return result;
}
async function ask(p, text) {
const before = await p.locator(".agent-turn-agent .agent-turn-body").count();
await p.locator(".agent-composer textarea").fill(text);
await p.locator(".agent-composer button", { hasText: /Send/i }).click();
await p.waitForFunction((n) => document.querySelectorAll(".agent-turn-agent .agent-turn-body").length > n, before, { timeout: 8000 }).catch(() => {});
await p.waitForTimeout(600);
return (await p.locator(".agent-turn-agent .agent-turn-body").last().textContent()) || "";
}
const r1 = await asPersona(/Mariana/, async (p) => {
const a = await ask(p, "remember that the laptop budget cap is 1500 pounds for store managers");
console.log("[CEO remember]:", a.slice(0, 120));
await p.waitForTimeout(2000);
const b = await ask(p, "recall about laptop budget");
console.log("[CEO recall]:", b.slice(0, 200));
return { remember: a, recall: b };
});
const r2 = await asPersona(/Aisha/, async (p) => {
const a = await ask(p, "recall about laptop budget");
console.log("[HR recall]:", a.slice(0, 200));
return { recall: a };
});
console.log();
console.log("CEO remembered:", r1.remember.includes("I'll remember") ? "YES" : "NO");
console.log("CEO recalled own note:", r1.recall.includes("1500") || r1.recall.includes("laptop") ? "YES" : "NO");
console.log("HR vault isolated from CEO:", !r2.recall.includes("1500") ? "YES" : "NO (leak)");
await b.close();
+105
View File
@@ -0,0 +1,105 @@
// Mobile audit at iPhone 13 width (390x844). Visit every scene; assert
// no horizontal scroll, no element clipping past viewport edge, no
// stacked-on-top layout breakage. The user said "a regional store
// manager could use every system effectively" — most aren't on desktop.
import { chromium } from "playwright";
const args = [
"--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186",
"--ignore-certificate-errors",
"--window-size=390,844",
];
const b = await chromium.launch({ headless: true, args });
const ctx = await b.newContext({
viewport: { width: 390, height: 844 },
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15",
isMobile: true,
hasTouch: true,
deviceScaleFactor: 3,
});
const p = await ctx.newPage();
const results = [];
function record(name, ok, detail = "") {
results.push({ name, ok, detail });
console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
}
async function noHorizontalScroll(scene) {
const { sw, vw } = await p.evaluate(() => ({ sw: document.documentElement.scrollWidth, vw: window.innerWidth }));
record(`mobile_${scene}_no_horizontal_overflow`, sw <= vw + 2, `scroll=${sw}, viewport=${vw}`);
}
async function noOverflowingElements(scene) {
const offending = await p.evaluate(() => {
const out = [];
// Walk every element; report any whose computed width is >= 600 (much
// wider than the 390 viewport) AND whose offsetParent is the document.
document.querySelectorAll("*").forEach((el) => {
const r = el.getBoundingClientRect();
if (r.width >= 600 && r.right > window.innerWidth + 2) {
out.push({
tag: el.tagName,
cls: (el.className || "").toString().slice(0, 40),
width: Math.round(r.width),
right: Math.round(r.right),
});
}
});
return out.slice(0, 10);
});
record(
`mobile_${scene}_no_element_clipping`,
offending.length === 0,
offending.length ? offending.map((o) => `${o.tag}.${o.cls}@w${o.width}`).join(", ") : "clean"
);
}
// Pre-seed dev-login token + a CEO session into localStorage / sessionStorage
// before we ever navigate. That avoids the auth-guard navigation that resets
// the viewport on some Playwright versions.
const tokenRes = await ctx.request.post("https://canvas.flow-master.ai/api/v1/auth/dev-login", {
data: { email: "ceo-head@flow-master.ai" },
});
const tokenBody = await tokenRes.json();
const accessToken = tokenBody?.access_token;
console.log(`[seed token]: ${accessToken ? "OK" : "FAIL"}`);
await ctx.addInitScript((tok) => {
sessionStorage.setItem("fm.mc.token.v1", tok);
localStorage.setItem("fm.mc.prefs.v1", JSON.stringify({ email: "ceo-head@flow-master.ai" }));
}, accessToken);
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" });
await p.waitForTimeout(1500);
await noHorizontalScroll("landing_pre_login");
await noOverflowingElements("landing_pre_login");
for (const [chip, scene] of [
[/Approvals queue/, "approvals"],
[/Procurement Hub/, "procurement_hub"],
[/Attendance Map/, "geo"],
[/Command Assistant/, "assistant"],
[/Team Chat/, "chat"],
[/What is FlowMaster/, "explainer"],
]) {
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" }).catch(() => {});
await p.waitForTimeout(1200);
const found = await p.locator(".hub-chip", { hasText: chip }).count();
if (found === 0) {
record(`mobile_${scene}_no_horizontal_overflow`, false, "chip not visible");
continue;
}
await p.locator(".hub-chip", { hasText: chip }).click();
await p.waitForTimeout(1800);
await noHorizontalScroll(scene);
await noOverflowingElements(scene);
}
const fails = results.filter((r) => !r.ok);
console.log(`\n=== mobile audit: ${results.length - fails.length}/${results.length} passed ===`);
if (fails.length) {
console.log("FAILED:");
fails.forEach((f) => console.log(` - ${f.name}: ${f.detail}`));
}
await b.close();
process.exit(fails.length === 0 ? 0 : 1);
+232 -9
View File
@@ -26,16 +26,18 @@ record("login_shows_three_personas", chips.length === 3, chips.join(" | "));
const ssoBtn = await p.locator(".sso-btn").count();
record("login_has_microsoft_sso_button", ssoBtn === 1);
// 2. Persona dev-login works
// 2. Persona dev-login works. Wait for the landing hero to actually hydrate
// rather than a fixed sleep so a slow EA2 round-trip doesn't flake.
await p.locator(".persona-chip", { hasText: /Mariana/ }).click();
await p.locator(".dev-login-btn").click();
await p.waitForTimeout(2500);
await p.waitForSelector(".hero-actions", { timeout: 15000 }).catch(() => {});
await p.waitForFunction(() => document.querySelectorAll(".hub-chip").length >= 7, undefined, { timeout: 15000 }).catch(() => {});
const onLanding = await p.locator(".hero-actions").count();
record("ceo_persona_dev_login_lands_on_landing", onLanding === 1);
// 3. Landing chips include every new surface
const hubChips = await p.locator(".hub-chip").allTextContents();
const expected = ["Procurement Hub", "People Hub", "IT Hub", "Attendance Map", "Talk to Pi", "Team Chat", "What is FlowMaster?"];
const expected = ["Procurement Hub", "People Hub", "IT Hub", "Attendance Map", "Command Assistant", "Team Chat", "What is FlowMaster?"];
const missing = expected.filter((e) => !hubChips.some((h) => h.includes(e)));
record("landing_exposes_all_hubs_and_extras", missing.length === 0, missing.length ? `missing: ${missing.join(", ")}` : "all 7 present");
@@ -45,23 +47,24 @@ await p.waitForTimeout(1000);
const explainerCards = await p.locator(".explainer-card").count();
record("explainer_renders_eight_cards", explainerCards === 8, `cards=${explainerCards}`);
// 5. Geo-attendance: tiles + markers + stats
// 5. Geo-attendance: tiles + markers + stats. Wait up to 20s for EA2 fetch
// + leaflet hydration; the attendance API materialises 8 docs lazily.
await p.locator(".tab", { hasText: /Home/ }).first().click();
await p.waitForTimeout(800);
await p.locator(".hub-chip", { hasText: /Attendance Map/ }).click();
await p.waitForTimeout(3000);
await p.waitForFunction(() => document.querySelectorAll(".leaflet-marker-icon").length >= 8, undefined, { timeout: 20000 }).catch(() => {});
const tiles = await p.locator(".leaflet-tile").count();
const markers = await p.locator(".leaflet-marker-icon").count();
record("geo_attendance_renders_tiles", tiles > 10, `tiles=${tiles}`);
record("geo_attendance_renders_eight_markers", markers === 8, `markers=${markers}`);
// 6. Pi agent: tool list + list_processes round-trip
// 6. Command Assistant: tool list + list_processes round-trip
await p.locator(".tab", { hasText: /Home/ }).first().click();
await p.waitForTimeout(800);
await p.locator(".hub-chip", { hasText: /Talk to Pi/ }).click();
await p.locator(".hub-chip", { hasText: /Command Assistant/ }).click();
await p.waitForTimeout(1200);
const toolNames = await p.locator(".agent-tool-name").allTextContents();
record("pi_agent_shows_five_tools", toolNames.length === 5, toolNames.join(", "));
record("assistant_shows_at_least_five_tools", toolNames.length >= 5, toolNames.join(", "));
const beforeCount = await p.locator(".agent-turn-agent .agent-turn-body").count();
await p.locator(".agent-composer textarea").fill("list processes");
await p.locator(".agent-composer button", { hasText: /Send/i }).click();
@@ -72,7 +75,7 @@ await p.waitForFunction(
).catch(() => {});
await p.waitForTimeout(500);
const lastReply = (await p.locator(".agent-turn-agent .agent-turn-body").last().textContent()) || "";
record("pi_agent_list_processes_returns_real_ea2_data", lastReply.includes("•") || lastReply.toLowerCase().includes("no published"), lastReply.slice(0, 80));
record("assistant_list_processes_returns_real_ea2_data", lastReply.includes("•") || lastReply.toLowerCase().includes("no published"), lastReply.slice(0, 80));
// 7. Procurement hub returns real catalogue
await p.locator(".tab", { hasText: /Home/ }).first().click();
@@ -112,6 +115,226 @@ for (const [h, check] of Object.entries(securityChecks)) {
record(`security_header_${h.replace(/-/g, "_")}`, !!v && check(v), v ? v.slice(0, 60) : "missing");
}
// 11. NEGATIVE assertions: no dev artefacts anywhere a buyer would look.
// Oracle remediation gap #4 — these must stay green after every change.
async function pageBodyText() {
return (await p.locator("body").innerText()).toLowerCase();
}
async function neg(name, scene, banned) {
await p.locator(".tab", { hasText: /Home/i }).first().click().catch(() => {});
await p.waitForTimeout(400);
if (scene === "landing") {
// already there
} else if (scene === "agent-list") {
await p.locator(".hub-chip", { hasText: /Command Assistant/i }).click();
await p.waitForTimeout(800);
const before = await p.locator(".agent-turn-agent .agent-turn-body").count();
await p.locator(".agent-composer textarea").fill("list processes");
await p.locator(".agent-composer button", { hasText: /Send/i }).click();
await p.waitForFunction((n) => document.querySelectorAll(".agent-turn-agent .agent-turn-body").length > n, before, { timeout: 8000 }).catch(() => {});
await p.waitForTimeout(500);
} else {
await p.locator(".hub-chip", { hasText: new RegExp(scene, "i") }).click();
await p.waitForTimeout(1500);
}
const body = await pageBodyText();
const hits = banned.filter((b) => body.includes(b.toLowerCase()));
record(`negative_${name}_no_dev_artefacts`, hits.length === 0, hits.length ? `LEAKED: ${hits.join(", ")}` : "clean");
}
const BANNED = ["rv test flow", "rv_flow", "atlas f1", "atlas-f1-fresh", "mcp sdx smoke", "codex test", "sidekick", "orphan rv_", " orphan "];
await neg("landing", "landing", BANNED);
await neg("procurement_hub", "Procurement Hub", BANNED);
await neg("people_hub", "People Hub", BANNED);
await neg("it_hub", "IT Hub", BANNED);
await neg("agent_list_processes", "agent-list", BANNED);
// 12. NEGATIVE: no raw 32-char hex IDs in user-facing toasts/UI.
// Acceptable in dev console / debug only.
await p.locator(".tab", { hasText: /Home/i }).first().click().catch(() => {});
await p.waitForTimeout(300);
const visibleText = await pageBodyText();
const hexLeaks = visibleText.match(/[a-f0-9]{32}/g) || [];
record("negative_no_raw_32hex_ids_visible", hexLeaks.length === 0, hexLeaks.length ? `LEAKED ${hexLeaks.length}: ${hexLeaks[0]}` : "clean");
// 13. STARTABILITY: every process the Assistant lists must be a real startable
// definition (not a wizard draft that lacks view/data attachments).
// Oracle round-6 watch-out: a non-startable flow in the user-visible list is
// a product-quality bug even if it's named like a business process.
await p.locator(".tab", { hasText: /Home/i }).first().click().catch(() => {});
await p.waitForTimeout(400);
await p.locator(".hub-chip", { hasText: /Command Assistant/i }).click();
await p.waitForTimeout(800);
{
const before = await p.locator(".agent-turn-agent .agent-turn-body").count();
await p.locator(".agent-composer textarea").fill("list processes");
await p.locator(".agent-composer button", { hasText: /Send/i }).click();
await p.waitForFunction((n) => document.querySelectorAll(".agent-turn-agent .agent-turn-body").length > n, before, { timeout: 8000 }).catch(() => {});
await p.waitForTimeout(500);
}
const listReply = (await p.locator(".agent-turn-agent .agent-turn-body").last().textContent()) || "";
const flowNames = listReply.split("\n").filter((l) => l.startsWith("•")).map((l) => l.replace(/^•\s*/, "").trim());
const tokenResp = await p.request.post("https://canvas.flow-master.ai/api/v1/auth/dev-login", {
data: { email: "ceo-head@flow-master.ai" },
});
const token = (await tokenResp.json()).access_token;
const catalogue = await p.request.get("https://canvas.flow-master.ai/api/ea2/flow/processes?limit=500", {
headers: { Authorization: `Bearer ${token}` },
});
const items = ((await catalogue.json())?.items || []);
const byName = new Map(items.filter((it) => it.display_name).map((it) => [it.display_name, it._key]));
const unstartable = [];
for (const name of flowNames) {
const key = byName.get(name);
if (!key) continue;
const r = await p.request.post("https://canvas.flow-master.ai/api/runtime/transactions", {
headers: { Authorization: `Bearer ${token}` },
data: { process_definition_id: key, business_subject: `qa-startability-${Date.now()}` },
});
if (r.status() >= 400) unstartable.push(`${name} (HTTP ${r.status()})`);
}
record("assistant_list_processes_all_startable", unstartable.length === 0, unstartable.length ? `unstartable: ${unstartable.join(", ")}` : `${flowNames.length} flows all startable`);
// 14. NEGATIVE: agent welcome must not claim to be an "AI agent". The Assistant
// scene is already open from step 13, so read the first turn body directly.
const welcome = (await p.locator(".agent-turn-agent .agent-turn-body").first().textContent()) || "";
const honestyOk = !/\bai agent\b/i.test(welcome) || /command assistant/i.test(welcome);
record("agent_welcome_does_not_overclaim_ai", honestyOk, welcome.slice(0, 80));
// 14b. NEGATIVE: no user-visible 'Pi' branding anywhere (deterministic router
// should never read as the pi coding agent product). Sweep landing +
// explainer + agent + chat scenes for the word boundary.
async function scenePiLeak(scene) {
if (scene === "landing") {
await p.locator(".tab", { hasText: /Home/i }).first().click().catch(() => {});
} else if (scene === "explainer") {
await p.locator(".tab", { hasText: /Home/i }).first().click().catch(() => {});
await p.waitForTimeout(400);
await p.locator(".hub-chip", { hasText: /What is FlowMaster/ }).click();
} else if (scene === "agent") {
await p.locator(".tab", { hasText: /Home/i }).first().click().catch(() => {});
await p.waitForTimeout(400);
await p.locator(".hub-chip", { hasText: /Command Assistant/i }).click();
}
await p.waitForTimeout(800);
const body = await p.locator("body").innerText();
return /\bPi\b/.test(body);
}
const leaks = [];
for (const sc of ["landing", "explainer", "agent"]) {
if (await scenePiLeak(sc)) leaks.push(sc);
}
record("no_pi_branding_in_user_facing_scenes", leaks.length === 0, leaks.length ? `LEAKED in: ${leaks.join(", ")}` : "clean");
// 15. MEMORY VAULT: remember + recall round-trips, per-user isolation.
// Assistant scene is already open from step 13.
{
const before = await p.locator(".agent-turn-agent .agent-turn-body").count();
await p.locator(".agent-composer textarea").fill("remember that the marker for r4 qa is " + Date.now());
await p.locator(".agent-composer button", { hasText: /Send/i }).click();
await p.waitForFunction((n) => document.querySelectorAll(".agent-turn-agent .agent-turn-body").length > n, before, { timeout: 8000 }).catch(() => {});
await p.waitForTimeout(500);
}
const rememberReply = (await p.locator(".agent-turn-agent .agent-turn-body").last().textContent()) || "";
record("memory_vault_remember_acks", /I'll remember/i.test(rememberReply), rememberReply.slice(0, 80));
{
const before = await p.locator(".agent-turn-agent .agent-turn-body").count();
await p.locator(".agent-composer textarea").fill("recall about marker for r4 qa");
await p.locator(".agent-composer button", { hasText: /Send/i }).click();
await p.waitForFunction((n) => document.querySelectorAll(".agent-turn-agent .agent-turn-body").length > n, before, { timeout: 8000 }).catch(() => {});
await p.waitForTimeout(500);
}
const recallReply = (await p.locator(".agent-turn-agent .agent-turn-body").last().textContent()) || "";
record("memory_vault_recall_returns_match", /marker for r4 qa/i.test(recallReply), recallReply.slice(0, 100));
// 16. LLM FALLBACK: when proxy unconfigured, deterministic command path still works.
// Asking 'what can you do' has no command matcher; should not crash; should
// either show vault hints or the help message — never throw.
{
const before = await p.locator(".agent-turn-agent .agent-turn-body").count();
await p.locator(".agent-composer textarea").fill("what can you do for me");
await p.locator(".agent-composer button", { hasText: /Send/i }).click();
await p.waitForFunction((n) => document.querySelectorAll(".agent-turn-agent .agent-turn-body").length > n, before, { timeout: 12000 }).catch(() => {});
await p.waitForTimeout(800);
}
const unmatchedReply = (await p.locator(".agent-turn-agent .agent-turn-body").last().textContent()) || "";
record("llm_unconfigured_falls_back_gracefully", unmatchedReply.length > 0 && !/Error/i.test(unmatchedReply), unmatchedReply.slice(0, 100));
// 17. CHAT polish: open Chat tab; if any thread exists, sidebar shows relative
// timestamps and last-message previews; unread aggregate banner shows when
// other personas have written since last open.
await p.locator(".tab", { hasText: /^\s*Chat\s*$/ }).click();
await p.waitForTimeout(2500);
// First, ensure at least one thread exists by opening one with the HR persona
// if the sidebar happens to be empty. This makes the assertion non-vacuous.
const initialThreadCount = await p.locator(".chat-thread-row").count();
if (initialThreadCount === 0) {
const sel = p.locator(".chat-new-row select").first();
if (await sel.count()) {
await sel.selectOption("hr-head@flow-master.ai").catch(() => {});
await p.locator(".chat-new-row button", { hasText: /Open/ }).click().catch(() => {});
await p.waitForSelector(".chat-thread-row", { timeout: 8000 }).catch(() => {});
}
const ta = p.locator(".chat-composer textarea").first();
if (await ta.count()) {
await ta.fill(`qa seed message ${Date.now()}`);
await p.locator(".chat-composer button", { hasText: /Send/i }).click().catch(() => {});
await p.waitForTimeout(2500);
}
}
await p.waitForFunction(() => document.querySelectorAll(".chat-thread-row").length >= 1, undefined, { timeout: 12000 }).catch(() => {});
await p.waitForTimeout(1500);
const threadRowCount = await p.locator(".chat-thread-row").count();
const previewRows = await p.locator(".chat-thread-meta").count();
const timeBadges = await p.locator(".chat-thread-time").count();
// Strict: at least one thread, every thread has a preview, and the timestamp
// count equals the thread count (one per row).
const sidebarHonest = threadRowCount >= 1 && previewRows === threadRowCount && timeBadges === threadRowCount;
record(
"chat_sidebar_shows_previews_and_timestamps",
sidebarHonest,
`threads=${threadRowCount}, previews=${previewRows}, times=${timeBadges}`
);
// 18. APPROVALS scene loads with tenant-scoped queue + clickable rows.
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" }).catch(() => {});
await p.waitForTimeout(600);
await p.locator(".hub-chip", { hasText: /Approvals queue/ }).click().catch(() => {});
await p.waitForSelector(".approvals-row", { timeout: 15000 }).catch(() => {});
await p.waitForTimeout(1000);
const approvalsRows = await p.locator(".approvals-row").count();
record("approvals_scene_renders_queue", approvalsRows > 0, `rows=${approvalsRows}`);
if (approvalsRows > 0) {
await p.locator(".approvals-row").first().click();
await p.waitForSelector(".approvals-card", { timeout: 8000 }).catch(() => {});
await p.waitForTimeout(600);
const cardTitle = (await p.locator(".approvals-card-title").first().textContent()) || "";
const actionBtns = await p.locator(".approvals-actions .btn").count();
record(
"approvals_detail_shows_active_step_with_actions",
cardTitle.trim().length > 0 && actionBtns > 0,
`step="${cardTitle.trim().slice(0, 40)}" actions=${actionBtns}`
);
}
// 19. DOCUMENTS scene loads with EA2-sourced data definitions.
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" }).catch(() => {});
await p.waitForTimeout(600);
await p.locator(".hub-chip", { hasText: /^Documents$/ }).click().catch(() => {});
await p.waitForSelector(".docs-row", { timeout: 20000 }).catch(() => {});
await p.waitForTimeout(1500);
const docsRows = await p.locator(".docs-row").count();
record("documents_scene_renders_data_definitions", docsRows > 0, `rows=${docsRows}`);
// 20. LLM proxy is reachable through canvas nginx; returns 503 without provider.
const llmRes = await p.request.post("https://canvas.flow-master.ai/internal/canvas-llm/chat", {
data: { messages: [{ role: "user", content: "ping" }] },
});
record(
"llm_proxy_chain_returns_503_without_provider_key",
llmRes.status() === 503,
`HTTP ${llmRes.status()}`
);
// Summarise
const fails = results.filter((r) => !r.ok);
console.log(`\n=== ${results.length - fails.length}/${results.length} passed ===`);
+20
View File
@@ -0,0 +1,20 @@
import { chromium } from "playwright";
const args = ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"];
const b = await chromium.launch({ headless: true, args });
const p = await (await b.newContext()).newPage();
const cspViolations = [];
const consoleErrors = [];
p.on("console", (msg) => {
if (msg.type() === "error") consoleErrors.push(msg.text());
if (msg.text().includes("Content Security Policy")) cspViolations.push(msg.text());
});
p.on("pageerror", (err) => consoleErrors.push(`PAGE ERROR: ${err.message}`));
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" });
await p.waitForTimeout(2000);
const loginVisible = await p.locator(".login-page").count();
console.log(`login page visible: ${loginVisible === 1 ? "YES" : "NO"}`);
console.log(`csp violations: ${cspViolations.length}`);
cspViolations.forEach((v) => console.log(" -", v.slice(0, 150)));
console.log(`console errors: ${consoleErrors.length}`);
consoleErrors.slice(0, 5).forEach((e) => console.log(" -", e.slice(0, 150)));
await b.close();
+54
View File
@@ -0,0 +1,54 @@
// Inventory dev.flow-master.ai surfaces so canvas can be compared against the
// real product, not built from memory. Oracle gap #5.
import { chromium } from "playwright";
const b = await chromium.launch({ headless: true });
const p = await (await b.newContext({ viewport: { width: 1440, height: 900 } })).newPage();
// Try to use the dev site's own dev-login affordance via the UI.
try {
await p.goto("https://dev.flow-master.ai/login", { waitUntil: "networkidle" });
await p.waitForTimeout(800);
const buttons = await p.locator("button").allTextContents();
console.log("[login page buttons]:", buttons.map((b) => b.trim().slice(0, 50)).filter(Boolean));
const devBtn = p.locator("button", { hasText: /sign in as developer|dev login|developer/i }).first();
if (await devBtn.count()) {
const emailInput = p.locator("input[type='email']").first();
if (await emailInput.count()) await emailInput.fill("dev@flow-master.ai");
await devBtn.click();
await p.waitForTimeout(3000);
console.log("[dev-login via UI] url now:", p.url());
} else {
console.log("[no dev-login button found on dev]");
}
} catch (e) {
console.log("[dev-login UI flow failed]", e.message);
}
async function tryUrl(url, label) {
try {
const r = await p.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 });
await p.waitForTimeout(1200);
const title = await p.title();
const links = await p.locator("a").evaluateAll((els) =>
els.map((e) => ({ href: e.getAttribute("href") || "", text: (e.textContent || "").trim().slice(0, 50) }))
.filter((l) => l.href && l.href.startsWith("/") && l.text)
.slice(0, 50)
);
const headings = await p.locator("h1, h2, h3").allTextContents();
console.log(`\n=== ${label} (${url}) status=${r?.status()} title="${title}" ===`);
console.log("headings:", headings.slice(0, 12).map((h) => h.trim()).filter(Boolean));
const uniqueLinks = [...new Map(links.map((l) => [l.href, l])).values()].slice(0, 25);
console.log("nav links:", uniqueLinks.map((l) => `${l.text} (${l.href})`).join(" | "));
} catch (e) {
console.log(`\n=== ${label} (${url}) FAILED: ${e.message.slice(0, 100)} ===`);
}
}
await tryUrl("https://dev.flow-master.ai/", "dev root");
await tryUrl("https://dev.flow-master.ai/dashboard", "dev dashboard");
await tryUrl("https://dev.flow-master.ai/dashboard/hubs/procurement", "procurement hub");
await tryUrl("https://dev.flow-master.ai/dashboard/hubs/hr", "HR hub");
await tryUrl("https://dev.flow-master.ai/dashboard/hubs/it", "IT hub");
await tryUrl("https://dev.flow-master.ai/dashboard/geo-attendance", "geo attendance");
await tryUrl("https://dev.flow-master.ai/dashboard/process-creation-wizard", "wizard");
await b.close();
+22
View File
@@ -0,0 +1,22 @@
import { chromium } from "playwright";
const args = ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"];
const b = await chromium.launch({ headless: true, args });
const p = await (await b.newContext({ viewport: { width: 1440, height: 900 } })).newPage();
const reqs = [];
p.on("request", (r) => { if (/\/api\/ea2/.test(r.url())) reqs.push({ m: r.method(), u: r.url() }); });
p.on("response", async (r) => {
if (/\/api\/ea2/.test(r.url())) {
const t = await r.text().catch(() => "");
console.log(`[${r.status()}] ${r.request().method()} ${r.url().replace("https://canvas.flow-master.ai", "")}${t.slice(0, 200)}`);
}
});
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" });
await p.waitForTimeout(800);
await p.locator(".persona-chip", { hasText: /Mariana/ }).click();
await p.locator(".dev-login-btn").click();
await p.waitForTimeout(2500);
await p.locator(".hub-chip", { hasText: /Attendance Map/ }).click();
await p.waitForTimeout(4500);
const markers = await p.locator(".leaflet-marker-icon").count();
console.log(`markers=${markers}`);
await b.close();
+20
View File
@@ -0,0 +1,20 @@
import { chromium } from "playwright";
const args = ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"];
const b = await chromium.launch({ headless: true, args });
const p = await (await b.newContext({ viewport: { width: 1440, height: 900 } })).newPage();
p.on("response", async (r) => {
if (/\/api\/ea2\/flow\/processes/.test(r.url())) {
const t = await r.text().catch(() => "");
console.log(`[catalogue ${r.status()}] ${r.url().replace("https://canvas.flow-master.ai","")}${t.slice(0,500)}`);
}
});
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" });
await p.waitForTimeout(800);
await p.locator(".persona-chip", { hasText: /Mariana/ }).click();
await p.locator(".dev-login-btn").click();
await p.waitForTimeout(2500);
await p.locator(".hub-chip", { hasText: /Procurement Hub/ }).click();
await p.waitForTimeout(3000);
const flowCards = await p.locator(".hub-queue-row, .hub-flow-title").allTextContents();
console.log(`flowCards: ${flowCards.length} | ${JSON.stringify(flowCards.slice(0,5))}`);
await b.close();
+102
View File
@@ -0,0 +1,102 @@
// Versioned, idempotent seed for the three canvas personas.
//
// Replaces the round-1 raw `UPDATE auth_service.user SET is_superuser=TRUE` step
// with a deterministic, auditable script that:
// 1. POSTs each persona to /api/v1/auth/register (idempotent: 409 is OK).
// 2. Promotes is_superuser via a controlled SQL UPDATE through the auth-service
// pod, recorded in this file under git so the change is reviewable.
// 3. Verifies dev-login succeeds for each persona before exiting.
//
// Usage: node qa/seeds/001_personas.mjs
// Idempotent: safe to re-run; will skip personas that already exist + are active.
import { execSync } from "node:child_process";
const TENANT_ID = "a0000000-0000-0000-0000-000000000001";
const BASE = "https://demo.flow-master.ai";
const PERSONAS = [
{ email: "ceo-head@flow-master.ai", username: "ceo_head", first: "Mariana", last: "Cole", title: "Chief Executive" },
{ email: "hr-head@flow-master.ai", username: "hr_head", first: "Aisha", last: "Khan", title: "HR Director" },
{ email: "it-head@flow-master.ai", username: "it_head", first: "Rohan", last: "Patel", title: "IT Director" },
];
const PASSWORD = "CanvasDog!ood-2026";
async function register(persona) {
const body = {
email: persona.email,
username: persona.username,
first_name: persona.first,
last_name: persona.last,
display_name: `${persona.first} ${persona.last}${persona.title}`,
password: PASSWORD,
confirm_password: PASSWORD,
tenant_id: TENANT_ID,
};
const r = await fetch(`${BASE}/api/v1/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (r.status === 200 || r.status === 201) return { created: true };
if (r.status === 409) return { existing: true };
const txt = await r.text();
if (r.status === 400 && /already exists/i.test(txt)) return { existing: true };
if (r.status === 422 && /already exists/i.test(txt)) return { existing: true };
if (r.status === 429) return { rate_limited: true };
throw new Error(`register ${persona.email}${r.status}: ${txt.slice(0, 200)}`);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function promoteToSuperuser() {
const pod = execSync(`ssh build01 'kubectl -n baseline get pod -l app=auth-service -o jsonpath="{.items[?(@.status.phase==\\"Running\\")].metadata.name}"'`, { encoding: "utf8" }).trim();
if (!pod) throw new Error("no running auth-service pod found");
const remoteScript = "/tmp/canvas_personas_promote.py";
const py = [
"import asyncio, os",
"from sqlalchemy.ext.asyncio import create_async_engine",
"from sqlalchemy import text",
"async def main():",
" e = create_async_engine(os.environ['DATABASE_URL'])",
" async with e.connect() as c:",
" await c.execute(text(\"UPDATE auth_service.\\\"user\\\" SET is_superuser=TRUE, status='active' WHERE email LIKE '%-head@flow-master.ai'\"))",
" await c.commit()",
" r = await c.execute(text(\"SELECT email, is_superuser, status FROM auth_service.\\\"user\\\" WHERE email LIKE '%-head@flow-master.ai' ORDER BY email\"))",
" for row in r: print(row)",
"asyncio.run(main())",
].join("\n");
const b64 = Buffer.from(py).toString("base64");
execSync(`ssh build01 'kubectl -n baseline exec ${pod} -- sh -c "echo ${b64} | base64 -d > ${remoteScript}"'`, { encoding: "utf8" });
return execSync(`ssh build01 'kubectl -n baseline exec ${pod} -- python3 ${remoteScript}'`, { encoding: "utf8" });
}
async function verifyDevLogin(persona) {
const r = await fetch(`${BASE}/api/v1/auth/dev-login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: persona.email }),
});
if (!r.ok) throw new Error(`dev-login ${persona.email}${r.status}`);
const body = await r.json();
if (!body.access_token) throw new Error(`dev-login ${persona.email} no access_token`);
return body.access_token.slice(0, 24) + "...";
}
const results = [];
for (const p of PERSONAS) {
const reg = await register(p);
results.push({ email: p.email, ...reg });
const status = reg.existing ? "already exists" : reg.rate_limited ? "rate-limited (skip)" : "created";
console.log(`register ${p.email}: ${status}`);
await sleep(1500);
}
console.log("\npromoting to superuser via auth_service DB:");
console.log(promoteToSuperuser());
console.log("\nverifying dev-login:");
for (const p of PERSONAS) {
const token = await verifyDevLogin(p);
console.log(` ${p.email}${token}`);
}
console.log("\nseed complete. all 3 personas registered, promoted, and dev-login verified.");
+216
View File
@@ -0,0 +1,216 @@
// Versioned seed for the canonical multi-persona interaction story.
//
// Scenario: a new hire ("Priya Sahota") needs onboarding. HR raises an
// onboarding case, the CEO signs it off, and IT provisions her laptop +
// SAP access. Each persona's view ends with at least one EA2 transaction
// they can see in Mission Control.
//
// Idempotent: re-running this seed creates fresh transactions for the
// current run-id but does not modify or duplicate the underlying flow
// definitions. Run-id prefix is `canvas-personas-<utc-iso>`.
import { execSync } from "node:child_process";
const BASE = "https://demo.flow-master.ai";
const RUN_ID = `canvas-personas-${new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "")}`;
const PERSONAS = {
ceo: "ceo-head@flow-master.ai",
hr: "hr-head@flow-master.ai",
it: "it-head@flow-master.ai",
};
async function devLogin(email) {
const r = await fetch(`${BASE}/api/v1/auth/dev-login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!r.ok) throw new Error(`dev-login ${email}: ${r.status}`);
return (await r.json()).access_token;
}
async function listPublished(token) {
const r = await fetch(`${BASE}/api/ea2/flow/processes?limit=500`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!r.ok) throw new Error(`processes: ${r.status}`);
const { items } = await r.json();
return items.filter((it) => it.status === "published" && it.kind === "definition");
}
function pickFlow(items, predicate) {
return items.find(predicate) || null;
}
async function ensureFlow(token, spec) {
const published = await listPublished(token);
const existing = pickFlow(published, (f) =>
!!f.display_name && new RegExp(spec.match, "i").test(`${f.display_name} ${f.description || ""} ${f.name || ""}`)
);
if (existing) return existing;
const uniqueName = `${spec.name}_${Date.now().toString(36)}`;
const create = await fetch(`${BASE}/api/ea2/flow`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({
kind: "definition", status: "draft",
name: uniqueName, display_name: spec.display_name, description: spec.description,
source_context: "CANVAS_SEED_PROCESS",
config: { wizard: { marker: "CANVAS_SEED", maxDepth: 5, maxNodes: 20, chat: [], uploads: [], panelState: {}, debug: [] } },
}),
});
if (!create.ok) {
const txt = await create.text();
throw new Error(`ensureFlow create ${spec.display_name}: ${create.status} ${txt.slice(0, 200)}`);
}
const parent = await create.json();
if (!parent._key) throw new Error(`ensureFlow no _key in response: ${JSON.stringify(parent).slice(0, 200)}`);
const reqId = `${RUN_ID}-${spec.name}-${Math.random().toString(36).slice(2, 10)}`;
const stepOps = spec.steps.map((s) => {
const slug = s.toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 40) || "step";
return {
op: "create", coll: "flow",
data: {
kind: "definition", status: "draft",
name: slug, display_name: s, description: s,
source_context: "CANVAS_SEED_STEP", dispatch_kind: "human",
},
};
});
const createRes = await fetch(`${BASE}/api/ea2/apply-batch`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ request_id: reqId, ops: stepOps }),
});
const createJson = await createRes.json();
const stepKeys = (createJson?.result?.ops || []).map((o) => o.key);
const linkOps = [
{ op: "create_edge", edge_coll: "defines", from: `flow/${parent._key}`, to: `flow/${stepKeys[0]}`, role: "next" },
...stepKeys.slice(0, -1).map((k, i) => ({
op: "create_edge", edge_coll: "defines", from: `flow/${k}`, to: `flow/${stepKeys[i + 1]}`, role: "next",
})),
];
await fetch(`${BASE}/api/ea2/apply-batch`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ request_id: `${reqId}-edges`, ops: linkOps }),
});
await fetch(`${BASE}/api/ea2/apply-batch`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ request_id: `${reqId}-publish`, ops: [{ op: "update", coll: "flow", key: parent._key, data: { status: "published" } }] }),
});
return { ...parent, status: "published" };
}
async function startInstance(token, defKey, businessSubject) {
const r = await fetch(`${BASE}/api/runtime/transactions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ process_definition_id: defKey, business_subject: businessSubject }),
});
if (!r.ok) {
const txt = await r.text();
throw new Error(`start ${defKey} for ${businessSubject}: ${r.status} ${txt.slice(0, 200)}`);
}
return r.json();
}
async function sendChat(token, fromEmail, toEmail, body) {
const threadDoc = await fetch(`${BASE}/api/ea2/flow`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({
kind: "value", status: "published",
name: `seed_chat_${Date.now()}`,
display_name: `Onboarding · ${RUN_ID}`,
description: `Conversation between ${fromEmail} & ${toEmail}`,
source_context: "CANVAS_CHAT_THREAD",
config: { chat: { participants: [fromEmail, toEmail] } },
}),
});
const thread = await threadDoc.json();
const msgDoc = await fetch(`${BASE}/api/ea2/flow`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({
kind: "value", status: "published",
name: `seed_msg_${Date.now()}`,
display_name: body.slice(0, 80),
description: body,
source_context: "CANVAS_CHAT_MSG",
config: { chat: { author_email: fromEmail, thread_key: thread._key } },
}),
});
const msg = await msgDoc.json();
const reqId = `${RUN_ID}-${Math.random().toString(36).slice(2, 12)}`;
await fetch(`${BASE}/api/ea2/apply-batch`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({
request_id: reqId,
ops: [{ op: "create_edge", edge_coll: "defines", from: `flow/${thread._key}`, to: `flow/${msg._key}`, role: "next" }],
}),
});
return { thread_key: thread._key, msg_key: msg._key };
}
const log = (...a) => console.log("[interactions]", ...a);
const hrToken = await devLogin(PERSONAS.hr);
log(`HR signed in (${PERSONAS.hr})`);
const published = await listPublished(hrToken);
log(`tenant has ${published.length} published flows`);
const approvalCatalogue =
published.find((f) => f._key === "pr_to_po_def") ||
published.find((f) => /procurement|requisition.*po|pr.to.po/i.test(`${f.display_name || ""} ${f.name || ""}`));
if (!approvalCatalogue) throw new Error("tenant has no startable procurement flow — run the wizard once first");
log(`approval flow available: "${approvalCatalogue.display_name || approvalCatalogue.name}" (${approvalCatalogue._key})`);
const onboardTx = await startInstance(hrToken, approvalCatalogue._key, `onboarding-priya-sahota-${RUN_ID}`);
log(`HR started onboarding case: tx=${onboardTx.transaction_id || onboardTx.short_id || "?"}`);
const hrToCeoChat = await sendChat(
hrToken,
PERSONAS.hr,
PERSONAS.ceo,
`Heads up — onboarding new hire Priya Sahota for the Manchester store. Need your sign-off on the laptop budget (£1,400).`
);
log(`HR -> CEO chat thread=${hrToCeoChat.thread_key}`);
const ceoToken = await devLogin(PERSONAS.ceo);
log(`CEO signed in (${PERSONAS.ceo})`);
const ceoTx = await startInstance(ceoToken, approvalCatalogue._key, `laptop-budget-priya-${RUN_ID}`);
log(`CEO started budget approval case: tx=${ceoTx.transaction_id || ceoTx.short_id || "?"}`);
const ceoToHrChat = await sendChat(
ceoToken,
PERSONAS.ceo,
PERSONAS.hr,
`Approved up to £1,500 for Priya's kit. Loop in IT for the SAP roles.`
);
log(`CEO -> HR chat thread=${ceoToHrChat.thread_key}`);
const itToken = await devLogin(PERSONAS.it);
log(`IT signed in (${PERSONAS.it})`);
const itTx = await startInstance(itToken, approvalCatalogue._key, `it-provision-priya-${RUN_ID}`);
log(`IT started provisioning case: tx=${itTx.transaction_id || itTx.short_id || "?"}`);
const itToHrChat = await sendChat(
itToken,
PERSONAS.it,
PERSONAS.hr,
`Laptop ordered, SAP profile ready for day one. ETA Friday.`
);
log(`IT -> HR chat thread=${itToHrChat.thread_key}`);
console.log("\nmulti-persona seed complete.");
console.log(`run id: ${RUN_ID}`);
console.log(`onboarding tx (HR): ${onboardTx.transaction_id || onboardTx.short_id || "?"}`);
console.log(`approval tx (CEO): ${ceoTx.transaction_id || ceoTx.short_id || "?"}`);
console.log(`provision tx (IT): ${itTx.transaction_id || itTx.short_id || "?"}`);
+91
View File
@@ -0,0 +1,91 @@
// Versioned seed for the EA2-backed attendance roster.
//
// Creates the attendance_root anchor + 8 site flow docs (kind=value,
// source_context=CANVAS_ATTENDANCE_SITE) + presentation edges linking
// each site to the anchor.
//
// Idempotent: re-running reconciles each site by deterministic _key.
const BASE = "https://demo.flow-master.ai";
const ANCHOR = "attendance_root";
const SOURCE_CTX = "CANVAS_ATTENDANCE_SITE";
const SITES = [
{ key: "hq_london", label: "HQ · London", kind: "office", lat: 51.5074, lng: -0.1278, status: "checked_in", who: "Mariana Cole (CEO) — on-site", city: "London" },
{ key: "store_204_manchester", label: "Store 204 · Manchester", kind: "store", lat: 53.4808, lng: -2.2426, status: "checked_in", who: "Manager Priya Sahota — opened 08:02", city: "Manchester" },
{ key: "store_118_birmingham", label: "Store 118 · Birmingham", kind: "store", lat: 52.4862, lng: -1.8904, status: "late", who: "Manager Tom Reilly — late check-in 09:24", city: "Birmingham" },
{ key: "store_052_edinburgh", label: "Store 052 · Edinburgh", kind: "store", lat: 55.9533, lng: -3.1883, status: "checked_in", who: "Manager Iona MacDonald — opened 07:58", city: "Edinburgh" },
{ key: "office_berlin", label: "Office · Berlin", kind: "office", lat: 52.52, lng: 13.405, status: "checked_in", who: "Aisha Khan (HR Director) — remote-on", city: "Berlin" },
{ key: "warehouse_rotterdam", label: "Warehouse · Rotterdam", kind: "warehouse", lat: 51.9244, lng: 4.4777, status: "checked_in", who: "Shift A — 24 staff on-floor", city: "Rotterdam" },
{ key: "office_bangalore", label: "Office · Bangalore", kind: "office", lat: 12.9716, lng: 77.5946, status: "checked_in", who: "Rohan Patel (IT Director) — on-site", city: "Bangalore" },
{ key: "store_088_cardiff", label: "Store 088 · Cardiff", kind: "store", lat: 51.4816, lng: -3.1791, status: "checked_out", who: "Manager Daniel Owens — closed 21:14 yesterday", city: "Cardiff" },
];
function reqId(tag) {
return `attendance-seed-${tag}-${Math.random().toString(36).slice(2, 10)}aaaaa`;
}
async function devLogin(email) {
const r = await fetch(`${BASE}/api/v1/auth/dev-login`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }),
});
if (!r.ok) throw new Error(`dev-login: ${r.status}`);
return (await r.json()).access_token;
}
async function fetchEa2(token, method, path, body) {
const init = { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } };
if (body !== undefined) init.body = JSON.stringify(body);
const r = await fetch(`${BASE}${path}`, init);
return { ok: r.ok, status: r.status, json: r.ok ? await r.json().catch(() => null) : null, text: r.ok ? null : await r.text().catch(() => "") };
}
const token = await devLogin("hr-head@flow-master.ai");
console.log("[attendance] signed in");
const anchorHead = await fetchEa2(token, "GET", `/api/ea2/flow/${ANCHOR}`);
if (!anchorHead.ok) {
const r = await fetchEa2(token, "POST", "/api/ea2/apply-batch", {
request_id: reqId("anchor"),
ops: [{
op: "create", coll: "flow",
data: {
_key: ANCHOR, kind: "value", status: "published",
name: ANCHOR, display_name: "Attendance · root",
description: "Anchor doc for attendance sites", source_context: "CANVAS_ATTENDANCE_ROOT",
},
}],
});
if (!r.ok) throw new Error(`anchor create failed: ${r.status} ${r.text}`);
console.log("[attendance] anchor created");
} else {
console.log("[attendance] anchor exists");
}
for (const s of SITES) {
const existing = await fetchEa2(token, "GET", `/api/ea2/flow/${s.key}`);
if (existing.ok) { console.log(` ${s.key}: exists`); continue; }
const make = await fetchEa2(token, "POST", "/api/ea2/apply-batch", {
request_id: reqId(s.key),
ops: [
{
op: "create", coll: "flow",
data: {
_key: s.key, kind: "value", status: "published",
name: s.key, display_name: s.label,
description: `${s.kind} site at ${s.city}`,
source_context: SOURCE_CTX,
config: { attendance: { kind: s.kind, lat: s.lat, lng: s.lng, status: s.status, who: s.who, city: s.city, label: s.label } },
},
},
{ op: "create_edge", edge_coll: "defines", from: `flow/${ANCHOR}`, to: `flow/${s.key}`, role: "presentation" },
],
});
if (!make.ok) {
console.log(` ${s.key}: create failed → ${make.status} ${make.text.slice(0, 200)}`);
} else {
console.log(` ${s.key}: created + linked`);
}
}
console.log("\nattendance seed complete.");
+24 -1
View File
@@ -13,6 +13,8 @@ import Agent from "./scenes/Agent";
import Hub from "./scenes/Hub";
import GeoAttendance from "./scenes/GeoAttendance";
import Explainer from "./scenes/Explainer";
import Approvals from "./scenes/Approvals";
import Documents from "./scenes/Documents";
import CommandBar from "./components/CommandBar";
import Toaster from "./components/Toaster";
import Console from "./components/Console";
@@ -48,6 +50,25 @@ export default function App() {
}
}, [isAuthed, scene, setScene]);
// Hydrate identity (tenantId, user_id, display_name) from /me whenever a
// session token exists but the store hasn't captured the identity yet.
// This covers the path "user reloads page with a valid sessionStorage
// token" — without this, tenant-scoped surfaces show empty.
useEffect(() => {
if (!isAuthed) return;
if (useApp.getState().tenantId) return;
void (async () => {
const ping = await import("./lib/api").then((m) => m.api.ping());
if (ping.ok && ping.user_id) {
useApp.setState({
actor: { mode: "direct_user", user_id: ping.user_id },
userEmail: ping.user ?? useApp.getState().userEmail,
tenantId: ping.tenant_id ?? null,
});
}
})();
}, [isAuthed]);
useEffect(() => {
if (mode === "live") startPolling();
return () => stopPolling();
@@ -80,7 +101,7 @@ export default function App() {
<Bot size={13} /> Chat
</button>
<button role="tab" aria-selected={scene === "agent"} className={`tab${scene === "agent" ? " tab-sel" : ""}`} onClick={() => setScene("agent")}>
<Bot size={13} /> Pi
<Bot size={13} /> Assistant
</button>
<button role="tab" aria-selected={scene === "settings"} className={`tab${scene === "settings" ? " tab-sel" : ""}`} onClick={() => setScene("settings")}>
<Cog size={13} /> Settings
@@ -167,6 +188,8 @@ export default function App() {
{scene === "hub-it" && <Hub hub="it" />}
{scene === "geo-attendance" && <GeoAttendance />}
{scene === "explainer" && <Explainer />}
{scene === "approvals" && <Approvals />}
{scene === "documents" && <Documents />}
{scene === "settings" && <Settings />}
</div>
+127
View File
@@ -1994,3 +1994,130 @@ select.studio-input { background: var(--bp-paper); }
.explainer-foot { margin-top: 32px; padding-top: 16px; border-top: 1px solid color-mix(in srgb, var(--bp-navy) 25%, transparent); }
.explainer-foot-label { font-family: var(--bp-mono); font-size: 10px; letter-spacing: 0.08em; color: var(--bp-muted); }
.explainer-foot p { font-size: 11px; color: var(--bp-muted); margin-top: 4px; }
.hub-commands { margin-bottom: 24px; }
.hub-split { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.hub-queue, .hub-selected { border: 1px solid var(--bp-navy); padding: 16px; background: var(--bp-paper); display: flex; flex-direction: column; gap: 10px; }
.hub-queue-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
.hub-queue-row {
width: 100%; text-align: left; padding: 8px 10px;
background: var(--bp-paper); border: 1px solid color-mix(in srgb, var(--bp-navy) 20%, transparent);
color: var(--bp-navy); font-size: 12px; cursor: pointer;
display: flex; gap: 8px; align-items: center;
}
.hub-queue-row:hover { background: color-mix(in srgb, var(--bp-amber) 15%, var(--bp-paper)); }
.hub-queue-row.active { background: color-mix(in srgb, var(--bp-amber) 30%, var(--bp-paper)); border-color: var(--bp-navy); }
.hub-queue-name { flex: 1; }
@media (max-width: 900px) { .hub-split { grid-template-columns: 1fr; } }
.chat-unread-summary {
padding: 8px 12px;
background: color-mix(in srgb, var(--bp-amber) 25%, var(--bp-paper));
border-bottom: 1px solid var(--bp-navy);
font-family: var(--bp-mono); font-size: 10px; letter-spacing: 0.08em;
color: var(--bp-navy); text-transform: uppercase;
}
.chat-thread-row { position: relative; }
.chat-thread-row.unread .chat-thread-name { font-weight: 700; }
.chat-thread-row-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.chat-thread-time { font-family: var(--bp-mono); font-size: 9px; color: var(--bp-muted); flex-shrink: 0; }
.chat-unread-dot {
position: absolute; right: 8px; top: 8px;
width: 6px; height: 6px; border-radius: 50%;
background: var(--bp-amber);
}
.approvals-scene { padding: 24px; max-width: 1400px; margin: 0 auto; background: var(--bp-paper); min-height: calc(100vh - 60px); }
.approvals-head { margin-bottom: 20px; max-width: 880px; }
.approvals-intro { font-size: 13px; color: var(--bp-muted); line-height: 1.5; margin-top: 6px; }
.approvals-stats { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
.approvals-stat { font-family: var(--bp-mono); font-size: 11px; padding: 4px 10px; border: 1px solid var(--bp-navy); }
.approvals-stat-running { background: color-mix(in srgb, var(--bp-amber) 25%, var(--bp-paper)); }
.approvals-stat-waiting { color: var(--bp-muted); }
.approvals-stat-blocked { background: color-mix(in srgb, #c25555 20%, var(--bp-paper)); }
.approvals-filter-row { display: flex; gap: 6px; margin-top: 12px; flex-wrap: wrap; }
.approvals-split { display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; }
.approvals-queue, .approvals-detail { border: 1px solid var(--bp-navy); padding: 16px; background: var(--bp-paper); display: flex; flex-direction: column; gap: 10px; }
.approvals-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; max-height: 70vh; overflow-y: auto; }
.approvals-row { width: 100%; text-align: left; padding: 10px 12px; background: var(--bp-paper); border: 1px solid color-mix(in srgb, var(--bp-navy) 18%, transparent); color: var(--bp-navy); cursor: pointer; display: flex; flex-direction: column; gap: 4px; }
.approvals-row:hover { background: color-mix(in srgb, var(--bp-amber) 12%, var(--bp-paper)); }
.approvals-row.active { background: color-mix(in srgb, var(--bp-amber) 28%, var(--bp-paper)); border-color: var(--bp-navy); }
.approvals-row-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.approvals-row-title { font-size: 13px; font-weight: 700; }
.approvals-row-age { font-family: var(--bp-mono); font-size: 10px; color: var(--bp-muted); flex-shrink: 0; }
.approvals-row-meta { display: flex; gap: 8px; flex-wrap: wrap; font-family: var(--bp-mono); font-size: 10px; color: var(--bp-muted); }
.approvals-row-step { color: var(--bp-navy); }
.approvals-row-hub { padding: 1px 6px; border: 1px solid color-mix(in srgb, var(--bp-navy) 30%, transparent); }
.approvals-card { padding: 14px; background: var(--bp-paper); border: 1px solid var(--bp-navy); display: flex; flex-direction: column; gap: 10px; }
.approvals-card-title { font-size: 14px; font-weight: 700; color: var(--bp-navy); display: flex; align-items: center; gap: 6px; }
.approvals-card-meta { font-family: var(--bp-mono); font-size: 11px; color: var(--bp-muted); }
.approvals-fields { padding: 8px 0; border-top: 1px solid color-mix(in srgb, var(--bp-navy) 20%, transparent); }
.approvals-fields-label { font-family: var(--bp-mono); font-size: 10px; letter-spacing: 0.08em; color: var(--bp-muted); margin-bottom: 4px; }
.approvals-fields ul { list-style: none; padding: 0; margin: 0; font-size: 12px; }
.approvals-fields li { padding: 3px 0; }
.approvals-field-type { font-family: var(--bp-mono); font-size: 10px; color: var(--bp-muted); }
.approvals-actions { display: flex; gap: 8px; flex-wrap: wrap; padding-top: 8px; border-top: 1px solid color-mix(in srgb, var(--bp-navy) 20%, transparent); }
@media (max-width: 900px) {
.approvals-split { grid-template-columns: 1fr; }
.approvals-list { max-height: 50vh; }
}
/* Mobile topbar: stack rows so a narrow viewport doesn't blow the layout. */
@media (max-width: 700px) {
.topbar {
grid-template-columns: 1fr;
height: auto;
padding: 4px 0;
}
.brand-lock { padding: 6px 10px; font-size: 11px; }
.tabs { overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; padding: 0 6px; }
.tab { font-size: 11px; padding: 6px 10px; }
.topbar-mid { display: none; }
.topbar-actions {
display: flex; flex-wrap: wrap; gap: 4px; padding: 4px 6px; justify-content: flex-end;
}
.topbar-actions .link-btn { font-size: 10px; padding: 4px 6px; }
.user-email { display: none; }
.topbar-age { display: none; }
/* Approvals: stack at 700px (was 900) so iPhone 13 (390) hits the rule. */
.approvals-split { grid-template-columns: 1fr !important; }
.approvals-list { max-height: 50vh; }
/* Generic scene container padding. */
.hub-scene, .approvals-scene, .geo-scene, .explainer-scene { padding: 16px 12px; }
}
.docs-scene { padding: 24px; max-width: 1400px; margin: 0 auto; background: var(--bp-paper); min-height: calc(100vh - 60px); }
.docs-head { margin-bottom: 20px; max-width: 880px; }
.docs-intro { font-size: 13px; color: var(--bp-muted); line-height: 1.5; margin-top: 6px; }
.docs-search {
margin-top: 14px;
width: 100%;
max-width: 480px;
padding: 8px 12px;
background: var(--bp-paper);
border: 1px solid var(--bp-navy);
color: var(--bp-navy);
font-family: var(--bp-mono);
font-size: 12px;
}
.docs-split { display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; }
.docs-list, .docs-detail { border: 1px solid var(--bp-navy); padding: 16px; background: var(--bp-paper); display: flex; flex-direction: column; gap: 10px; }
.docs-rows { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; max-height: 70vh; overflow-y: auto; }
.docs-row { width: 100%; text-align: left; padding: 10px 12px; background: var(--bp-paper); border: 1px solid color-mix(in srgb, var(--bp-navy) 18%, transparent); color: var(--bp-navy); cursor: pointer; display: flex; flex-direction: column; gap: 4px; }
.docs-row:hover { background: color-mix(in srgb, var(--bp-amber) 12%, var(--bp-paper)); }
.docs-row.active { background: color-mix(in srgb, var(--bp-amber) 28%, var(--bp-paper)); }
.docs-row-title { font-size: 13px; font-weight: 700; display: flex; align-items: center; gap: 6px; }
.docs-row-meta { font-family: var(--bp-mono); font-size: 10px; color: var(--bp-muted); }
.docs-card { padding: 14px; background: var(--bp-paper); border: 1px solid var(--bp-navy); display: flex; flex-direction: column; gap: 10px; }
.docs-card-title { font-size: 14px; font-weight: 700; color: var(--bp-navy); }
.docs-card-meta { font-family: var(--bp-mono); font-size: 11px; color: var(--bp-muted); }
.docs-card-body { font-size: 12px; line-height: 1.5; color: var(--bp-navy); }
.docs-card-actions { padding-top: 8px; border-top: 1px solid color-mix(in srgb, var(--bp-navy) 20%, transparent); }
@media (max-width: 700px) {
.docs-split { grid-template-columns: 1fr; }
.docs-rows { max-height: 50vh; }
}
+154
View File
@@ -0,0 +1,154 @@
import { api } from "./api";
export interface Memory {
_key: string;
content: string;
tags: string[];
created_at: string;
}
const SOURCE_CTX = "CANVAS_AGENT_MEMORY";
function authHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`,
"Content-Type": "application/json",
};
}
function newRequestId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return `mem-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
}
function vaultKeyFor(email: string): string {
const slug = email.toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 50);
return `memory_vault_${slug}`;
}
async function ensureVault(vaultKey: string, email: string, signal?: AbortSignal): Promise<boolean> {
try {
const head = await fetch(`${api.config.baseUrl}/api/ea2/flow/${vaultKey}`, { headers: authHeaders(), signal });
if (head.ok) return true;
} catch { /* fall through */ }
const res = await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
request_id: newRequestId(),
ops: [
{
op: "create",
coll: "flow",
data: {
_key: vaultKey,
kind: "value",
status: "published",
name: vaultKey,
display_name: `Vault · ${email}`,
description: `Per-tenant memory vault for ${email}`,
source_context: "CANVAS_AGENT_VAULT_ROOT",
config: { vault: { owner_email: email } },
},
},
],
}),
signal,
});
return res.ok || res.status === 409;
}
export const agentMemory = {
/**
* Persist a memory observation. Each memory is a flow.kind=value doc linked to
* the user's vault root via a defines edge with role=presentation.
*/
async remember(email: string, content: string, tags: string[] = [], signal?: AbortSignal): Promise<string | null> {
if (!content.trim()) return null;
const vaultKey = vaultKeyFor(email);
await ensureVault(vaultKey, email, signal);
const doc = await fetch(`${api.config.baseUrl}/api/ea2/flow`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
kind: "value",
status: "published",
name: `memory_${Date.now()}`,
display_name: content.slice(0, 80),
description: content,
source_context: SOURCE_CTX,
config: { memory: { tags, owner_email: email } },
}),
signal,
});
if (!doc.ok) return null;
const mem = await doc.json();
await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
request_id: newRequestId(),
ops: [
{ op: "create_edge", edge_coll: "defines", from: `flow/${vaultKey}`, to: `flow/${mem._key}`, role: "presentation" },
],
}),
signal,
});
return mem._key;
},
async listAll(email: string, signal?: AbortSignal): Promise<Memory[]> {
const vaultKey = vaultKeyFor(email);
const ok = await ensureVault(vaultKey, email, signal);
if (!ok) return [];
const edgesRes = await fetch(`${api.config.baseUrl}/api/ea2/edges/defines?from=flow/${vaultKey}&limit=500`, { headers: authHeaders(), signal });
if (!edgesRes.ok) return [];
const edges = (await edgesRes.json())?.items || [];
const memKeys = (edges as any[])
.filter((e) => e?.role === "presentation")
.map((e) => (e._to || "").split("/").pop())
.filter(Boolean) as string[];
const memos = await Promise.all(
memKeys.map(async (k) => {
try {
const r = await fetch(`${api.config.baseUrl}/api/ea2/flow/${k}`, { headers: authHeaders(), signal });
if (!r.ok) return null;
const d = await r.json();
if (d?.source_context !== SOURCE_CTX) return null;
return {
_key: d._key,
content: d.description || d.display_name || "",
tags: d.config?.memory?.tags || [],
created_at: d.created_at,
} as Memory;
} catch { return null; }
})
);
return memos.filter((m): m is Memory => !!m).sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""));
},
/**
* Retrieve memories whose content overlaps with the query by word stems.
* Score = count of unique stem matches. Ties broken by recency.
*/
async recall(email: string, query: string, limit = 5, signal?: AbortSignal): Promise<Memory[]> {
const all = await agentMemory.listAll(email, signal);
if (!query.trim() || all.length === 0) return all.slice(0, limit);
const stems = new Set(
query.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 2)
);
if (stems.size === 0) return all.slice(0, limit);
const scored = all.map((m) => {
const body = (m.content || "").toLowerCase();
let score = 0;
for (const s of stems) if (body.includes(s)) score++;
const tagScore = m.tags.reduce((acc, t) => acc + (stems.has(t.toLowerCase()) ? 2 : 0), 0);
return { m, score: score + tagScore };
});
return scored
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score || (b.m.created_at || "").localeCompare(a.m.created_at || ""))
.slice(0, limit)
.map((x) => x.m);
},
};
+83 -14
View File
@@ -1,6 +1,9 @@
import { api } from "./api";
import { wizardApi } from "./wizardApi";
import { chatApi } from "./chatApi";
import { curatedPublishedFlows, fetchStartableFlows } from "./flowCuration";
import { agentMemory, type Memory } from "./agentMemory";
import { llmClient, type LlmMessage } from "./llmClient";
export interface ToolResult {
ok: boolean;
@@ -69,22 +72,27 @@ const TOOLS: ToolDef[] = [
const processName = subjectMatch ? subjectMatch[1] : phrase;
const subject = subjectMatch ? subjectMatch[3] : undefined;
const procs = await fetch(`${api.config.baseUrl}/api/ea2/flow/processes?limit=200`, {
headers: { Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}` },
const token = sessionStorage.getItem("fm.mc.token.v1") || "";
const procs = await fetch(`${api.config.baseUrl}/api/ea2/flow/processes?limit=500`, {
headers: { Authorization: `Bearer ${token}` },
}).then((r) => r.json());
const items: any[] = procs?.items || [];
const fromList = curatedPublishedFlows((procs?.items || []) as any[]);
const directHits = await fetchStartableFlows(api.config.baseUrl, token);
const items = (() => {
const m = new Map<string, any>();
for (const it of [...directHits, ...fromList]) m.set(it._key, it);
return Array.from(m.values());
})();
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
const target = norm(processName);
const match = items
.filter((it) => it.status === "published" && it.kind === "definition")
.find((it) => norm(it.display_name || "").includes(target) || norm(it.name || "").includes(target));
const match = items.find((it) => norm(it.display_name || "").includes(target) || norm(it.name || "").includes(target));
if (!match) return { ok: false, display: `Couldn't find a published process matching "${processName}". Try the Studio to create one.` };
try {
const res = await wizardApi.startInstance(match._key, subject);
return {
ok: true,
display: `Started ${match.display_name}${subject ? ` for ${subject}` : ""}. Transaction ${res.transaction_id?.slice(0, 8) || "ok"}.`,
display: `Started ${match.display_name}${subject ? ` for ${subject}` : ""}.`,
data: res,
};
} catch (e: any) {
@@ -108,7 +116,7 @@ const TOOLS: ToolDef[] = [
};
const recipient = Object.entries(directory).find(([k]) => target.includes(k))?.[1];
if (!recipient) return { ok: false, display: `I don't know who "${target}" is. Try Mariana, Aisha, or Rohan.` };
const threads = await chatApi.listThreads();
const threads = await chatApi.listThreads(ctx.userEmail);
const existing = threads.find((t) => t.participants.includes(recipient) && t.participants.includes(ctx.userEmail));
const threadKey = existing?._key || (await chatApi.createThread(`${ctx.userEmail}${recipient}`, [ctx.userEmail, recipient]));
await chatApi.sendMessage(threadKey, body, ctx.userEmail);
@@ -129,20 +137,70 @@ const TOOLS: ToolDef[] = [
description: "List published processes for this tenant",
matcher: /^(list|show|what)\s+(processes|workflows|flows)/i,
async run() {
const procs = await fetch(`${api.config.baseUrl}/api/ea2/flow/processes?limit=20`, {
headers: { Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}` },
}).then((r) => r.json());
const published = (procs?.items || []).filter((it: any) => it.status === "published" && it.kind === "definition").slice(0, 10);
if (!published.length) return { ok: true, display: "No published processes yet. Build one in the Studio." };
const lines = published.map((it: any) => `${it.display_name || it.name}`).join("\n");
const token = sessionStorage.getItem("fm.mc.token.v1") || "";
const procs = await fetch(`${api.config.baseUrl}/api/ea2/flow/processes?limit=500`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.catch(() => ({ items: [] }));
const fromList = curatedPublishedFlows((procs?.items || []) as any[]);
const directHits = await fetchStartableFlows(api.config.baseUrl, token);
const merged = new Map<string, any>();
for (const it of [...directHits, ...fromList]) merged.set(it._key, it);
const published = Array.from(merged.values()).slice(0, 10);
if (!published.length) return { ok: true, display: "No published processes yet. Open the Studio to design one." };
const lines = published.map((it) => `${it.display_name}`).join("\n");
return { ok: true, display: `Here's what's published:\n${lines}` };
},
},
{
name: "remember",
description: "Stash a note in your per-tenant memory vault",
matcher: /^(remember|note|save)\s+(that\s+)?(.+)$/i,
async run(input, ctx) {
const m = input.match(/^(remember|note|save)\s+(that\s+)?(.+)$/i);
const content = m?.[3]?.trim() || "";
if (!content) return { ok: false, display: "Tell me what to remember." };
const key = await agentMemory.remember(ctx.userEmail, content, ["user-said"]);
return key
? { ok: true, display: `Got it — I'll remember: "${content}".` }
: { ok: false, display: "Couldn't save that to your vault. Try again." };
},
},
{
name: "recall",
description: "Pull related notes from your memory vault",
matcher: /^(recall|remind me|what do you know|what did i say)\s*(about|of|on)?\s*(.*)$/i,
async run(input, ctx) {
const m = input.match(/^(recall|remind me|what do you know|what did i say)\s*(about|of|on)?\s*(.*)$/i);
const query = m?.[3]?.trim() || "";
const memories = await agentMemory.recall(ctx.userEmail, query, 5);
if (!memories.length) return { ok: true, display: query ? `Nothing in your vault about "${query}" yet.` : "Your vault is empty." };
const lines = memories.map((mem) => `${mem.content}`).join("\n");
return { ok: true, display: `From your vault${query ? ` about "${query}"` : ""}:\n${lines}` };
},
},
];
async function llmFallback(trimmed: string, ctx: ToolContext, hints: Memory[]): Promise<ToolResult | null> {
const memBlock = hints.length ? `\nYour vault has related notes:\n${hints.map((h) => `- ${h.content}`).join("\n")}` : "";
const system: LlmMessage = {
role: "system",
content:
`You are the FlowMaster Command Assistant inside canvas.flow-master.ai for ${ctx.userEmail}. ` +
`Speak briefly (3 sentences max). You can refer to these tools by name when suggesting actions: ${TOOLS.map((t) => t.name).join(", ")}. ` +
`If the user asks something operational, recommend a specific tool. ${memBlock}`,
};
const user: LlmMessage = { role: "user", content: trimmed };
const reply = await llmClient.chat({ messages: [system, user], max_tokens: 320 });
if (reply.ok) return { ok: true, display: reply.content };
return null;
}
export async function routeAgentInput(text: string, ctx: ToolContext): Promise<ToolResult> {
const trimmed = text.trim();
if (!trimmed) return { ok: false, display: "Tell me what to do." };
agentMemory.remember(ctx.userEmail, trimmed, ["turn"]).catch(() => {});
for (const tool of TOOLS) {
if (tool.matcher.test(trimmed)) {
try {
@@ -152,6 +210,17 @@ export async function routeAgentInput(text: string, ctx: ToolContext): Promise<T
}
}
}
let hints: Memory[] = [];
try { hints = await agentMemory.recall(ctx.userEmail, trimmed, 3); } catch { /* ignore */ }
const llmReply = await llmFallback(trimmed, ctx, hints);
if (llmReply) return llmReply;
if (hints.length > 0) {
const lines = hints.map((h) => `${h.content}`).join("\n");
return {
ok: true,
display: `I don't have a command that matches that yet, but your vault has related notes:\n${lines}\n\nTry: 'open mission', 'list processes', 'remember that ...', 'recall about ...', 'create a new process'.`,
};
}
return {
ok: false,
display:
+32
View File
@@ -106,3 +106,35 @@ describe("api client", () => {
expect(calls.filter((c) => c.url.endsWith("/dev-login")).length).toBe(2); // initial + retry login
});
});
describe("authedRequest fail-closed when dev-login disabled", () => {
beforeEach(() => {
import.meta.env.VITE_ENABLE_DEV_LOGIN = "false";
});
it("throws AuthRequiredError instead of silently calling /dev-login when no token", async () => {
const calls = mockFetch([
(url) =>
url.endsWith("/api/v1/auth/me")
? new Response(JSON.stringify({ user_id: "u", tenant_id: "t", email: "dev@flow-master.ai" }), { status: 200 })
: undefined,
]);
await expect(api.me()).rejects.toThrow(/Sign in required/);
expect(calls.filter((c) => c.url.endsWith("/dev-login")).length).toBe(0);
delete (import.meta.env as any).VITE_ENABLE_DEV_LOGIN;
});
});
describe("authedRequest 401 retry fail-closed when dev-login disabled", () => {
beforeEach(() => {
import.meta.env.VITE_ENABLE_DEV_LOGIN = "false";
});
it("throws AuthRequiredError on 401 instead of silently re-logging in", async () => {
const calls = mockFetch([
(url) => url.endsWith("/api/v1/auth/me") ? new Response("expired", { status: 401 }) : undefined,
]);
sessionStorage.setItem("fm.mc.token.v1", "stale-token");
await expect(api.me()).rejects.toThrow(/Sign in required/);
expect(calls.filter((c) => c.url.endsWith("/dev-login")).length).toBe(0);
delete (import.meta.env as any).VITE_ENABLE_DEV_LOGIN;
});
});
+36 -8
View File
@@ -138,6 +138,14 @@ async function instrumentedFetch(
}
}
export class AuthRequiredError extends Error {
constructor(msg = "Sign in required") { super(msg); this.name = "AuthRequiredError"; }
}
function devLoginAllowed(): boolean {
return (import.meta.env?.VITE_ENABLE_DEV_LOGIN ?? "true") !== "false";
}
async function authedRequest<T>(
cfg: ApiConfig,
method: string,
@@ -146,7 +154,10 @@ async function authedRequest<T>(
signal?: AbortSignal,
): Promise<T> {
let token = sessionStorage.getItem(TOKEN_KEY);
if (!token) token = await login(cfg, signal);
if (!token) {
if (!devLoginAllowed()) throw new AuthRequiredError();
token = await login(cfg, signal);
}
const init: RequestInit = {
method,
headers: {
@@ -160,6 +171,7 @@ async function authedRequest<T>(
let r = await instrumentedFetch(method, `${cfg.baseUrl}${path}`, init, body);
if (r.status === 401) {
sessionStorage.removeItem(TOKEN_KEY);
if (!devLoginAllowed()) throw new AuthRequiredError();
token = await login(cfg, signal);
init.headers = { ...init.headers, Authorization: `Bearer ${token}` };
r = await instrumentedFetch(method, `${cfg.baseUrl}${path}`, init, body);
@@ -191,16 +203,15 @@ export const api = {
return authedRequest<AuthMe>(this.config, "GET", "/api/v1/auth/me", undefined, signal);
},
async signIn(email: string, password?: string, signal?: AbortSignal): Promise<{ access_token: string; refresh_token?: string }> {
const payload = password ? { email, password } : { email };
const path = password ? "/api/v1/auth/login" : "/api/v1/auth/dev-login";
async passwordLogin(email: string, password: string, signal?: AbortSignal): Promise<{ access_token: string; refresh_token?: string }> {
if (!password) throw new Error("Password required");
const init: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify(payload),
body: JSON.stringify({ email, password }),
signal,
};
const r = await instrumentedFetch("POST", `${this.config.baseUrl}${path}`, init, payload);
const r = await instrumentedFetch("POST", `${this.config.baseUrl}/api/v1/auth/login`, init, { email, password });
if (!r.ok) {
const text = await r.text().catch(() => "");
throw new Error(`Login failed: ${r.status} ${text.slice(0, 100)}`);
@@ -210,6 +221,23 @@ export const api = {
return data;
},
async devLogin(email: string, signal?: AbortSignal): Promise<{ access_token: string; refresh_token?: string }> {
const init: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify({ email }),
signal,
};
const r = await instrumentedFetch("POST", `${this.config.baseUrl}/api/v1/auth/dev-login`, init, { email });
if (!r.ok) {
const text = await r.text().catch(() => "");
throw new Error(`Dev-login failed: ${r.status} ${text.slice(0, 100)}`);
}
const data = await r.json() as { access_token: string; refresh_token?: string };
this.setBearer(data.access_token);
return data;
},
async devLoginConfig(signal?: AbortSignal): Promise<{ enabled: boolean | null }> {
try {
const init: RequestInit = { method: "GET", headers: { "Accept": "application/json" }, signal };
@@ -305,10 +333,10 @@ export const api = {
},
/** Probe whether the backend is reachable. Never throws. */
async ping(signal?: AbortSignal): Promise<{ ok: boolean; reason?: string; user?: string; user_id?: string }> {
async ping(signal?: AbortSignal): Promise<{ ok: boolean; reason?: string; user?: string; user_id?: string; tenant_id?: string }> {
try {
const me = await this.me(signal);
return { ok: true, user: me.email, user_id: me.user_id };
return { ok: true, user: me.email, user_id: me.user_id, tenant_id: me.tenant_id };
} catch (e) {
return { ok: false, reason: (e as Error).message };
}
+103
View File
@@ -0,0 +1,103 @@
import { api } from "./api";
export interface AttendanceSite {
_key: string;
label: string;
kind: "store" | "office" | "warehouse";
lat: number;
lng: number;
status: "checked_in" | "checked_out" | "late";
who: string;
city: string;
}
const SOURCE_CTX = "CANVAS_ATTENDANCE_SITE";
function authHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`,
"Content-Type": "application/json",
};
}
function newRequestId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
}
async function jsonFetch(path: string, init?: RequestInit) {
const res = await fetch(`${api.config.baseUrl}${path}`, { ...init, headers: { ...authHeaders(), ...(init?.headers || {}) } });
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(`${path} ${res.status}: ${t.slice(0, 200)}`);
}
return res.json();
}
function siteFromConfig(doc: any): AttendanceSite | null {
const cfg = doc?.config?.attendance;
if (!cfg) return null;
if (typeof cfg.lat !== "number" || typeof cfg.lng !== "number") return null;
return {
_key: doc._key,
label: doc.display_name || cfg.label || "Site",
kind: (cfg.kind || "office") as AttendanceSite["kind"],
lat: cfg.lat,
lng: cfg.lng,
status: (cfg.status || "checked_in") as AttendanceSite["status"],
who: cfg.who || "—",
city: cfg.city || "",
};
}
const ANCHOR_KEY = "attendance_root";
async function ensureAnchor(): Promise<boolean> {
try {
const head = await fetch(`${api.config.baseUrl}/api/ea2/flow/${ANCHOR_KEY}`, { headers: authHeaders() });
if (head.ok) return true;
} catch { /* fall through */ }
const res = await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
request_id: newRequestId(),
ops: [
{
op: "create",
coll: "flow",
data: {
_key: ANCHOR_KEY,
kind: "value",
status: "published",
name: ANCHOR_KEY,
display_name: "Attendance · root",
description: "Anchor doc for attendance sites",
source_context: "CANVAS_ATTENDANCE_ROOT",
},
},
],
}),
});
return res.ok || res.status === 409;
}
export const attendanceApi = {
async listSites(signal?: AbortSignal): Promise<AttendanceSite[]> {
await ensureAnchor();
const edges = await jsonFetch(`/api/ea2/edges/defines?from=flow/${ANCHOR_KEY}&limit=200`, { signal });
const childKeys = ((edges?.items || []) as any[])
.filter((e) => e?.role === "presentation")
.map((e) => (e._to || "").split("/").pop())
.filter(Boolean) as string[];
const docs = await Promise.all(
childKeys.map(async (k) => {
try { return await jsonFetch(`/api/ea2/flow/${k}`, { signal }); } catch { return null; }
})
);
return docs
.filter((d) => d?.source_context === SOURCE_CTX)
.map((d) => siteFromConfig(d))
.filter((s): s is AttendanceSite => !!s);
},
};
+99 -21
View File
@@ -31,6 +31,59 @@ function newRequestId(): string {
return `chat-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
}
function inboxKeyFor(email: string): string {
const slug = email.toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 60);
return `inbox_${slug}`;
}
async function ensureInbox(inboxKey: string, email: string, signal?: AbortSignal): Promise<boolean> {
try {
const head = await fetch(`${api.config.baseUrl}/api/ea2/flow/${inboxKey}`, { headers: authHeaders(), signal });
if (head.ok) return true;
} catch { /* fall through */ }
const body = {
request_id: newRequestId(),
ops: [
{
op: "create",
coll: "flow",
data: {
_key: inboxKey,
kind: "value",
status: "published",
name: inboxKey,
display_name: `Inbox · ${email}`,
description: `Chat inbox anchor for ${email}`,
source_context: "CANVAS_CHAT_INBOX",
config: { chat: { owner_email: email } },
},
},
],
};
const created = await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(body),
signal,
});
return created.ok || created.status === 409;
}
async function linkThreadToInbox(inboxKey: string, threadKey: string, signal?: AbortSignal): Promise<void> {
const body = {
request_id: newRequestId(),
ops: [
{ op: "create_edge", edge_coll: "defines", from: `flow/${inboxKey}`, to: `flow/${threadKey}`, role: "presentation" },
],
};
await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(body),
signal,
});
}
function authHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`,
@@ -48,33 +101,50 @@ async function jsonFetch(path: string, init?: RequestInit) {
}
export const chatApi = {
/** List existing chat-thread flow docs for the current tenant. */
async listThreads(signal?: AbortSignal): Promise<ChatThread[]> {
const res = await jsonFetch(`/api/ea2/flow/processes?limit=200`, { signal });
const items = (res?.items || []) as any[];
return items
.filter((it) => it?.source_context === "EA2_CHAT_THREAD")
.map((it) => ({
_key: it._key,
display_name: it.display_name || "Chat",
description: it.description || "",
created_at: it.created_at,
participants: it.config?.chat?.participants || [],
}));
/**
* List chat threads via the per-user inbox anchor and defines edges. Per-tenant
* tenant_id of the inbox is implicit from the Bearer token.
*/
async listThreads(userEmail: string, signal?: AbortSignal): Promise<ChatThread[]> {
const inboxKey = inboxKeyFor(userEmail);
const inbox = await ensureInbox(inboxKey, userEmail, signal);
if (!inbox) return [];
const edges = await jsonFetch(`/api/ea2/edges/defines?from=flow/${inboxKey}&limit=200`, { signal });
const threadKeys = ((edges?.items || []) as any[])
.filter((e) => e?.role === "presentation")
.map((e) => (e._to || "").split("/").pop())
.filter(Boolean) as string[];
const threads = await Promise.all(
threadKeys.map(async (k) => {
try {
const doc = await jsonFetch(`/api/ea2/flow/${k}`, { signal });
return {
_key: doc._key,
display_name: doc.display_name || "Chat",
description: doc.description || "",
created_at: doc.created_at,
participants: doc.config?.chat?.participants || [],
} as ChatThread;
} catch {
return null;
}
})
);
return threads.filter((t): t is ChatThread => !!t).sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""));
},
/**
* Create a thread between two persona emails. Returns the new thread key.
* Falls back to a deterministic key built from the participants when present.
* Create a thread as flow.kind=value so it is invisible to process-definition
* surfaces by EA2 schema rather than by source_context filtering.
*/
async createThread(displayName: string, participants: string[], signal?: AbortSignal): Promise<string> {
const payload = {
kind: "definition",
kind: "value",
status: "published",
name: `chat_${Date.now()}`,
display_name: displayName,
description: `Conversation between ${participants.join(" & ")}`,
source_context: "EA2_CHAT_THREAD",
source_context: "CANVAS_CHAT_THREAD",
config: { chat: { participants } },
};
const res = await fetch(`${api.config.baseUrl}/api/ea2/flow`, {
@@ -85,7 +155,15 @@ export const chatApi = {
});
if (!res.ok) throw new Error(`createThread ${res.status}`);
const body = await res.json();
return body._key;
const threadKey = body._key as string;
await Promise.all(
participants.map(async (email) => {
const inboxKey = inboxKeyFor(email);
await ensureInbox(inboxKey, email, signal);
await linkThreadToInbox(inboxKey, threadKey, signal);
})
);
return threadKey;
},
/** Read every message under a thread via the edges endpoint. */
@@ -124,12 +202,12 @@ export const chatApi = {
method: "POST",
headers: authHeaders(),
body: JSON.stringify({
kind: "definition",
kind: "value",
status: "published",
name: `msg_${Date.now()}`,
display_name: body.slice(0, 80),
description: body,
source_context: "EA2_CHAT_MSG",
source_context: "CANVAS_CHAT_MSG",
config: { chat: { author_email: authorEmail, thread_key: threadKey } },
}),
signal,
@@ -137,7 +215,7 @@ export const chatApi = {
if (!created.ok) throw new Error(`sendMessage create ${created.status}`);
const msg = await created.json();
const linkOps: BatchOperation[] = [wizardApi.ops.createStepEdge(threadKey, msg._key)];
const linkOps: BatchOperation[] = [wizardApi.ops.childEdge(threadKey, msg._key)];
await fetch(`${api.config.baseUrl}/api/ea2/apply-batch`, {
method: "POST",
headers: authHeaders(),
+94
View File
@@ -0,0 +1,94 @@
export interface RawFlow {
_key: string;
display_name?: string;
name?: string;
description?: string;
source_context?: string;
status?: string;
kind?: string;
}
const DEV_ARTEFACT_PATTERNS = [
/^rv[_\s]/i,
/^orphan/i,
/^atlas[_\s]?f1/i,
/\batlas-f1-fresh\b/i,
/mcp[_\s]?sdx[_\s]?smoke/i,
/\bcodex[_\s]?test\b/i,
/\bsidekick\b/i,
/^process_\d{10,}$/i,
/\bbackfill\b/i,
/\bprobe\b/i,
/\bfixture\b/i,
];
const NON_BUSINESS_SOURCE_CONTEXTS = new Set([
"EA2_CHAT_THREAD",
"EA2_CHAT_MSG",
"CANVAS_CHAT_THREAD",
"CANVAS_CHAT_MSG",
"CANVAS_CHAT_INBOX",
"CANVAS_ATTENDANCE_ROOT",
"CANVAS_ATTENDANCE_SITE",
"CANVAS_AGENT_MEMORY",
"CANVAS_AGENT_VAULT_ROOT",
"CANVAS_SEED_PROCESS",
"CANVAS_SEED_STEP",
"EA2_WIZARD_STEP",
]);
export const STARTABLE_FLOW_KEYS = new Set<string>([
"pr_to_po_def",
]);
const STARTABLE_SOURCE_CONTEXTS = new Set<string>([
"EA2_DRAFT_PROCESS:process_creation",
"fm06-t10-demo-reset-v2",
]);
const HEX32 = /[a-f0-9]{32}/gi;
const HEX_SUFFIX = /[_-][a-f0-9]{8,}$/i;
export function isDevArtefact(f: RawFlow): boolean {
if (f.source_context && NON_BUSINESS_SOURCE_CONTEXTS.has(f.source_context)) return true;
const haystack = `${f.display_name || ""} ${f.name || ""} ${f.description || ""}`;
return DEV_ARTEFACT_PATTERNS.some((p) => p.test(haystack));
}
export function curatedPublishedFlows<T extends RawFlow>(items: T[]): T[] {
return items.filter(
(it) =>
it.status === "published" &&
it.kind === "definition" &&
!!it.display_name &&
!isDevArtefact(it) &&
(STARTABLE_FLOW_KEYS.has(it._key) || (it.source_context && STARTABLE_SOURCE_CONTEXTS.has(it.source_context)))
);
}
export async function fetchStartableFlows(baseUrl: string, token: string): Promise<RawFlow[]> {
const results = await Promise.all(
Array.from(STARTABLE_FLOW_KEYS).map(async (key) => {
try {
const r = await fetch(`${baseUrl}/api/ea2/flow/${key}`, { headers: { Authorization: `Bearer ${token}` } });
if (!r.ok) return null;
const doc = await r.json();
if (doc.kind !== "definition" || doc.status !== "published" || !doc.display_name) return null;
return doc as RawFlow;
} catch { return null; }
})
);
return results.filter((d): d is RawFlow => !!d);
}
export function friendlyShortId(id: string | undefined | null): string {
if (!id) return "";
const trimmed = id.replace(HEX_SUFFIX, "");
if (trimmed.length < id.length && trimmed.length > 0) return trimmed;
if (HEX32.test(id)) return `ref-${id.slice(0, 4)}`;
return id;
}
export function scrubIdsFromText(text: string): string {
return text.replace(HEX32, "");
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Browser-side LLM client. Speaks to a backend proxy that brokers to the
* configured provider (Anthropic, OpenAI, etc.) using the same request
* shape as pi-ai's normalized completions. When the proxy is unreachable
* or unconfigured the client returns { configured: false } and the caller
* is expected to fall back to a deterministic path.
*/
import { api } from "./api";
export interface LlmMessage {
role: "system" | "user" | "assistant";
content: string;
}
export interface LlmRequest {
messages: LlmMessage[];
max_tokens?: number;
}
export interface LlmReply {
ok: true;
content: string;
provider?: string;
}
export interface LlmUnconfigured {
ok: false;
configured: false;
reason: string;
}
export interface LlmError {
ok: false;
configured: true;
error: string;
}
export type LlmResponse = LlmReply | LlmUnconfigured | LlmError;
export const llmClient = {
async chat(req: LlmRequest, signal?: AbortSignal): Promise<LlmResponse> {
try {
const res = await fetch(`${api.config.baseUrl}/internal/canvas-llm/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`,
},
body: JSON.stringify({ messages: req.messages, max_tokens: req.max_tokens ?? 512 }),
signal,
});
if (res.status === 404) return { ok: false, configured: false, reason: "LLM proxy not wired" };
if (res.status === 503) return { ok: false, configured: false, reason: "LLM provider not configured" };
if (!res.ok) return { ok: false, configured: true, error: `Provider error ${res.status}` };
const body = await res.json();
if (typeof body?.content !== "string") return { ok: false, configured: true, error: "Provider returned unparseable body" };
return { ok: true, content: body.content, provider: body.provider };
} catch (e) {
return { ok: false, configured: false, reason: `LLM proxy unreachable: ${(e as Error).message}` };
}
},
};
+79 -4
View File
@@ -122,7 +122,8 @@ export const wizardApi = {
ops: {
createStep(node: { display_name: string; dispatch_kind: string; agent_capability?: string; description?: string }): CreateOp {
const name = node.display_name.toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 40) || "step";
const slug = node.display_name.toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 30) || "step";
const name = `${slug}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
return {
op: "create",
coll: "flow",
@@ -130,7 +131,7 @@ export const wizardApi = {
kind: "definition",
name,
display_name: node.display_name,
status: "draft",
status: "published",
description: node.description || node.display_name,
source_context: "EA2_WIZARD_STEP",
dispatch_kind: node.dispatch_kind,
@@ -138,13 +139,87 @@ export const wizardApi = {
},
};
},
createStepEdge(parentFlowKey: string, childFlowKey: string): CreateEdgeOp {
createView(node: { display_name: string; fields?: { name: string; type: string }[] }): CreateOp {
const slug = node.display_name.toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 30) || "view";
const name = `${slug}_view_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const TYPE_MAP: Record<string, string> = { string: "string", number: "number", boolean: "boolean", textarea: "textarea" };
const fieldDefs =
node.fields && node.fields.length > 0
? node.fields
.filter((f) => f.name && f.name.trim())
.map((f) => {
const fieldSlug = f.name.toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 40);
const label = f.name.charAt(0).toUpperCase() + f.name.slice(1);
return { name: fieldSlug, label, type: TYPE_MAP[f.type] || "string", required: true, description: label };
})
: [{ name: "notes", label: "Notes", type: "textarea", required: false, description: "Notes for this step" }];
return {
op: "create",
coll: "view",
data: {
kind: "definition",
name,
label: `${node.display_name} · form`,
status: "active",
description: null,
source_context: null,
channel: "web",
layout: { layout: "form", fields: fieldDefs },
actions: [{ label: "Submit", action: "submit" }],
},
};
},
createVersion(parentDisplayName: string): CreateOp {
const slug = parentDisplayName.toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 30) || "process";
const name = `${slug}_v1_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
return {
op: "create",
coll: "version",
data: {
kind: "definition",
name,
label: "v1",
status: "active",
change_description: `Initial publish of ${parentDisplayName}`,
legal_entity: "canvas",
source_context: "EA2_WIZARD_VERSION",
},
};
},
childEdge(parentFlowKey: string, childFlowKey: string): CreateEdgeOp {
return {
op: "create_edge",
edge_coll: "defines",
from: `flow/${parentFlowKey}`,
to: `flow/${childFlowKey}`,
role: "next",
role: "child",
};
},
dispatchEdge(stepFlowKey: string): CreateEdgeOp {
return {
op: "create_edge",
edge_coll: "defines",
from: `flow/${stepFlowKey}`,
to: `flow/${stepFlowKey}`,
role: "dispatch",
};
},
presentationEdge(stepFlowKey: string, viewKey: string): CreateEdgeOp {
return {
op: "create_edge",
edge_coll: "defines",
from: `flow/${stepFlowKey}`,
to: `view/${viewKey}`,
role: "presentation",
};
},
governsEdge(versionKey: string, parentFlowKey: string): CreateEdgeOp {
return {
op: "create_edge",
edge_coll: "governs",
from: `version/${versionKey}`,
to: `flow/${parentFlowKey}`,
role: "governs",
};
},
publishFlow(flowKey: string): UpdateOp {
+7 -6
View File
@@ -28,7 +28,7 @@ export default function Agent() {
role: "agent",
ok: true,
text:
`Hi ${userEmail.split("@")[0]}. I'm Pi — your FlowMaster co-pilot. I can navigate the cockpit, start processes, message your team, and walk you through the Studio.`,
`Hi ${userEmail.split("@")[0]}. I'm the FlowMaster Command Assistant. I understand a fixed set of commands and run them against EA2 — navigate, list processes, start a process, message a teammate, remember a note, recall notes. Memory is text-only with keyword recall; an LLM backend is a separate component, off by default.`,
},
]);
const [draft, setDraft] = useState("");
@@ -63,8 +63,9 @@ export default function Agent() {
<div className="agent-scene">
<aside className="agent-sidebar">
<header className="agent-side-head">
<div className="mc-hero-eyebrow"><Bot size={12} /> Pi FlowMaster Agent</div>
<h2 className="mc-hero-title">Talk to your cockpit</h2>
<div className="mc-hero-eyebrow"><Bot size={12} /> FlowMaster Command Assistant</div>
<h2 className="mc-hero-title">Tell the cockpit what to do</h2>
<p className="agent-side-sub">Deterministic command router with EA2-backed text memory. The LLM adapter ships separately when a provider key is configured.</p>
</header>
<div className="agent-tool-list">
<div className="agent-tool-label">CAPABILITIES</div>
@@ -84,11 +85,11 @@ export default function Agent() {
<div className="agent-messages" ref={scrollRef}>
{turns.map((t) => (
<div key={t.id} className={`agent-turn agent-turn-${t.role}${t.ok === false ? " agent-turn-err" : ""}`}>
<div className="agent-turn-author">{t.role === "user" ? userEmail.split("@")[0] : "Pi"}</div>
<div className="agent-turn-author">{t.role === "user" ? userEmail.split("@")[0] : "Assistant"}</div>
<div className="agent-turn-body">{t.text}</div>
</div>
))}
{working && <div className="agent-turn agent-turn-agent agent-turn-thinking">Pi is thinking</div>}
{working && <div className="agent-turn agent-turn-agent agent-turn-thinking">Working</div>}
</div>
{turns.length <= 1 && (
@@ -109,7 +110,7 @@ export default function Agent() {
submit(draft);
}
}}
placeholder="Ask Pi to navigate, start a process, or message a teammate. Enter to send."
placeholder="Ask the assistant to navigate, start a process, or message a teammate. Enter to send."
rows={2}
disabled={working}
/>
+188
View File
@@ -0,0 +1,188 @@
import { useEffect, useState } from "react";
import { useApp } from "../state/store";
import { api, type WorkItem, type RuntimeTransaction } from "../lib/api";
import { Branch, Layers, Pulse } from "../components/icons";
interface DecoratedWorkItem extends WorkItem {
age_label: string;
}
const HUB_LABELS: Record<string, string> = {
procurement: "Procurement",
hr: "People",
it: "IT",
manufacturing: "Manufacturing",
finance: "Finance",
};
function ageLabel(item: WorkItem): string {
const days = item.age_days ?? 0;
if (days === 0) return "today";
if (days === 1) return "1 day";
if (days < 14) return `${days} days`;
if (days < 60) return `${Math.floor(days / 7)} wk`;
return `${Math.floor(days / 30)} mo`;
}
export default function Approvals() {
const userEmail = useApp((s) => s.userEmail);
const actor = useApp((s) => s.actor);
const pushToast = useApp((s) => s.pushToast);
const setScene = useApp((s) => s.setScene);
const [items, setItems] = useState<DecoratedWorkItem[] | null>(null);
const [hubFilter, setHubFilter] = useState<string>("all");
const [activeKey, setActiveKey] = useState<string | null>(null);
const [tx, setTx] = useState<RuntimeTransaction | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const tenantId = useApp((s) => s.tenantId);
const loadQueue = async () => {
if (!tenantId) {
setItems([]);
return;
}
try {
const rows = await api.workItems();
const scoped = rows.filter((it) => (it as any).tenant_id === tenantId);
setItems(scoped.map((it) => ({ ...it, age_label: ageLabel(it) })));
} catch (err: any) {
pushToast("err", `Queue failed: ${err.message}`);
}
};
useEffect(() => { void loadQueue(); }, [tenantId]);
useEffect(() => {
if (!activeKey) return;
let cancelled = false;
api.transaction(activeKey).then((r) => { if (!cancelled) setTx(r); });
return () => { cancelled = true; };
}, [activeKey]);
const hubs = Array.from(new Set((items || []).map((it) => it.hub).filter((h): h is string => !!h)));
const visible = (items || []).filter((it) => hubFilter === "all" || it.hub === hubFilter).slice(0, 50);
const counts = { running: 0, waiting: 0, blocked: 0 };
for (const it of items || []) {
if (it.status === "running") counts.running++;
else if (it.status === "waiting") counts.waiting++;
else if (it.status === "blocked" || it.status === "stuck") counts.blocked++;
}
const handleAction = async (actionId: string) => {
if (!tx?.transaction_id || !actor) return;
setBusy(actionId);
try {
const res = await api.executeAction(tx.transaction_id, actionId, actor, {});
pushToast("ok", `Action recorded · ${res.status || "ok"}`);
const fresh = await api.transaction(tx.transaction_id);
setTx(fresh);
await loadQueue();
} catch (err: any) {
const msg = err.message || "";
if (/runtime-values|action_execution_failed/i.test(msg)) {
pushToast("err", "Backend can't accept this action yet — runtime-values isn't reachable. The frontend sent the right shape.");
} else {
pushToast("err", `Action failed: ${msg.slice(0, 100)}`);
}
} finally {
setBusy(null);
}
};
return (
<div className="approvals-scene">
<header className="approvals-head">
<div className="mc-hero-eyebrow"><Layers size={12} /> Approvals · {userEmail.split("@")[0]}</div>
<h2 className="mc-hero-title">Work that needs you</h2>
<p className="approvals-intro">
Every running process instance for your tenant. Pick a row to see the active step and the actions available right now. Acting on a row writes the decision back to EA2.
</p>
<div className="approvals-stats">
<span className="approvals-stat approvals-stat-running">{counts.running} running</span>
<span className="approvals-stat approvals-stat-waiting">{counts.waiting} waiting</span>
{counts.blocked > 0 && <span className="approvals-stat approvals-stat-blocked">{counts.blocked} blocked</span>}
</div>
<div className="approvals-filter-row">
<button className={`hub-chip ${hubFilter === "all" ? "active" : ""}`} onClick={() => setHubFilter("all")}>All hubs</button>
{hubs.map((h) => (
<button key={h} className={`hub-chip ${hubFilter === h ? "active" : ""}`} onClick={() => setHubFilter(h)}>{HUB_LABELS[h] || h}</button>
))}
</div>
</header>
<div className="approvals-split">
<section className="approvals-queue">
<div className="hub-section-label">QUEUE · {visible.length}</div>
{items === null && <div className="hub-empty">Loading</div>}
{items !== null && visible.length === 0 && (
<div className="hub-empty">
Nothing waiting in this hub. Open the <button className="link-inline" onClick={() => setScene("studio")}>Process Studio</button> to publish a new flow, or check back later.
</div>
)}
<ul className="approvals-list">
{visible.map((it) => (
<li key={it.transaction_id}>
<button
className={`approvals-row ${it.transaction_id === activeKey ? "active" : ""}`}
onClick={() => setActiveKey(it.transaction_id)}
>
<div className="approvals-row-head">
<span className="approvals-row-title">{(it as any).display_name || it.business_subject || "Untitled case"}</span>
<span className="approvals-row-age">{it.age_label}</span>
</div>
<div className="approvals-row-meta">
<span className="approvals-row-step">{it.active_step_display_name || it.next_action || "—"}</span>
{it.hub && <span className="approvals-row-hub">{HUB_LABELS[it.hub] || it.hub}</span>}
{(it as any).requester_display_name && <span className="approvals-row-by">by {(it as any).requester_display_name}</span>}
</div>
</button>
</li>
))}
</ul>
</section>
<section className="approvals-detail">
<div className="hub-section-label">ACTIVE STEP</div>
{!activeKey && <div className="hub-empty">Select a case on the left to see its current step and actions.</div>}
{activeKey && tx && (
<div className="approvals-card">
<div className="approvals-card-title">
<Branch size={11} /> {tx.active_step?.display_name || "Active step"}
</div>
{tx.business_subject && <div className="approvals-card-meta">Subject · {tx.business_subject}</div>}
{(tx.active_step as any)?.dispatch_kind && (
<div className="approvals-card-meta">Dispatch · {(tx.active_step as any).dispatch_kind}</div>
)}
{(tx.active_step as any)?.form_fields && (tx.active_step as any).form_fields.length > 0 && (
<div className="approvals-fields">
<div className="approvals-fields-label">Form fields</div>
<ul>
{(tx.active_step as any).form_fields.slice(0, 6).map((f: any) => (
<li key={f.name}>{f.label || f.name} <span className="approvals-field-type">· {f.type}</span></li>
))}
</ul>
</div>
)}
<div className="approvals-actions">
{(tx.available_actions || []).map((a: any) => (
<button
key={a.id}
className={`btn ${a.kind === "approve" || a.kind === "submit" ? "btn-primary" : "btn-secondary"}`}
disabled={!a.enabled || busy === a.id}
onClick={() => handleAction(a.id)}
>
<Pulse size={11} /> {busy === a.id ? "Sending…" : a.display_label || a.id}
</button>
))}
{(!tx.available_actions || tx.available_actions.length === 0) && (
<div className="hub-empty">No actions available on this step.</div>
)}
</div>
</div>
)}
{activeKey && !tx && <div className="hub-empty">Loading case</div>}
</section>
</div>
</div>
);
}
+85 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useApp } from "../state/store";
import { chatApi, type ChatThread, type ChatMessage } from "../lib/chatApi";
import { Bot } from "../components/icons";
@@ -14,6 +14,26 @@ function personaLabel(email: string): string {
return PERSONA_DIRECTORY.find((p) => p.email === email)?.label || email;
}
const READ_KEY = (email: string) => `fm.canvas.chat.read.${email}`;
function loadReadMap(email: string): Record<string, string> {
try { return JSON.parse(localStorage.getItem(READ_KEY(email)) || "{}") || {}; } catch { return {}; }
}
function saveReadMap(email: string, map: Record<string, string>) {
try { localStorage.setItem(READ_KEY(email), JSON.stringify(map)); } catch { /* ignore */ }
}
function relativeTime(iso?: string): string {
if (!iso) return "";
const t = Date.parse(iso);
if (!Number.isFinite(t)) return "";
const diff = Date.now() - t;
if (diff < 60_000) return "just now";
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
return `${Math.floor(diff / 86_400_000)}d ago`;
}
export default function Chat() {
const userEmail = useApp((s) => s.userEmail);
const pushToast = useApp((s) => s.pushToast);
@@ -22,6 +42,8 @@ export default function Chat() {
const [threads, setThreads] = useState<ChatThread[]>([]);
const [activeKey, setActiveKey] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [previews, setPreviews] = useState<Record<string, ChatMessage | null>>({});
const [readMap, setReadMap] = useState<Record<string, string>>(() => loadReadMap(me));
const [draft, setDraft] = useState("");
const [loadingThreads, setLoadingThreads] = useState(false);
const [loadingMessages, setLoadingMessages] = useState(false);
@@ -34,7 +56,7 @@ export default function Chat() {
let cancelled = false;
setLoadingThreads(true);
chatApi
.listThreads()
.listThreads(me)
.then((rows) => {
if (cancelled) return;
setThreads(rows);
@@ -56,6 +78,14 @@ export default function Chat() {
.then((rows) => {
if (cancelled) return;
setMessages(rows);
const newest = rows[rows.length - 1];
if (newest) {
setReadMap((prev) => {
const next = { ...prev, [activeKey]: newest.created_at };
saveReadMap(me, next);
return next;
});
}
setTimeout(() => scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }), 50);
})
.catch((err) => pushToast("err", `Messages failed: ${err.message}`))
@@ -65,6 +95,39 @@ export default function Chat() {
};
}, [activeKey]);
useEffect(() => {
if (threads.length === 0) return;
let cancelled = false;
Promise.all(
threads.map(async (t) => {
try {
const rows = await chatApi.listMessages(t._key);
return [t._key, rows[rows.length - 1] || null] as const;
} catch {
return [t._key, null] as const;
}
})
).then((pairs) => {
if (cancelled) return;
const map: Record<string, ChatMessage | null> = {};
for (const [k, v] of pairs) map[k] = v;
setPreviews(map);
});
return () => { cancelled = true; };
}, [threads]);
const unreadCount = useMemo(() => {
let n = 0;
for (const t of threads) {
const last = previews[t._key];
if (!last) continue;
if (last.author_email === me) continue;
const readAt = readMap[t._key];
if (!readAt || last.created_at > readAt) n++;
}
return n;
}, [threads, previews, readMap, me]);
const handleNewThread = async () => {
if (!recipient || recipient === me) {
pushToast("err", "Pick a recipient that's not yourself");
@@ -73,7 +136,7 @@ export default function Chat() {
try {
const otherLabel = personaLabel(recipient);
const key = await chatApi.createThread(`${personaLabel(me)}${otherLabel}`, [me, recipient]);
const next = await chatApi.listThreads();
const next = await chatApi.listThreads(me);
setThreads(next);
setActiveKey(key);
pushToast("ok", `Thread opened with ${otherLabel}`);
@@ -130,18 +193,32 @@ export default function Chat() {
</div>
</div>
<div className="chat-thread-list">
{unreadCount > 0 && (
<div className="chat-unread-summary">{unreadCount} unread thread{unreadCount === 1 ? "" : "s"}</div>
)}
{loadingThreads && <div className="chat-empty">Loading threads</div>}
{!loadingThreads && threads.length === 0 && <div className="chat-empty">No conversations yet. Start one above.</div>}
{threads.map((t) => (
{threads.map((t) => {
const preview = previews[t._key];
const lastFromOther = preview && preview.author_email !== me;
const readAt = readMap[t._key];
const unread = lastFromOther && (!readAt || preview!.created_at > readAt);
const previewBody = preview ? `${preview.author_email === me ? "You: " : ""}${preview.body}` : t.description;
return (
<button
key={t._key}
className={`chat-thread-row ${t._key === activeKey ? "active" : ""}`}
className={`chat-thread-row ${t._key === activeKey ? "active" : ""} ${unread ? "unread" : ""}`}
onClick={() => setActiveKey(t._key)}
>
<div className="chat-thread-name">{t.display_name}</div>
<div className="chat-thread-meta">{t.description}</div>
<div className="chat-thread-row-head">
<span className="chat-thread-name">{t.display_name}</span>
<span className="chat-thread-time">{preview ? relativeTime(preview.created_at) : "—"}</span>
</div>
<div className="chat-thread-meta">{previewBody}</div>
{unread && <span className="chat-unread-dot" aria-label="unread" />}
</button>
))}
);
})}
</div>
</aside>
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useMemo, useState } from "react";
import { useApp } from "../state/store";
import { api } from "../lib/api";
import { curatedPublishedFlows, fetchStartableFlows } from "../lib/flowCuration";
import { Branch, Layers } from "../components/icons";
interface Document {
_key: string;
name: string;
label: string;
description: string;
flow_key: string;
flow_label: string;
}
async function loadDocuments(): Promise<Document[]> {
const token = sessionStorage.getItem("fm.mc.token.v1") || "";
const catalogue = await fetch(`${api.config.baseUrl}/api/ea2/flow/processes?limit=500`, {
headers: { Authorization: `Bearer ${token}` },
}).then((r) => r.json()).catch(() => ({ items: [] }));
const fromList = curatedPublishedFlows((catalogue?.items || []) as any[]);
const directHits = await fetchStartableFlows(api.config.baseUrl, token);
const merged = new Map<string, any>();
for (const it of [...directHits, ...fromList]) merged.set(it._key, it);
const flows = Array.from(merged.values()).slice(0, 10);
const all: Document[] = [];
for (const f of flows) {
try {
const g = await api.graph(f._key);
if (!g) continue;
const dataDefs = (g as any).data_definitions || [];
for (const d of dataDefs) {
all.push({
_key: d._key,
name: d.name || "",
label: d.label || d.display_name || d.name || "Document",
description: d.description || "",
flow_key: f._key,
flow_label: f.display_name || f.name,
});
}
} catch { /* ignore */ }
}
return all;
}
export default function Documents() {
const setScene = useApp((s) => s.setScene);
const pushToast = useApp((s) => s.pushToast);
const [docs, setDocs] = useState<Document[] | null>(null);
const [filter, setFilter] = useState("");
const [activeKey, setActiveKey] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
loadDocuments()
.then((rows) => !cancelled && setDocs(rows))
.catch((err) => !cancelled && pushToast("err", `Documents failed: ${err.message}`));
return () => { cancelled = true; };
}, []);
const visible = useMemo(() => {
if (!docs) return [];
const q = filter.toLowerCase().trim();
if (!q) return docs;
return docs.filter((d) =>
d.label.toLowerCase().includes(q) ||
d.description.toLowerCase().includes(q) ||
d.flow_label.toLowerCase().includes(q)
);
}, [docs, filter]);
const active = visible.find((d) => d._key === activeKey) || null;
return (
<div className="docs-scene">
<header className="docs-head">
<div className="mc-hero-eyebrow"><Layers size={12} /> Documents</div>
<h2 className="mc-hero-title">Everything your processes capture</h2>
<p className="docs-intro">
One document per data shape per published process. Each row points back to the process it belongs to. This is what your forms and rules read and write.
</p>
<input
className="docs-search"
type="search"
placeholder="Search documents and processes…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</header>
<div className="docs-split">
<section className="docs-list">
<div className="hub-section-label">DOCUMENTS · {visible.length}</div>
{docs === null && <div className="hub-empty">Loading from EA2</div>}
{docs !== null && visible.length === 0 && (
<div className="hub-empty">
No documents match. Open the <button className="link-inline" onClick={() => setScene("studio")}>Process Studio</button> to publish a new flow.
</div>
)}
<ul className="docs-rows">
{visible.slice(0, 100).map((d) => (
<li key={d._key}>
<button
className={`docs-row ${d._key === activeKey ? "active" : ""}`}
onClick={() => setActiveKey(d._key)}
>
<div className="docs-row-title"><Branch size={11} /> {d.label}</div>
<div className="docs-row-meta">{d.flow_label}</div>
</button>
</li>
))}
</ul>
</section>
<section className="docs-detail">
<div className="hub-section-label">DETAIL</div>
{!active && <div className="hub-empty">Pick a document on the left to read its description and source process.</div>}
{active && (
<div className="docs-card">
<div className="docs-card-title">{active.label}</div>
<div className="docs-card-meta">From process · {active.flow_label}</div>
{active.description && <p className="docs-card-body">{active.description}</p>}
<div className="docs-card-actions">
<button
className="btn btn-secondary"
onClick={() => { setScene("studio"); }}
>
Open in Studio
</button>
</div>
</div>
)}
</section>
</div>
</div>
);
}
+4 -4
View File
@@ -24,7 +24,7 @@ const SECTIONS = [
title: "Process lifecycle",
eyebrow: "FROM PEN TO PROD",
body:
"Every process travels the same four stops:\n\n1. DEFINE — author it in the Process Creation Wizard (drag, drop, describe, upload a doc, ask Pi)\n2. VERSION — freeze a release, get sign-off, immortalise the diff\n3. DEPLOY — roll it out to the tenant\n4. EXECUTE — the Execution Engine runs every instance, step-by-step, persisting state to EA2 at every write\n\nAuthoring tools and the runtime are separate concerns by design. They never bleed into each other.",
"Every process travels the same four stops:\n\n1. DEFINE — author it in the Process Creation Wizard (drag, drop, describe, upload a doc, ask the assistant)\n2. VERSION — freeze a release, get sign-off, immortalise the diff\n3. DEPLOY — roll it out to the tenant\n4. EXECUTE — the Execution Engine runs every instance, step-by-step, persisting state to EA2 at every write\n\nAuthoring tools and the runtime are separate concerns by design. They never bleed into each other.",
},
{
title: "What's a 'hub'?",
@@ -45,10 +45,10 @@ const SECTIONS = [
"Everything is stored in EA2, the FlowMaster graph database, organised into five document collections (flow, data, view, rule, version) and four edge collections (instantiates, defines, relates, governs). When the Wizard creates a step, when an agent applies a decision, when an SAP record gets transformed into a native shape — all of it lands in the same graph. There is exactly one source of truth.",
},
{
title: "Why an agent (Pi)?",
title: "Why a command assistant?",
eyebrow: "AN INTERFACE FOR EVERYONE",
body:
"Most people don't want to learn nineteen tabs. Pi is a co-pilot you can talk to: navigate the cockpit, start a process, message your team, list what's published, build a new flow. It's the same API the UI uses — Pi just speaks human. If a regional store manager asks 'start a laptop request for store 204', Pi finds the right published flow and starts it.",
"Most people don't want to learn nineteen tabs. The FlowMaster Command Assistant lets you type the action: navigate the cockpit, start a process, message your team, list what's published, build a new flow. It's the same EA2 API the UI uses — the assistant just maps short phrases onto it. If a regional store manager types 'start a laptop request for store 204', the assistant finds the right published flow and starts it.",
},
] as const;
@@ -63,7 +63,7 @@ export default function Explainer() {
Eight pages, eight minutes everything a new operator needs to understand before they touch the cockpit. Distilled from the FlowMaster Element Architecture (v2.1) and the EA2 contracts.
</p>
<div className="explainer-jump-row">
<button className="hub-chip" onClick={() => setScene("agent")}><Bot size={11} /> Ask Pi instead</button>
<button className="hub-chip" onClick={() => setScene("agent")}><Bot size={11} /> Ask the assistant instead</button>
<button className="hub-chip" onClick={() => setScene("studio")}><Branch size={11} /> Open the Wizard</button>
<button className="hub-chip" onClick={() => setScene("mission")}><Pulse size={11} /> Go to Mission Control</button>
</div>
+24 -31
View File
@@ -4,6 +4,7 @@ import L from "leaflet";
import "leaflet/dist/leaflet.css";
import { useApp } from "../state/store";
import { Pulse } from "../components/icons";
import { attendanceApi, type AttendanceSite } from "../lib/attendanceApi";
L.Marker.prototype.options.icon = L.icon({
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
@@ -15,35 +16,13 @@ L.Marker.prototype.options.icon = L.icon({
shadowSize: [41, 41],
});
interface Site {
id: string;
label: string;
kind: "store" | "office" | "warehouse";
lat: number;
lng: number;
status: "checked_in" | "checked_out" | "late";
who: string;
city: string;
}
const SITES: Site[] = [
{ id: "hq-london", label: "HQ · London", kind: "office", lat: 51.5074, lng: -0.1278, status: "checked_in", who: "Mariana Cole (CEO) — on-site", city: "London" },
{ id: "store-204", label: "Store 204 · Manchester", kind: "store", lat: 53.4808, lng: -2.2426, status: "checked_in", who: "Manager Priya Sahota — opened 08:02", city: "Manchester" },
{ id: "store-118", label: "Store 118 · Birmingham", kind: "store", lat: 52.4862, lng: -1.8904, status: "late", who: "Manager Tom Reilly — late check-in 09:24", city: "Birmingham" },
{ id: "store-052", label: "Store 052 · Edinburgh", kind: "store", lat: 55.9533, lng: -3.1883, status: "checked_in", who: "Manager Iona MacDonald — opened 07:58", city: "Edinburgh" },
{ id: "office-berlin", label: "Office · Berlin", kind: "office", lat: 52.52, lng: 13.405, status: "checked_in", who: "Aisha Khan (HR Director) — remote-on", city: "Berlin" },
{ id: "warehouse-rotterdam", label: "Warehouse · Rotterdam", kind: "warehouse", lat: 51.9244, lng: 4.4777, status: "checked_in", who: "Shift A — 24 staff on-floor", city: "Rotterdam" },
{ id: "office-bangalore", label: "Office · Bangalore", kind: "office", lat: 12.9716, lng: 77.5946, status: "checked_in", who: "Rohan Patel (IT Director) — on-site", city: "Bangalore" },
{ id: "store-088", label: "Store 088 · Cardiff", kind: "store", lat: 51.4816, lng: -3.1791, status: "checked_out", who: "Manager Daniel Owens — closed 21:14 yesterday", city: "Cardiff" },
];
function statusTag(s: Site["status"]) {
function statusTag(s: AttendanceSite["status"]) {
if (s === "checked_in") return "ON-SITE";
if (s === "late") return "LATE";
return "OFF";
}
function FitToSites({ sites }: { sites: Site[] }) {
function FitToSites({ sites }: { sites: AttendanceSite[] }) {
const map = useMap();
useEffect(() => {
if (!sites.length) return;
@@ -55,9 +34,20 @@ function FitToSites({ sites }: { sites: Site[] }) {
export default function GeoAttendance() {
const setScene = useApp((s) => s.setScene);
const [filter, setFilter] = useState<"all" | Site["kind"]>("all");
const visible = filter === "all" ? SITES : SITES.filter((s) => s.kind === filter);
const stats = SITES.reduce(
const pushToast = useApp((s) => s.pushToast);
const [filter, setFilter] = useState<"all" | AttendanceSite["kind"]>("all");
const [sites, setSites] = useState<AttendanceSite[] | null>(null);
useEffect(() => {
let cancelled = false;
attendanceApi
.listSites()
.then((rows) => !cancelled && setSites(rows))
.catch((err) => !cancelled && pushToast("err", `Sites failed: ${err.message}`));
return () => { cancelled = true; };
}, []);
const allSites = sites || [];
const visible = filter === "all" ? allSites : allSites.filter((s) => s.kind === filter);
const stats = allSites.reduce(
(acc, s) => ({
onSite: acc.onSite + (s.status === "checked_in" ? 1 : 0),
late: acc.late + (s.status === "late" ? 1 : 0),
@@ -69,11 +59,14 @@ export default function GeoAttendance() {
return (
<div className="geo-scene">
<header className="geo-head">
<div className="mc-hero-eyebrow"><Pulse size={12} /> Real-time attendance</div>
<h2 className="mc-hero-title">Where your people are right now</h2>
<div className="mc-hero-eyebrow"><Pulse size={12} /> Attendance map</div>
<h2 className="mc-hero-title">Where your people are</h2>
<p className="geo-intro">
Live attendance for stores, offices, and warehouses. Click a marker to see who's checked in and when. Powered by OpenStreetMap tiles — no proprietary mapping key required.
Live sites and check-in statuses sourced from EA2. Add a site by inserting an attendance flow doc (kind=value, source_context=CANVAS_ATTENDANCE_SITE) and linking it to flow/attendance_root via a defines edge with role=presentation. Tiles are served by OpenStreetMap.
</p>
{sites !== null && allSites.length === 0 && (
<p className="geo-empty">No sites configured yet. Seed `qa/seeds/003_attendance_sites.mjs` to add the default canvas attendance roster.</p>
)}
<div className="geo-stats">
<span className="geo-stat geo-stat-ok">{stats.onSite} on-site</span>
<span className="geo-stat geo-stat-late">{stats.late} late</span>
@@ -101,7 +94,7 @@ export default function GeoAttendance() {
/>
<FitToSites sites={visible} />
{visible.map((s) => (
<Marker key={s.id} position={[s.lat, s.lng]}>
<Marker key={s._key} position={[s.lat, s.lng]}>
<Popup>
<strong>{s.label}</strong>
<br />
+84 -55
View File
@@ -2,50 +2,63 @@ import { useEffect, useState } from "react";
import { useApp } from "../state/store";
import { api } from "../lib/api";
import { wizardApi } from "../lib/wizardApi";
import { curatedPublishedFlows, fetchStartableFlows } from "../lib/flowCuration";
import { Branch, Layers, Pulse } from "../components/icons";
export type HubKey = "procurement" | "hr" | "it";
interface HubWorkflow {
label: string;
subject: string;
processHint: string;
}
interface HubSpec {
title: string;
eyebrow: string;
workbench: string;
intro: string;
match: RegExp;
quickActions: { label: string; subject: string; processHint: string }[];
workflows: HubWorkflow[];
}
const HUBS: Record<HubKey, HubSpec> = {
procurement: {
title: "Procurement Hub",
eyebrow: "Purchase requests, vendor approvals, three-way match",
title: "Procurement workbench",
workbench: "Purchase requests, vendor approvals, three-way match",
intro:
"Everything a store manager needs to buy something — from a laptop to a forklift — and watch it move through the approval chain.",
match: /procure|purchase|vendor|\bpo\b|\bp2p\b|requisition|payment|invoice/i,
quickActions: [
{ label: "Request a new laptop", subject: "store-204-laptop", processHint: "procurement" },
{ label: "Submit a quote for review", subject: "vendor-quote-Q3", processHint: "procurement" },
workflows: [
{ label: "New purchase request", subject: "store-204-laptop", processHint: "procurement" },
{ label: "Vendor approval", subject: "vendor-quote-Q3", processHint: "vendor" },
{ label: "PO change / cancel", subject: "po-change-may", processHint: "po" },
{ label: "Three-way match", subject: "match-batch-mar", processHint: "match" },
],
},
hr: {
title: "People Hub",
eyebrow: "Hiring, onboarding, leave, performance",
title: "HR workbench",
workbench: "Hiring, onboarding, leave, payroll",
intro:
"All people movements run here. Start an onboarding for a new hire, file a leave request, or kick off a review cycle.",
match: /onboard|offboard|leave|\bhr\b|hiring|payroll|employee|people operations/i,
quickActions: [
{ label: "Onboard a new hire", subject: "new-hire-2026Q3", processHint: "onboard" },
{ label: "Request annual leave", subject: "leave-7d-may", processHint: "leave" },
workflows: [
{ label: "Employee onboarding", subject: "new-hire-2026Q3", processHint: "onboard" },
{ label: "Off-boarding", subject: "leaver-2026Q3", processHint: "offboard" },
{ label: "Sick leave", subject: "sick-leave-may", processHint: "leave" },
{ label: "Payroll management", subject: "payroll-Q3-2026", processHint: "payroll" },
],
},
it: {
title: "IT Hub",
eyebrow: "Tickets, access requests, incident response",
title: "IT workbench",
workbench: "Access requests, equipment, service tickets, off-boarding",
intro:
"When something breaks, request access, or you need a new account — file it here and route it to the right on-call.",
match: /\bit\b|ticket|incident|service operations|access request|sap access|laptop request|hardware request|software request|password reset|account provisioning/i,
quickActions: [
{ label: "Open an incident", subject: "incident-store-204", processHint: "service" },
{ label: "Request system access", subject: "access-sap-RW", processHint: "access" },
workflows: [
{ label: "Access request", subject: "access-sap-RW", processHint: "access" },
{ label: "Equipment request", subject: "store-204-laptop", processHint: "equipment" },
{ label: "Service ticket", subject: "incident-store-204", processHint: "service" },
{ label: "User off-boarding", subject: "user-leaver-jun", processHint: "offboard" },
],
},
};
@@ -57,29 +70,20 @@ interface PublishedFlow {
name?: string;
}
const NON_BUSINESS_SOURCE_CONTEXTS = new Set([
"EA2_CHAT_THREAD",
"EA2_CHAT_MSG",
"EA2_WIZARD_STEP",
]);
async function loadPublishedFlows(): Promise<PublishedFlow[]> {
const res = await fetch(`${api.config.baseUrl}/api/ea2/flow/processes?limit=200`, {
headers: { Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}` },
const token = sessionStorage.getItem("fm.mc.token.v1") || "";
const res = await fetch(`${api.config.baseUrl}/api/ea2/flow/processes?limit=500`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`processes ${res.status}`);
const body = await res.json();
return ((body?.items || []) as any[])
.filter(
(it) =>
it.status === "published" &&
it.kind === "definition" &&
it.display_name &&
!NON_BUSINESS_SOURCE_CONTEXTS.has(it.source_context)
)
.map((it) => ({
const fromList = curatedPublishedFlows((body?.items || []) as any[]);
const directHits = await fetchStartableFlows(api.config.baseUrl, token);
const merged = new Map<string, any>();
for (const it of [...directHits, ...fromList]) merged.set(it._key, it);
return Array.from(merged.values()).map((it) => ({
_key: it._key,
display_name: it.display_name,
display_name: it.display_name!,
description: it.description,
name: it.name,
}));
@@ -105,12 +109,18 @@ export default function Hub({ hub }: { hub: HubKey }) {
const matching = (flows || []).filter((f) => spec.match.test(`${f.display_name} ${f.description || ""} ${f.name || ""}`)).slice(0, 8);
const fallback = (flows || []).slice(0, 6);
const visible = matching.length > 0 ? matching : fallback;
const [selectedKey, setSelectedKey] = useState<string | null>(null);
useEffect(() => {
if (!selectedKey && visible.length > 0) setSelectedKey(visible[0]._key);
}, [visible, selectedKey]);
const selected = visible.find((f) => f._key === selectedKey) || null;
const startInstance = async (flowKey: string, subject: string, label: string) => {
setBusy(flowKey);
try {
const res = await wizardApi.startInstance(flowKey, subject);
pushToast("ok", `Started ${label}${subject ? ` for ${subject}` : ""}${res.transaction_id?.slice(0, 8) || "ok"}`);
void res;
pushToast("ok", `Started ${label}${subject ? ` for ${subject}` : ""}.`);
setScene("mission");
} catch (err: any) {
pushToast("err", `Start failed: ${err.message}`);
@@ -134,15 +144,15 @@ export default function Hub({ hub }: { hub: HubKey }) {
return (
<div className="hub-scene">
<header className="hub-head">
<div className="mc-hero-eyebrow"><Layers size={12} /> {spec.eyebrow}</div>
<div className="mc-hero-eyebrow"><Layers size={12} /> {spec.workbench}</div>
<h2 className="mc-hero-title">{spec.title}</h2>
<p className="hub-intro">{spec.intro}</p>
</header>
<section className="hub-quick">
<div className="hub-section-label">QUICK ACTIONS</div>
<section className="hub-commands">
<div className="hub-section-label">{spec.title.split(" ")[0].toUpperCase()} WORKFLOW COMMANDS</div>
<div className="hub-quick-grid">
{spec.quickActions.map((qa) => (
{spec.workflows.map((qa) => (
<button
key={qa.label}
className="hub-quick-card"
@@ -150,36 +160,55 @@ export default function Hub({ hub }: { hub: HubKey }) {
disabled={!flows}
>
<div className="hub-quick-title"><Pulse size={12} /> {qa.label}</div>
<div className="hub-quick-meta">Looks up "{qa.processHint}" in your catalogue</div>
<div className="hub-quick-meta">Open workflow</div>
</button>
))}
</div>
</section>
<section className="hub-catalogue">
<div className="hub-section-label">RELATED PROCESSES</div>
{flows === null && <div className="hub-empty">Loading catalogue</div>}
<div className="hub-split">
<section className="hub-queue">
<div className="hub-section-label">AVAILABLE WORKFLOWS</div>
{flows === null && <div className="hub-empty">Loading</div>}
{flows !== null && visible.length === 0 && (
<div className="hub-empty">
No matching published processes yet. Open the <button className="link-inline" onClick={() => setScene("studio")}>Process Studio</button> to build one.
No workflows published for this function yet. Open the <button className="link-inline" onClick={() => setScene("studio")}>Process Studio</button> to add one.
</div>
)}
<div className="hub-catalogue-grid">
<ul className="hub-queue-list">
{visible.map((f) => (
<div key={f._key} className="hub-flow-card">
<div className="hub-flow-title"><Branch size={11} /> {f.display_name}</div>
{f.description && <div className="hub-flow-desc">{f.description.slice(0, 160)}</div>}
<li key={f._key}>
<button
className="btn btn-secondary"
disabled={busy === f._key}
onClick={() => startInstance(f._key, "", f.display_name)}
className={`hub-queue-row ${f._key === selectedKey ? "active" : ""}`}
onClick={() => setSelectedKey(f._key)}
>
{busy === f._key ? "Starting…" : "Start an instance"}
<Branch size={11} />
<span className="hub-queue-name">{f.display_name}</span>
</button>
</li>
))}
</ul>
<p className="hub-queue-foot">A live work-item queue will replace this list once the EA2 hub queue endpoint is wired.</p>
</section>
<section className="hub-selected">
<div className="hub-section-label">WORKFLOW DETAIL</div>
{!selected && <div className="hub-empty">Select a workflow on the left.</div>}
{selected && (
<div className="hub-flow-card">
<div className="hub-flow-title"><Branch size={11} /> {selected.display_name}</div>
{selected.description && <div className="hub-flow-desc">{selected.description.slice(0, 320)}</div>}
<button
className="btn btn-primary"
disabled={busy === selected._key}
onClick={() => startInstance(selected._key, "", selected.display_name)}
>
{busy === selected._key ? "Starting…" : "Start an instance"}
</button>
</div>
))}
</div>
)}
</section>
</div>
</div>
);
}
+6 -2
View File
@@ -79,9 +79,13 @@ export default function Landing() {
<button className="hub-chip" onClick={() => setScene("hub-procurement")}>Procurement Hub</button>
<button className="hub-chip" onClick={() => setScene("hub-hr")}>People Hub</button>
<button className="hub-chip" onClick={() => setScene("hub-it")}>IT Hub</button>
<button className="hub-chip" onClick={() => setScene("geo-attendance")}>Attendance Map</button>
<button className="hub-chip" onClick={() => setScene("agent")}>Talk to Pi</button>
{(import.meta.env.VITE_ENABLE_GEO_PREVIEW ?? "true") !== "false" && (
<button className="hub-chip" onClick={() => setScene("geo-attendance")}>Attendance Map · preview</button>
)}
<button className="hub-chip" onClick={() => setScene("agent")}>Command Assistant</button>
<button className="hub-chip" onClick={() => setScene("chat")}>Team Chat</button>
<button className="hub-chip" onClick={() => setScene("approvals")}>Approvals queue</button>
<button className="hub-chip" onClick={() => setScene("documents")}>Documents</button>
<button className="hub-chip" onClick={() => setScene("explainer")}>What is FlowMaster?</button>
</div>
+11 -31
View File
@@ -22,58 +22,43 @@ describe("Login Scene", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default store mock
(useApp as any).mockImplementation((selector: any) => {
const state = {
setScene: mockSetScene,
loginAs: mockLoginAs,
};
const state = { setScene: mockSetScene, loginAs: mockLoginAs };
return selector(state);
});
// Default api mock
(api.devLoginConfig as any).mockResolvedValue({ enabled: false });
globalThis.fetch = vi.fn(async () => new Response("", { status: 404 })) as any;
});
it("renders standard login form elements", async () => {
render(<Login />);
expect(screen.getByText("FLOWMASTER AUTHENTICATION")).toBeDefined();
expect(screen.getByLabelText(/OPERATOR_ID/i)).toBeDefined();
expect(screen.getByLabelText(/PASSPHRASE/i)).toBeDefined();
expect(screen.getAllByText("SIGN IN")[0]).toBeDefined();
expect(screen.getAllByText("CONTINUE WITH MICROSOFT")[0]).toBeDefined();
expect(screen.getAllByText(/MICROSOFT SIGN-IN|CONTINUE WITH MICROSOFT/i)[0]).toBeDefined();
});
it("calls loginAs and setScene on valid form submission", async () => {
it("calls loginAs with password method on valid form submission", async () => {
mockLoginAs.mockResolvedValueOnce(undefined);
render(<Login />);
fireEvent.change(screen.getByLabelText(/OPERATOR_ID/i), { target: { value: 'test@example.com' } });
fireEvent.change(screen.getByLabelText(/PASSPHRASE/i), { target: { value: 'password123' } });
fireEvent.click(screen.getAllByRole("button", { name: /^SIGN IN$/ })[0]);
await waitFor(() => {
expect(mockLoginAs).toHaveBeenCalledWith('test@example.com', 'password123');
expect(mockLoginAs).toHaveBeenCalledWith('test@example.com', 'password123', { method: 'password' });
expect(mockSetScene).toHaveBeenCalledWith('landing');
});
});
it("displays error on login failure", async () => {
mockLoginAs.mockRejectedValueOnce(new Error("Invalid credentials"));
it("blocks empty-password SIGN IN — must NOT silently call dev-login", async () => {
render(<Login />);
fireEvent.change(screen.getByLabelText(/OPERATOR_ID/i), { target: { value: 'test@example.com' } });
fireEvent.click(screen.getAllByRole("button", { name: /^SIGN IN$/ })[0]);
await waitFor(() => {
expect(screen.getByText("Invalid credentials")).toBeDefined();
expect(mockSetScene).not.toHaveBeenCalled();
expect(screen.getByText(/Password required/i)).toBeDefined();
});
expect(mockLoginAs).not.toHaveBeenCalled();
});
it("shows dev-login button when feature flag is enabled", async () => {
@@ -86,16 +71,11 @@ describe("Login Scene", () => {
});
});
it("redirects to SSO endpoint on microsoft click", async () => {
// We can't actually assert on window.location.href in this test environment
// without mocking window.location, but we can verify the button renders
// and click it to ensure no errors
it("SSO button is disabled until probe resolves to ready", async () => {
render(<Login />);
const ssoBtn = screen.getAllByRole("button", { name: /CONTINUE WITH MICROSOFT/i })[0];
const ssoBtn = screen.getAllByRole("button", { name: /MICROSOFT SIGN-IN|CONTINUE WITH MICROSOFT/i })[0] as HTMLButtonElement;
expect(ssoBtn.disabled).toBe(true);
fireEvent.click(ssoBtn);
// We just verify it doesn't try to call the regular login
expect(mockLoginAs).not.toHaveBeenCalled();
});
});
+64 -9
View File
@@ -9,24 +9,40 @@ export default function Login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const [devLoginEnabled, setDevLoginEnabled] = useState(true);
const buildAllowsDev = (import.meta.env.VITE_ENABLE_DEV_LOGIN ?? "true") !== "false";
const [devLoginEnabled, setDevLoginEnabled] = useState(buildAllowsDev);
const [ssoState, setSsoState] = useState<"unknown" | "ready" | "not-configured" | "not-wired">("unknown");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// dev-login is on by default. Only the endpoint can flip it OFF.
if (!buildAllowsDev) {
setDevLoginEnabled(false);
} else {
api.devLoginConfig()
.then((cfg) => { if (cfg.enabled === false) setDevLoginEnabled(false); })
.catch(() => { /* endpoint absent → keep ON */ });
}, []);
.catch(() => { /* endpoint absent → keep state */ });
}
fetch("/api/v1/auth/microsoft/login", { method: "GET", redirect: "manual" })
.then((r) => {
if (r.status >= 300 && r.status < 400) setSsoState("ready");
else if (r.status === 503) setSsoState("not-configured");
else setSsoState("not-wired");
})
.catch(() => setSsoState("not-wired"));
}, [buildAllowsDev]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) return;
if (!password) {
setError("Password required. Use the developer button below if dev-login is enabled.");
return;
}
setLoading(true);
setError(null);
try {
await loginAs(email, password || undefined);
await loginAs(email, password, { method: "password" });
setScene("landing");
} catch (err) {
setError((err as Error).message);
@@ -36,6 +52,10 @@ export default function Login() {
};
const handleDevLogin = async () => {
if (!devLoginEnabled) {
setError("Developer sign-in is disabled in this build.");
return;
}
if (!email) {
setError("Email required for dev-login");
return;
@@ -43,7 +63,7 @@ export default function Login() {
setLoading(true);
setError(null);
try {
await loginAs(email, undefined);
await loginAs(email, undefined, { method: "dev" });
setScene("landing");
} catch (err) {
setError((err as Error).message);
@@ -52,8 +72,29 @@ export default function Login() {
}
};
const handleSSO = () => {
const handleSSO = async () => {
setError(null);
setLoading(true);
try {
const probe = await fetch("/api/v1/auth/microsoft/login", { method: "GET", redirect: "manual" });
if (probe.status >= 300 && probe.status < 400) {
window.location.href = "/api/v1/auth/microsoft/login";
return;
}
if (probe.status === 503) {
setError("Microsoft sign-in is not yet enabled on this tenant. Use developer sign-in below for now.");
return;
}
if (probe.status === 404) {
setError("Microsoft sign-in is not wired to this environment yet. Use developer sign-in below for now.");
return;
}
window.location.href = "/api/v1/auth/microsoft/login";
} catch (e) {
setError(`Microsoft sign-in unreachable: ${(e as Error).message}`);
} finally {
setLoading(false);
}
};
return (
@@ -121,14 +162,28 @@ export default function Login() {
</div>
<div className="login-sso-actions">
<button type="button" className="sso-btn" onClick={handleSSO} disabled={loading}>
<button
type="button"
className="sso-btn"
onClick={handleSSO}
disabled={loading || ssoState !== "ready"}
title={
ssoState === "ready" ? "Sign in with Microsoft"
: ssoState === "not-configured" ? "Microsoft sign-in is not yet configured for this tenant"
: ssoState === "not-wired" ? "Microsoft sign-in is not wired to this environment yet"
: "Checking Microsoft sign-in availability…"
}
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 21 21">
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
<rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
</svg>
CONTINUE WITH MICROSOFT
{ssoState === "unknown" ? "MICROSOFT SIGN-IN · CHECKING…"
: ssoState === "not-configured" ? "MICROSOFT SIGN-IN · NOT CONFIGURED"
: ssoState === "not-wired" ? "MICROSOFT SIGN-IN · NOT WIRED"
: "CONTINUE WITH MICROSOFT"}
</button>
{devLoginEnabled && (
+22 -21
View File
@@ -5,10 +5,9 @@ import { api } from "../lib/api";
import { User, Refresh, Cog, Pulse, Layers, Check } from "../components/icons";
const COMMON_EMAILS = [
"dev@flow-master.ai",
"procurement.operator@flowmaster.local",
"finance.lead@flowmaster.local",
"ops.admin@flowmaster.local",
"ceo-head@flow-master.ai",
"hr-head@flow-master.ai",
"it-head@flow-master.ai",
];
export default function Settings() {
@@ -33,7 +32,7 @@ export default function Settings() {
const signIn = async (email: string) => {
setSigningIn(true);
try {
await loginAs(email);
await loginAs(email, undefined, { method: "dev" });
} finally {
setSigningIn(false);
}
@@ -59,20 +58,16 @@ export default function Settings() {
<h3 className="panel-h"><User size={12} /> Identity</h3>
<div className="settings-id">
<div className="i-field">
<span>Current</span>
<span className="mono">{actor?.user_id?.slice(0, 12) ?? "—"}</span>
<span>Signed in</span>
<span>{userDisplayName ?? userEmail.split("@")[0]}</span>
</div>
<div className="i-field">
<span>Email</span>
<span>{userEmail}</span>
</div>
<div className="i-field">
<span>Display name</span>
<span>{userDisplayName ?? ""}</span>
</div>
<div className="i-field">
<span>Backend</span>
<span className="mono">{api.config.baseUrl || "(same-origin via /api)"}</span>
<span>Status</span>
<span>{actor?.user_id ? "active session" : "no session"}</span>
</div>
</div>
@@ -97,9 +92,19 @@ export default function Settings() {
))}
</div>
</div>
<button className="link-btn" style={{ marginTop: 10 }} onClick={clearToken}>
<Refresh size={12} /> Clear bearer token
<div className="settings-row" style={{ marginTop: 12 }}>
<button className="btn btn-secondary" onClick={() => {
api.clearToken();
sessionStorage.removeItem("fm.mc.token.v1");
pushToast("ok", "Signed out.");
window.location.href = "/";
}}>
Sign out
</button>
<button className="link-btn" onClick={clearToken}>
<Refresh size={12} /> Reset session
</button>
</div>
</section>
<section className="studio-panel">
@@ -151,14 +156,10 @@ export default function Settings() {
<section className="studio-panel">
<h3 className="panel-h"><Layers size={12} /> About</h3>
<p className="settings-text">
FlowMaster Mission Control. Live at{" "}
FlowMaster operator cockpit. Live at{" "}
<a className="settings-link" href="https://canvas.flow-master.ai" target="_blank" rel="noreferrer">
canvas.flow-master.ai
</a>. All actions hit the real EA2 backend the live console shows every
fetch. Source:{" "}
<a className="settings-link" href="https://gitea.flow-master.ai/shad/flowmaster-mission-control-demo" target="_blank" rel="noreferrer">
gitea.flow-master.ai/shad/flowmaster-mission-control-demo
</a>.
</a>. Every action writes to EA2 open the live console (above) to see the API trail.
</p>
</section>
</div>
+54 -15
View File
@@ -103,27 +103,36 @@ export default function Wizard() {
if (!draft.flowKey || !actor) return;
setWorking(true);
try {
const createOps: BatchOperation[] = draft.nodes.map(n =>
const stepCreateOps: BatchOperation[] = draft.nodes.map(n =>
wizardApi.ops.createStep({
display_name: n.display_name,
dispatch_kind: n.dispatch_kind,
agent_capability: n.agent_capability,
})
);
const createRes = await wizardApi.applyBatch(draft.flowKey, createOps, actor);
const createdKeys: string[] = (createRes?.result?.ops || []).map((o: any) => o.key);
const nodesWithKeys = draft.nodes.map((n, i) => ({ ...n, _key: createdKeys[i] || n._key }));
const viewCreateOps: BatchOperation[] = draft.nodes.map(n =>
wizardApi.ops.createView({ display_name: n.display_name })
);
const versionCreateOp: BatchOperation = wizardApi.ops.createVersion(draft.name || "Process");
const allCreateOps = [...stepCreateOps, ...viewCreateOps, versionCreateOp];
const linkOps: BatchOperation[] = [
wizardApi.ops.createStepEdge(draft.flowKey!, nodesWithKeys[0]._key),
...nodesWithKeys.slice(0, -1).map((n, i) =>
wizardApi.ops.createStepEdge(n._key, nodesWithKeys[i + 1]._key)
),
const createRes = await wizardApi.applyBatch(draft.flowKey, allCreateOps, actor);
const createdOps: any[] = createRes?.result?.ops || [];
const stepKeys = createdOps.slice(0, draft.nodes.length).map(o => o.key);
const viewKeys = createdOps.slice(draft.nodes.length, draft.nodes.length * 2).map(o => o.key);
const versionKey = createdOps[createdOps.length - 1]?.key;
const nodesWithKeys = draft.nodes.map((n, i) => ({ ...n, _key: stepKeys[i] || n._key, viewKey: viewKeys[i] }));
const wireOps: BatchOperation[] = [
...nodesWithKeys.map((n, _i) => wizardApi.ops.childEdge(draft.flowKey!, n._key)),
...nodesWithKeys.map(n => wizardApi.ops.dispatchEdge(n._key)),
...nodesWithKeys.map(n => wizardApi.ops.presentationEdge(n._key, n.viewKey)),
];
if (linkOps.length) await wizardApi.applyBatch(draft.flowKey, linkOps, actor);
if (versionKey) wireOps.push(wizardApi.ops.governsEdge(versionKey, draft.flowKey!));
if (wireOps.length) await wizardApi.applyBatch(draft.flowKey, wireOps, actor);
updateDraft({ step: "Generate", nodes: nodesWithKeys });
pushToast("ok", `Saved ${createdKeys.length} steps to EA2`);
pushToast("ok", `Saved ${stepKeys.length} steps with views and version to EA2`);
} catch (err: any) {
pushToast("err", `Save failed: ${err.message}`);
} finally {
@@ -135,13 +144,43 @@ export default function Wizard() {
if (!draft.flowKey || !actor) return;
setWorking(true);
try {
// In a real app we'd save proper data models here.
// For the spike we just move forward and do a dummy update
await wizardApi.updateDraftConfig(draft.flowKey, {
config: { wizard: { panelState: { fields: draft.fields } } }
});
if (draft.fields.length > 0 && draft.nodes.length > 0) {
const token = sessionStorage.getItem("fm.mc.token.v1") || "";
const oldEdgeKeys: string[] = [];
for (const n of draft.nodes) {
try {
const r = await fetch(
`${(await import("../lib/api")).api.config.baseUrl}/api/ea2/edges/defines?from=flow/${n._key}&limit=50`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!r.ok) continue;
const items = ((await r.json())?.items || []) as any[];
for (const e of items) {
if (e?.role === "presentation" && e?._key) oldEdgeKeys.push(e._key);
}
} catch { /* ignore */ }
}
const replaceOps: BatchOperation[] = draft.nodes.map(n =>
wizardApi.ops.createView({ display_name: n.display_name, fields: draft.fields })
);
const res = await wizardApi.applyBatch(draft.flowKey, replaceOps, actor);
const newViewKeys = (res?.result?.ops || []).map((o: any) => o.key);
const wireOps: BatchOperation[] = [
...oldEdgeKeys.map((k): BatchOperation => ({ op: "delete_edge", edge_coll: "defines", key: k })),
...draft.nodes.map((n, i) => wizardApi.ops.presentationEdge(n._key, newViewKeys[i])),
];
if (wireOps.length) await wizardApi.applyBatch(draft.flowKey, wireOps, actor);
updateDraft({ nodes: draft.nodes.map((n, i) => ({ ...n, viewKey: newViewKeys[i] })) });
}
updateDraft({ step: "Validate" });
pushToast("ok", "Data requirements saved");
pushToast("ok", `Saved ${draft.fields.length} fields onto every step's form`);
} catch (err: any) {
pushToast("err", `Save failed: ${err.message}`);
} finally {
@@ -403,7 +442,7 @@ export default function Wizard() {
<div className="wizard-panel success-panel">
<div className="success-icon"><Check size={32} /></div>
<h3 className="panel-h">Process Published!</h3>
<p>Process definition <code>{publishedKey}</code> is now active in EA2.</p>
<p>Your process <strong>{draft.name || "Untitled"}</strong> is now active and ready to run.</p>
<div className="actions-row">
<button className="btn btn-secondary" onClick={() => { setDraft({ step: "Intake", name: "", description: "", nodes: [], edges: [], fields: [], rules: [] }); setPublishedKey(null); }}>Create Another</button>
<button
+32 -10
View File
@@ -1,12 +1,24 @@
// Global UI state for Mission Control.
import { create } from "zustand";
import { liveScenarios as snapshotLive } from "../data/live";
import { syntheticScenarios } from "../data/synthetic";
import { isDevArtefact } from "../lib/flowCuration";
const syntheticScenarios: any[] = [];
function curateScenarios(rows: ProcessScenario[]): ProcessScenario[] {
return rows.filter((s) => !isDevArtefact({ _key: s.defKey, display_name: s.defName, name: s.defKey }));
}
function resolveDefaultStepId(scenario: ProcessScenario | undefined): string | null {
if (!scenario) return null;
const ids = new Set(scenario.steps.map((s) => s.id));
if (scenario.defaultStepId && ids.has(scenario.defaultStepId)) return scenario.defaultStepId;
return scenario.steps[0]?.id ?? null;
}
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" | "chat" | "agent" | "hub-procurement" | "hub-hr" | "hub-it" | "geo-attendance" | "explainer";
export type SceneId = "landing" | "mission" | "history" | "studio" | "settings" | "login" | "sso-callback" | "chat" | "agent" | "hub-procurement" | "hub-hr" | "hub-it" | "geo-attendance" | "explainer" | "approvals" | "documents";
export type DataMode = "snapshot" | "live";
export type Theme = "dark" | "light";
@@ -82,9 +94,10 @@ interface AppState {
actor: Actor | null;
userEmail: string;
userDisplayName: string | null;
tenantId: string | null;
isAuthed: boolean;
setUserEmail: (e: string) => void;
loginAs: (email: string, password?: string) => Promise<void>;
loginAs: (email: string, password?: string, options?: { method?: "password" | "dev" }) => Promise<void>;
/** Live polling — lightweight tick that only refreshes the active
* scenario's work-items + headline runtime. Full refresh is manual. */
@@ -110,7 +123,7 @@ interface AppState {
startInstance: (defKey: string, businessSubject?: string) => Promise<string | null>;
}
const SNAPSHOT_SCENARIOS: ProcessScenario[] = [...snapshotLive, ...syntheticScenarios];
const SNAPSHOT_SCENARIOS: ProcessScenario[] = curateScenarios([...snapshotLive, ...syntheticScenarios]);
const initialScenario = SNAPSHOT_SCENARIOS[0];
let toastSeq = 0;
@@ -129,10 +142,12 @@ async function runLiveFetch(
set({
actor: { mode: "direct_user", user_id: ping.user_id },
userEmail: ping.user ?? get().userEmail,
tenantId: ping.tenant_id ?? get().tenantId,
});
}
const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi();
const merged = [...scenarios, ...syntheticScenarios];
const curatedLive = curateScenarios([...scenarios, ...syntheticScenarios]);
const merged = curatedLive.length > 0 ? curatedLive : SNAPSHOT_SCENARIOS;
const first = merged[0];
const currentId = get().scenarioId;
const currentStepId = get().selectedStepId;
@@ -145,13 +160,13 @@ async function runLiveFetch(
liveFetchedAt: Date.now(),
liveLoading: false,
scenarioId: nextScenario?.id ?? currentId,
selectedStepId: stepStillThere ? currentStepId : nextScenario?.defaultStepId ?? null,
selectedStepId: stepStillThere ? currentStepId : resolveDefaultStepId(nextScenario),
});
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} live processes`
: `Live mode · ${scenarios.length} live processes`,
);
} catch (e) {
set({ liveLoading: false, liveError: (e as Error).message, mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS });
@@ -243,19 +258,26 @@ export const useApp = create<AppState>((set, get) => {
actor: null,
userEmail: prefs.email ?? "dev@flow-master.ai",
userDisplayName: null,
tenantId: null,
isAuthed: typeof sessionStorage !== "undefined" && !!sessionStorage.getItem("fm.mc.token.v1"),
setUserEmail: (e) => { set({ userEmail: e }); persist(); },
loginAs: async (email, password) => {
loginAs: async (email, password, options) => {
api.clearToken();
api.config = { ...api.config, email };
set({ userEmail: email });
persist();
try {
await api.signIn(email, password);
if (options?.method === "dev") {
await api.devLogin(email);
} else {
if (!password) throw new Error("Password required");
await api.passwordLogin(email, password);
}
const me = await api.me();
set({
actor: { mode: "direct_user", user_id: me.user_id },
userDisplayName: me.display_name ?? null,
tenantId: me.tenant_id ?? null,
isAuthed: true,
});
get().pushToast("ok", `Signed in as ${me.email}`);