Moving a dashboard slider lit up an edge between two nodes that had done nothing. The canvas pulsed on the message's timestamp alone, and a message has no idea who published it — so it credited whichever node happened to be drawn as a producer. That was never only about dashboards. Two nodes producing one message pulsed both their edges whichever fired, and a message produced in another flow changed with nothing on screen to account for it at all. Values now carry their cause: a node, a dashboard widget, another flow, an agent or an API caller. An edge pulses only for the producer that actually published, and the edge inspector says where a value came from when it did not come from a node. What is not a node in this flow is now drawn as one — a label rather than a card, because a dashboard with twenty tiles would otherwise bury the logic the canvas exists to show. That covers cross-flow wiring too, which is the link in/out affordance that has been missing. They are never part of the document. They join at render, after everything that reads or writes the canvas nodes, so an autosave, an undo or a delete cannot reach them — with a Playwright test that drags a node and asserts the stored flow still holds exactly what it did. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
183 lines
6.3 KiB
Python
183 lines
6.3 KiB
Python
import asyncio
|
|
import contextlib
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
|
|
import sentry_sdk
|
|
from fastapi import FastAPI
|
|
from fastapi.responses import JSONResponse
|
|
from fastapi.routing import APIRoute
|
|
from starlette.middleware.cors import CORSMiddleware
|
|
|
|
from app.api.main import api_router
|
|
from app.api.routes.alerts import read_config as read_alerts_config
|
|
from app.core import security
|
|
from app.core.config import settings
|
|
from app.flow import logs
|
|
from app.flow.alerts import AlertManager
|
|
from app.flow.controller import FlowController
|
|
from app.flow.dashboards import DashboardStore
|
|
from app.flow.events import event_bus
|
|
from app.flow.executor import ExecutionService
|
|
from app.flow.nodes.http import close_shared_client
|
|
from app.flow.plugins import load_plugins
|
|
from app.flow.queue import MemoryWorkQueue, RedisWorkQueue, WorkQueue
|
|
from app.flow.secrets import init_secrets
|
|
from app.flow.state import MemoryState, RedisState, StateBackend
|
|
from app.flow.store import FlowStore
|
|
from app.flow.watchdog import LoopWatchdog
|
|
|
|
|
|
def custom_generate_unique_id(route: APIRoute) -> str:
|
|
return f"{route.tags[0]}-{route.name}"
|
|
|
|
|
|
if settings.SENTRY_DSN and settings.ENVIRONMENT != "local":
|
|
sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True)
|
|
|
|
|
|
def _state_backend() -> StateBackend:
|
|
if settings.REDIS_HOST:
|
|
return RedisState(host=settings.REDIS_HOST, port=settings.REDIS_PORT)
|
|
return MemoryState()
|
|
|
|
|
|
def _work_queue() -> WorkQueue:
|
|
"""Redis makes queued work survive the process; memory does not pretend to."""
|
|
if settings.REDIS_HOST:
|
|
return RedisWorkQueue(host=settings.REDIS_HOST, port=settings.REDIS_PORT)
|
|
return MemoryWorkQueue()
|
|
|
|
|
|
def _mcp_sessions() -> AbstractAsyncContextManager[None]:
|
|
"""The MCP session manager's run scope, or nothing when MCP is off."""
|
|
if not settings.MCP_ENABLED:
|
|
return contextlib.nullcontext()
|
|
from app.mcp.server import mcp as mcp_server
|
|
|
|
return mcp_server.session_manager.run()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
"""Start the flow engine alongside the API."""
|
|
event_bus.bind(asyncio.get_running_loop())
|
|
# Node code is user code, and `print` is how it says things.
|
|
logs.install()
|
|
init_secrets(settings.SECRETS_FILE, settings.SECRET_KEY)
|
|
# Connectors register their node types before any flow is built with them.
|
|
load_plugins()
|
|
|
|
alerts = AlertManager(event_bus, config=read_alerts_config())
|
|
execution = ExecutionService(
|
|
queue=_work_queue(),
|
|
max_workers=settings.FLOW_MAX_WORKERS,
|
|
events=event_bus,
|
|
)
|
|
controller = FlowController(
|
|
store=FlowStore(settings.FLOWS_DIR),
|
|
state=_state_backend(),
|
|
events=event_bus,
|
|
max_workers=settings.FLOW_MAX_WORKERS,
|
|
fastapi_app=app,
|
|
execution=execution,
|
|
alerts=alerts,
|
|
)
|
|
app.state.flow_controller = controller
|
|
dashboards = DashboardStore(controller.store)
|
|
app.state.dashboard_store = dashboards
|
|
controller.dashboards = dashboards
|
|
# Charts need a deeper series than the default; tell the engine
|
|
# before it starts recording.
|
|
controller.set_history_limits(dashboards.history_requirements())
|
|
watchdog = LoopWatchdog(event_bus)
|
|
app.state.watchdog = watchdog
|
|
watchdog_task = asyncio.create_task(watchdog.run(), name="loop-watchdog")
|
|
alerts_task = asyncio.create_task(alerts.run(), name="alert-manager")
|
|
await controller.start()
|
|
try:
|
|
# A mounted sub-app gets no lifespan of its own, so the MCP session
|
|
# manager is entered here; without it every /mcp request fails.
|
|
async with _mcp_sessions():
|
|
yield
|
|
finally:
|
|
watchdog_task.cancel()
|
|
alerts_task.cancel()
|
|
await controller.stop()
|
|
close_shared_client()
|
|
if settings.MCP_ENABLED:
|
|
from app.mcp.http import aclose
|
|
|
|
await aclose()
|
|
|
|
|
|
app = FastAPI(
|
|
title=settings.PROJECT_NAME,
|
|
openapi_url=f"{settings.API_V1_STR}/openapi.json",
|
|
generate_unique_id_function=custom_generate_unique_id,
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
# Set all CORS enabled origins
|
|
if settings.all_cors_origins:
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=settings.all_cors_origins,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
app.include_router(api_router, prefix=settings.API_V1_STR)
|
|
|
|
|
|
# Tagged because the operation-id builder reads the first tag; the route
|
|
# itself stays out of the schema.
|
|
@app.get(
|
|
"/.well-known/oauth-authorization-server",
|
|
include_in_schema=False,
|
|
tags=["oauth"],
|
|
)
|
|
def oauth_authorization_server() -> JSONResponse:
|
|
"""RFC 8414 metadata, so an agent can find its way in unaided.
|
|
|
|
The authorization endpoint is the dashboard rather than the API: approving
|
|
a client needs a signed-in human, and the browser session lives there.
|
|
"""
|
|
issuer = settings.oauth_issuer
|
|
return JSONResponse(
|
|
content={
|
|
"issuer": issuer,
|
|
"authorization_endpoint": (
|
|
f"{settings.FRONTEND_HOST.rstrip('/')}/oauth/authorize"
|
|
),
|
|
"token_endpoint": f"{issuer}{settings.API_V1_STR}/oauth/token",
|
|
"registration_endpoint": f"{issuer}{settings.API_V1_STR}/oauth/register",
|
|
"jwks_uri": f"{issuer}/.well-known/jwks.json",
|
|
"response_types_supported": ["code"],
|
|
"grant_types_supported": ["authorization_code", "refresh_token"],
|
|
"code_challenge_methods_supported": ["S256"],
|
|
"token_endpoint_auth_methods_supported": ["none"],
|
|
"scopes_supported": [security.MCP_SCOPE],
|
|
},
|
|
headers={"Cache-Control": "public, max-age=3600"},
|
|
)
|
|
|
|
|
|
@app.get("/.well-known/jwks.json", include_in_schema=False, tags=["oauth"])
|
|
def jwks() -> JSONResponse:
|
|
"""The public half of the MCP signing key."""
|
|
return JSONResponse(
|
|
content=security.public_jwks(),
|
|
headers={"Cache-Control": "public, max-age=3600"},
|
|
)
|
|
|
|
|
|
# Mounted last, and at the root: the SDK serves both /mcp and the protected
|
|
# resource metadata that has to sit beside it, so mounting under /mcp would put
|
|
# that metadata somewhere no client looks for it.
|
|
if settings.MCP_ENABLED:
|
|
from app.mcp.http import build_http_app
|
|
|
|
app.mount("/", build_http_app(app))
|