flow: structured dtypes, and the widgets that read them

A series, record or list message declares its shape instead of riding
DType.JSON, so a widget binds a shape rather than some JSON and a wrong
binding is refused before anything runs. A list declares its item type,
which is what keeps list[float] expressible for a pipeline.

On top of that: an agenda over a list, a notification over a record, and
a dashboard alert channel that publishes engine faults as one — so a
panel can show what went wrong without a flow wiring it by hand.

Also: only None means a node published nothing, a falsy value of the
wrong shape is now the named error it always should have been; and the
gauge's readout says its size is viewBox geometry rather than type scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 15:06:45 +02:00
co-authored by Claude Opus 5
parent 18837e8880
commit 413501c6ce
16 changed files with 751 additions and 43 deletions
+30 -4
View File
@@ -298,7 +298,7 @@ export const ChannelSchema = {
},
kind: {
type: 'string',
enum: ['ntfy', 'smtp', 'webhook'],
enum: ['ntfy', 'smtp', 'webhook', 'dashboard'],
title: 'Kind'
},
enabled: {
@@ -320,10 +320,15 @@ export const ChannelSchema = {
export const DTypeSchema = {
type: 'string',
enum: ['float', 'int', 'str', 'bool', 'json'],
enum: ['float', 'int', 'str', 'bool', 'json', 'series', 'record', 'list'],
title: 'DType',
description: `Serializable payload types.
The scalars carry what a single reading can say. The three structured ones
are declared shapes rather than "some JSON": a widget or a downstream node
knows what it is getting before anything runs, which is what lets the
dashboard picker offer a message and refuse a wrong binding.
Binary payloads (tensors, images) will arrive later as explicitly declared
codec fields; until then everything on the wire is JSON.`
} as const;
@@ -1151,6 +1156,16 @@ export const MessageSpecSchema = {
'$ref': '#/components/schemas/DType',
default: 'float'
},
item: {
anyOf: [
{
'$ref': '#/components/schemas/DType'
},
{
type: 'null'
}
]
},
interval: {
type: 'number',
minimum: 0,
@@ -1172,6 +1187,10 @@ export const MessageSpecSchema = {
:param port: The identifier the node function sees. Defaults to the last
segment of \`\`name\`\`, so unqualified flows read naturally.
:param dtype: Payload type, validated on every message that passes through.
:param item: The type of each item of a \`\`list\`\` port, ignored otherwise.
Unset means \`\`record\`\`, which is what the agenda and forecast widgets
read; \`\`float\`\` is the numeric list a pipeline passes around. A list of
lists, or of series, is refused — one declared level is the point.
:param interval: Deliver at most every this many seconds; 0 is every time.
On an output it holds back publishing, on an input it holds back waking
the node. The value is never lost — state keeps the latest — only the
@@ -2497,7 +2516,7 @@ export const WidgetDefSchema = {
},
type: {
type: 'string',
enum: ['stat', 'gauge', 'chart', 'markdown', 'button', 'switch', 'slider', 'input', 'dropdown'],
enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'button', 'switch', 'slider', 'input', 'dropdown'],
title: 'Type'
},
title: {
@@ -2525,7 +2544,14 @@ export const WidgetDefSchema = {
\`\`config\`\` is per type — a chart names its series, a button names the
message it publishes — and is validated against the type below rather than
by a schema per class, because the whole set is small and closed.`
by a schema per class, because the whole set is small and closed.
A chart comes in two kinds. The default reads what the engine kept for a
message. One with \`\`source: "query"\`\` asks instead, and its config is
\`\`{source, request, request_dtype: "record", message, dtype: "series",
refresh_s, range_s}\`\`: it publishes \`\`{range_s, interval_s}\`\` to
\`\`request\`\` exactly as a slider publishes a value, and draws the \`\`series\`\`
a flow answers with on \`\`message\`\`.`
} as const;
export const app__api__routes__dashboards__PublishRequestSchema = {