Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c46c03b3b | ||
|
|
739245b3d7 | ||
|
|
2150636fd0 | ||
|
|
e520f39647 | ||
|
|
c9deaeb5c2 | ||
|
|
5072e65ec3 | ||
|
|
da779ed9b6 | ||
|
|
93de195b0a | ||
|
|
071166cae1 | ||
|
|
224c259bfd | ||
|
|
e52c4a7d53 | ||
|
|
973aa9c15b | ||
|
|
6a665da21d | ||
|
|
492defa08e | ||
|
|
05be9ca8dc | ||
|
|
bfac76dcde | ||
|
|
cc037a149e | ||
|
|
810d011b80 | ||
|
|
0266c77875 | ||
|
|
15ab8ededa | ||
|
|
9884ddf4ac | ||
|
|
221cccf96b | ||
|
|
338864a8e1 | ||
|
|
b951724c5f | ||
|
|
c779919453 | ||
|
|
83ebc28a81 | ||
|
|
e70944b6fa | ||
|
|
11e8442a93 | ||
|
|
42884667f6 | ||
|
|
b5d1ea54b0 | ||
|
|
07eb93f67c | ||
|
|
7aaba5fb47 | ||
|
|
8f35f7b88b | ||
|
|
1cfd787179 | ||
|
|
5acdde3e27 | ||
|
|
b507ffe7e3 | ||
|
|
456243c31c | ||
|
|
c222aa2511 | ||
|
|
665dcf488e | ||
|
|
8933148719 |
@@ -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.
@@ -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"},
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi==0.115.4
|
||||
uvicorn[standard]==0.32.0
|
||||
httpx==0.27.2
|
||||
+12
-1
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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
@@ -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 ===`);
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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.");
|
||||
@@ -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 || "?"}`);
|
||||
@@ -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
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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, "");
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user