diff --git a/backend/fluksio/api/routes/observability.py b/backend/fluksio/api/routes/observability.py index 409f9c4..a0e3aa8 100644 --- a/backend/fluksio/api/routes/observability.py +++ b/backend/fluksio/api/routes/observability.py @@ -20,7 +20,8 @@ from sqlmodel import col, select from fluksio.api.deps import FlowControllerDep, SessionDep, get_current_user from fluksio.api.routes.runs import elapsed_ms from fluksio.core.config import settings -from fluksio.flow.controller import ADVISORY_ISSUES, NodeStatus +from fluksio.flow.controller import NodeStatus +from fluksio.flow.pipeline import ADVISORY_ISSUES from fluksio.models import EngineEvent, FlowRun, MetricBucket router = APIRouter( diff --git a/backend/fluksio/flow/controller.py b/backend/fluksio/flow/controller.py index 2e35944..4883eb9 100644 --- a/backend/fluksio/flow/controller.py +++ b/backend/fluksio/flow/controller.py @@ -54,6 +54,7 @@ from fluksio.flow.nodes import ( TriggerNode, ) from fluksio.flow.pipeline import ( + ADVISORY_ISSUES, NodeOutcome, Pipeline, RunCacheLookup, @@ -81,10 +82,6 @@ logger = logging.getLogger(__name__) HOOK_PREFIX = "/hooks" -# Validation codes that are worth saying but do not stop a flow running, so -# neither the brain graph nor the health summary treats them as a fault. -ADVISORY_ISSUES = frozenset({"unauthenticated_hook"}) - # How long a node gets to close what it opened before the rebuild moves on. # A node's `stop` talks to whatever it connected to, and a broker that has gone # away can leave it waiting for an acknowledgement that never arrives — which diff --git a/backend/fluksio/flow/pipeline.py b/backend/fluksio/flow/pipeline.py index 534c61b..69cb639 100644 --- a/backend/fluksio/flow/pipeline.py +++ b/backend/fluksio/flow/pipeline.py @@ -25,7 +25,7 @@ from concurrent.futures import Future, ThreadPoolExecutor, wait from contextlib import contextmanager from typing import Any, Literal, Protocol -from pydantic import BaseModel +from pydantic import BaseModel, computed_field from fluksio.flow import logs from fluksio.flow.artifacts import is_reference @@ -41,9 +41,15 @@ logger = logging.getLogger(__name__) #: manual run shows up in the history — but it is no one's idempotency key. MANUAL_RUN_PREFIX = "manual-" +# Validation codes that are worth saying but do not stop a flow running, so +# neither the brain graph nor the health summary treats them as a fault. +# Lives here rather than beside the controller so `ValidationIssue` can carry +# the distinction itself, and every reader gets it for free. +ADVISORY_ISSUES = frozenset({"unauthenticated_hook"}) + class ValidationIssue(BaseModel): - """A problem that keeps a flow from running correctly.""" + """Something wrong with a flow — a fault, or merely advisory.""" code: Literal[ "cycle", @@ -60,6 +66,12 @@ class ValidationIssue(BaseModel): port: str | None = None message_name: str | None = None + @computed_field # type: ignore[prop-decorator] + @property + def advisory(self) -> bool: + """Worth saying, but not a fault — the UI says so in a softer tone.""" + return self.code in ADVISORY_ISSUES + class ValueSource(BaseModel): """Who caused a message to take its current value. diff --git a/backend/tests/flow/test_pipeline.py b/backend/tests/flow/test_pipeline.py index cec2058..65cb81b 100644 --- a/backend/tests/flow/test_pipeline.py +++ b/backend/tests/flow/test_pipeline.py @@ -4,7 +4,7 @@ from fluksio.flow.controller import _declared_inputs from fluksio.flow.events import EventBus from fluksio.flow.messages import DType, MessageSpec from fluksio.flow.nodes import Node -from fluksio.flow.pipeline import Pipeline +from fluksio.flow.pipeline import Pipeline, ValidationIssue from fluksio.flow.schemas import FlowDef, FlowInput @@ -197,3 +197,12 @@ def test_values_carry_timestamps(): values = pipeline.values("f") assert values["f.out"]["value"] == 1.0 assert values["f.out"]["ts"] > 0 + + +def test_only_advisory_codes_are_flagged_advisory(): + """The UI reads this to keep an advisory out of the fault tone.""" + hook = ValidationIssue(code="unauthenticated_hook", message="open") + cycle = ValidationIssue(code="cycle", message="loop") + + assert hook.advisory is True + assert cycle.advisory is False diff --git a/frontend/biome.json b/frontend/biome.json index 5058819..351123a 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -7,6 +7,7 @@ "!**/dist/**/*", "!**/node_modules/**/*", "!**/src/routeTree.gen.ts", + "!**/openapi.json", "!**/src/client/**/*", "!**/src/components/ui/**/*", "!**/playwright-report", diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index fa2fcaa..6e7449b 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -3185,12 +3185,18 @@ export const ValidationIssueSchema = { } ], title: 'Message Name' + }, + advisory: { + type: 'boolean', + title: 'Advisory', + description: 'Worth saying, but not a fault — the UI says so in a softer tone.', + readOnly: true } }, type: 'object', - required: ['code', 'message'], + required: ['code', 'message', 'advisory'], title: 'ValidationIssue', - description: 'A problem that keeps a flow from running correctly.' + description: 'Something wrong with a flow — a fault, or merely advisory.' } as const; export const ValidationResultSchema = { diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 92221ac..694d840 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -1057,7 +1057,7 @@ export type ValidationError = { }; /** - * A problem that keeps a flow from running correctly. + * Something wrong with a flow — a fault, or merely advisory. */ export type ValidationIssue = { code: 'cycle' | 'unconnected_input' | 'missing_initial_value' | 'node_error' | 'unauthenticated_hook' | 'self_loop_needs_initial'; @@ -1067,6 +1067,10 @@ export type ValidationIssue = { node?: (string | null); port?: (string | null); message_name?: (string | null); + /** + * Worth saying, but not a fault — the UI says so in a softer tone. + */ + readonly advisory: boolean; }; export type code = 'cycle' | 'unconnected_input' | 'missing_initial_value' | 'node_error' | 'unauthenticated_hook' | 'self_loop_needs_initial'; diff --git a/frontend/src/components/Flow/FlowDock.tsx b/frontend/src/components/Flow/FlowDock.tsx index c8751bd..d0d06dd 100644 --- a/frontend/src/components/Flow/FlowDock.tsx +++ b/frontend/src/components/Flow/FlowDock.tsx @@ -110,6 +110,10 @@ export function FlowDock({ }) { const { fitView } = useReactFlow() const connected = useLiveConnection() + // The engine marks the issues it does not count against a flow. They are + // still worth saying, so they stay in the list — but in the muted tone, and + // without turning the summary red, since nothing here is actually broken. + const faults = issues.filter((issue) => !issue.advisory) return ( 0 + ? "text-destructive" + : "text-muted-foreground", + )} data-testid="validation-summary" > @@ -191,7 +200,10 @@ export function FlowDock({