Advisory issues read as advice rather than failure
Docs / docs (push) Successful in 21s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m37s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 35s
Playwright Tests / merge-reports (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Test Backend / test-backend (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s

`ADVISORY_ISSUES` moves next to `ValidationIssue` in pipeline.py, and the
model derives an `advisory` flag from its own code, so the distinction the
engine already made ships to the client instead of being re-guessed there.

The dock keeps its summary in `--destructive` only when a real fault is
among the issues and paints an advisory row `--muted-foreground`; the
canvas leaves advisories off a node's dot and border entirely, since node
status has three colours and no warning tier.

biome checks the generated `openapi.json`, which nothing formats since the
SDK script dropped its format pass — ignore it like the other generated
files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Gf7WaExcJ9bs3kfJXB3nK
This commit is contained in:
2026-08-25 21:46:40 +02:00
co-authored by Claude Opus 5
parent 4a2337f4de
commit 3af5342a2b
9 changed files with 59 additions and 14 deletions
+2 -1
View File
@@ -20,7 +20,8 @@ from sqlmodel import col, select
from fluksio.api.deps import FlowControllerDep, SessionDep, get_current_user from fluksio.api.deps import FlowControllerDep, SessionDep, get_current_user
from fluksio.api.routes.runs import elapsed_ms from fluksio.api.routes.runs import elapsed_ms
from fluksio.core.config import settings 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 from fluksio.models import EngineEvent, FlowRun, MetricBucket
router = APIRouter( router = APIRouter(
+1 -4
View File
@@ -54,6 +54,7 @@ from fluksio.flow.nodes import (
TriggerNode, TriggerNode,
) )
from fluksio.flow.pipeline import ( from fluksio.flow.pipeline import (
ADVISORY_ISSUES,
NodeOutcome, NodeOutcome,
Pipeline, Pipeline,
RunCacheLookup, RunCacheLookup,
@@ -81,10 +82,6 @@ logger = logging.getLogger(__name__)
HOOK_PREFIX = "/hooks" 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. # 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 # 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 # away can leave it waiting for an acknowledgement that never arrives — which
+14 -2
View File
@@ -25,7 +25,7 @@ from concurrent.futures import Future, ThreadPoolExecutor, wait
from contextlib import contextmanager from contextlib import contextmanager
from typing import Any, Literal, Protocol from typing import Any, Literal, Protocol
from pydantic import BaseModel from pydantic import BaseModel, computed_field
from fluksio.flow import logs from fluksio.flow import logs
from fluksio.flow.artifacts import is_reference 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 shows up in the history — but it is no one's idempotency key.
MANUAL_RUN_PREFIX = "manual-" 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): class ValidationIssue(BaseModel):
"""A problem that keeps a flow from running correctly.""" """Something wrong with a flow — a fault, or merely advisory."""
code: Literal[ code: Literal[
"cycle", "cycle",
@@ -60,6 +66,12 @@ class ValidationIssue(BaseModel):
port: str | None = None port: str | None = None
message_name: 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): class ValueSource(BaseModel):
"""Who caused a message to take its current value. """Who caused a message to take its current value.
+10 -1
View File
@@ -4,7 +4,7 @@ from fluksio.flow.controller import _declared_inputs
from fluksio.flow.events import EventBus from fluksio.flow.events import EventBus
from fluksio.flow.messages import DType, MessageSpec from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.nodes import Node 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 from fluksio.flow.schemas import FlowDef, FlowInput
@@ -197,3 +197,12 @@ def test_values_carry_timestamps():
values = pipeline.values("f") values = pipeline.values("f")
assert values["f.out"]["value"] == 1.0 assert values["f.out"]["value"] == 1.0
assert values["f.out"]["ts"] > 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
+1
View File
@@ -7,6 +7,7 @@
"!**/dist/**/*", "!**/dist/**/*",
"!**/node_modules/**/*", "!**/node_modules/**/*",
"!**/src/routeTree.gen.ts", "!**/src/routeTree.gen.ts",
"!**/openapi.json",
"!**/src/client/**/*", "!**/src/client/**/*",
"!**/src/components/ui/**/*", "!**/src/components/ui/**/*",
"!**/playwright-report", "!**/playwright-report",
+8 -2
View File
@@ -3185,12 +3185,18 @@ export const ValidationIssueSchema = {
} }
], ],
title: 'Message Name' 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', type: 'object',
required: ['code', 'message'], required: ['code', 'message', 'advisory'],
title: 'ValidationIssue', 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; } as const;
export const ValidationResultSchema = { export const ValidationResultSchema = {
+5 -1
View File
@@ -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 = { export type ValidationIssue = {
code: 'cycle' | 'unconnected_input' | 'missing_initial_value' | 'node_error' | 'unauthenticated_hook' | 'self_loop_needs_initial'; 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); node?: (string | null);
port?: (string | null); port?: (string | null);
message_name?: (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'; export type code = 'cycle' | 'unconnected_input' | 'missing_initial_value' | 'node_error' | 'unauthenticated_hook' | 'self_loop_needs_initial';
+14 -2
View File
@@ -110,6 +110,10 @@ export function FlowDock({
}) { }) {
const { fitView } = useReactFlow() const { fitView } = useReactFlow()
const connected = useLiveConnection() 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 ( return (
<motion.div <motion.div
@@ -175,7 +179,12 @@ export function FlowDock({
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-11 gap-1.5 text-destructive md:h-8" className={cn(
"h-11 gap-1.5 md:h-8",
faults.length > 0
? "text-destructive"
: "text-muted-foreground",
)}
data-testid="validation-summary" data-testid="validation-summary"
> >
<AlertCircle className="size-4" /> <AlertCircle className="size-4" />
@@ -191,7 +200,10 @@ export function FlowDock({
<li key={`${issue.code}-${issue.node}-${issue.message_name}`}> <li key={`${issue.code}-${issue.node}-${issue.message_name}`}>
<button <button
type="button" type="button"
className="w-full rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/50 disabled:cursor-default disabled:hover:bg-transparent" className={cn(
"w-full rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/50 disabled:cursor-default disabled:hover:bg-transparent",
issue.advisory && "text-muted-foreground",
)}
onClick={() => issue.node && onFocusNode(issue.node)} onClick={() => issue.node && onFocusNode(issue.node)}
disabled={!issue.node} disabled={!issue.node}
> >
+4 -1
View File
@@ -336,7 +336,10 @@ function FlowEditorInner({
const issuesByNode = useMemo(() => { const issuesByNode = useMemo(() => {
const map = new Map<string, string[]>() const map = new Map<string, string[]>()
for (const issue of issues) { for (const issue of issues) {
if (!issue.node) continue // A node's dot and border are its status channel, and status has no
// warning tier — an advisory is not a fault, so it says its piece in the
// dock rather than painting the node red.
if (!issue.node || issue.advisory) continue
const list = map.get(issue.node) ?? [] const list = map.get(issue.node) ?? []
list.push(issue.message) list.push(issue.message)
map.set(issue.node, list) map.set(issue.node, list)