Add the connector contract, reusable nodes and per-port intervals

Connectors are the device-facing node class third parties write, so the
surface they build against is versioned and documented: ConnectorNode carries
a declared contract version, a polling loop that publishes only what changed
and reports health around it, and parameters whose credential fields are
marked x-secret so the editor offers the secrets store instead of a text box.
They are found through the fluksio.node_types entry point group, with the
package's own metadata as the manifest. docs/connectors/ has the contract and
the authoring guide; connector-skeleton/ is a working one to copy.

The controller no longer knows what any node type is: start, stop and
report_health are protocol methods on Node, and the built-ins were migrated to
them first, so the hooks a connector implements are the ones the engine has
been driving all along.

Marking a node reusable moves its source to _lib/ and points the node at it by
name. Other flows instantiate it with their own ports and settings, one fix
reaches all of them, and a shared source still in use cannot be deleted.

Ports gained an interval: an output publishes, and an input wakes its node, at
most every n seconds. State keeps the latest value, so only the delivery is
skipped, and pressing Run is never throttled.

Also fixes autosave sending no version on its first save of a session, which
made every flow saved more than once conflict with itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Melvin Strobl
2026-08-15 23:57:44 +02:00
co-authored by Claude Fable 5
parent 7344eac262
commit 3724b68f23
22 changed files with 1541 additions and 62 deletions
@@ -1,3 +1,4 @@
import { useQuery } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { useEffect } from "react"
@@ -10,6 +11,7 @@ import {
CommandItem,
CommandList,
} from "@/components/ui/command"
import { libraryQueryOptions } from "./queries"
/**
* ⌘K: add a node, jump to another flow, or run the current one, without
@@ -21,6 +23,7 @@ export function CommandPalette({
nodeTypes,
flows,
onAddNode,
onAddSharedNode,
onRun,
}: {
open: boolean
@@ -28,9 +31,14 @@ export function CommandPalette({
nodeTypes: NodeTypeInfo[]
flows: FlowSummary[]
onAddNode: (type: string) => void
onAddSharedNode: (libName: string) => void
onRun: () => void
}) {
const navigate = useNavigate()
const { data: libraryData } = useQuery({
...libraryQueryOptions(),
enabled: open,
})
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
@@ -48,6 +56,8 @@ export function CommandPalette({
// dialog outright is deterministic; the palette does not need to fade out.
if (!open) return null
const library = libraryData ?? []
const close = (action: () => void) => {
onOpenChange(false)
action()
@@ -81,6 +91,28 @@ export function CommandPalette({
))}
</CommandGroup>
{library.length > 0 ? (
<CommandGroup heading="Reusable nodes">
{library.map((entry) => (
<CommandItem
key={entry.name}
value={`shared ${entry.name}`}
onSelect={() => close(() => onAddSharedNode(entry.name))}
>
<span className="flex flex-col">
<span className="font-mono">{entry.name}</span>
<span className="text-xs text-muted-foreground">
{(entry.used_by ?? []).length === 1
? "Used once"
: `Used ${(entry.used_by ?? []).length} times`}
, code shared with every flow using it
</span>
</span>
</CommandItem>
))}
</CommandGroup>
) : null}
{flows.length > 0 ? (
<CommandGroup heading="Flows">
{flows.map((flow) => (
+9 -2
View File
@@ -453,8 +453,8 @@ function FlowEditorInner({
})
const addNode = useCallback(
(type: string) => {
const id = uniqueNodeId(definitions, type)
(type: string, sourceRef?: string) => {
const id = uniqueNodeId(definitions, sourceRef ?? type)
// Drop it where the user is looking, but never on top of another node.
const position = freePosition(
definitions,
@@ -470,6 +470,9 @@ function FlowEditorInner({
params: {},
requires: [],
provides: [],
// A shared node brings the code; the ports and settings are this
// flow's own.
...(sourceRef ? { source_ref: sourceRef } : {}),
}
const nextDefinitions = [...definitions, node]
const nextCanvas = [
@@ -809,6 +812,9 @@ function FlowEditorInner({
onSaveSource={(code) => {
if (selected) sourceMutation.mutate({ nodeId: selected.id, code })
}}
// Sharing rewrites the stored document, so the canvas takes the
// server's copy rather than keeping its own.
onShared={onReload}
onClose={() => {
flush()
setEditorExpanded(false)
@@ -830,6 +836,7 @@ function FlowEditorInner({
nodeTypes={nodeTypeInfo ?? []}
flows={flows.data}
onAddNode={addNode}
onAddSharedNode={(libName) => addNode("python", libName)}
onRun={() => runMutation.mutate()}
/>
+182 -4
View File
@@ -1,8 +1,14 @@
import { useQuery } from "@tanstack/react-query"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Maximize2, Minimize2, X } from "lucide-react"
import { lazy, Suspense, useEffect, useRef, useState } from "react"
import type { DType, MessageSpec, NodeDef_Input, NodeTypeInfo } from "@/client"
import {
type DType,
FlowsService,
type MessageSpec,
type NodeDef_Input,
type NodeTypeInfo,
} from "@/client"
import { Button } from "@/components/ui/button"
import {
Command,
@@ -22,15 +28,24 @@ import {
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import useCustomToast from "@/hooks/useCustomToast"
import { cn } from "@/lib/utils"
import { MessageSparkline } from "./MessageSparkline"
import { nodeSourceQueryOptions } from "./queries"
import {
flowKeys,
libraryQueryOptions,
nodeSourceQueryOptions,
secretsQueryOptions,
} from "./queries"
import { PANEL_SECTION, SidePanel } from "./SidePanel"
const NodeEditor = lazy(() => import("./NodeEditor"))
const DTYPES: DType[] = ["float", "int", "str", "bool", "json"]
/** Radix selects cannot hold an empty value, so "no secret" needs a name. */
const NO_SECRET = "__none__"
const SECTION = PANEL_SECTION
/**
@@ -208,6 +223,19 @@ function PortList({
))}
</SelectContent>
</Select>
<Input
type="number"
min={0}
step="any"
value={spec.interval ? String(spec.interval) : ""}
placeholder="∞"
aria-label="Deliver at most every n seconds"
title="Deliver at most every n seconds; empty is every time"
className="h-8 w-16 text-sm"
onChange={(event) =>
update(index, { interval: Number(event.target.value) || 0 })
}
/>
<Button
variant="ghost"
size="icon-sm"
@@ -235,6 +263,8 @@ function ParamsForm({
params: Record<string, unknown>
onChange: (next: Record<string, unknown>) => void
}) {
const { data: secretList } = useQuery(secretsQueryOptions())
const secrets = secretList?.data ?? []
const properties = (schema?.properties ?? {}) as Record<
string,
{
@@ -242,6 +272,7 @@ function ParamsForm({
title?: string
description?: string
default?: unknown
"x-secret"?: boolean
}
>
const entries = Object.entries(properties)
@@ -272,6 +303,41 @@ function ParamsForm({
)
}
// A credential is stored once and referenced, so it never ends up in
// flow.json where the whole team can read it.
if (property["x-secret"]) {
const reference = (params[key] ?? null) as { $secret?: string } | null
return (
<div key={key} className="grid gap-1.5">
<Label className="text-sm font-normal">{label}</Label>
<Select
value={reference?.$secret ?? ""}
onValueChange={(name) =>
set(key, name === NO_SECRET ? null : { $secret: name })
}
>
<SelectTrigger className="!h-8 text-sm" aria-label={label}>
<SelectValue placeholder="Pick a stored secret" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_SECRET}>None</SelectItem>
{secrets.map((secret) => (
<SelectItem key={secret} value={secret}>
{secret}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{secrets.length
? property.description ||
"Stored in the secrets store, never in the flow file."
: "No secrets stored yet."}
</p>
</div>
)
}
if (property.type === "object" || property.type === "array") {
return null
}
@@ -307,6 +373,108 @@ function ParamsForm({
)
}
/**
* Sharing a node moves its code to the library, where other flows can point at
* it. Each flow keeps its own ports and settings; only the code is common, so
* one fix reaches all of them.
*/
function SharingSection({
flow,
node,
onShared,
}: {
flow: string
node: NodeDef_Input
onShared: () => void
}) {
const queryClient = useQueryClient()
const { showErrorToast } = useCustomToast()
const { data: library } = useQuery(libraryQueryOptions())
const [name, setName] = useState("")
const shared = node.source_ref
const usages = library?.find((entry) => entry.name === shared)?.used_by ?? []
const done = () => {
queryClient.invalidateQueries({ queryKey: flowKeys.library })
queryClient.invalidateQueries({ queryKey: flowKeys.detail(flow) })
onShared()
}
const share = useMutation({
mutationFn: (libName: string) =>
FlowsService.shareNode({
name: flow,
nodeId: node.id,
requestBody: { lib_name: libName },
}),
onSuccess: done,
onError: () =>
showErrorToast("That name is taken, or is not a valid name."),
})
const unshare = useMutation({
mutationFn: () => FlowsService.unshareNode({ name: flow, nodeId: node.id }),
onSuccess: done,
onError: () => showErrorToast("The node could not be unshared."),
})
if (shared) {
return (
<div className="grid gap-2">
<span className={SECTION}>Shared</span>
<p className="text-sm text-muted-foreground">
Runs <span className="font-mono">{shared}</span> from the library
{usages.length > 1
? `, along with ${usages.length - 1} other node${
usages.length === 2 ? "" : "s"
}`
: ""}
. Editing the code here changes it everywhere.
</p>
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
disabled={unshare.isPending}
onClick={() => unshare.mutate()}
data-testid="unshare-node"
>
Keep a private copy
</Button>
</div>
)
}
return (
<div className="grid gap-2">
<span className={SECTION}>Reuse</span>
<p className="text-sm text-muted-foreground">
Move this node's code to the library so other flows can run it too.
</p>
<div className="flex items-center gap-1.5">
<Input
value={name}
placeholder="read_temperature"
aria-label="Shared name"
autoComplete="off"
className="h-8 flex-1 font-mono text-sm"
onChange={(event) => setName(event.target.value)}
/>
<Button
size="sm"
className="h-8"
disabled={!name || share.isPending}
onClick={() => share.mutate(name)}
data-testid="share-node"
>
Share
</Button>
</div>
</div>
)
}
function PanelBody({
node,
flow,
@@ -316,6 +484,7 @@ function PanelBody({
onChange,
onRenameMessage,
onSaveSource,
onShared,
onToggleExpand,
}: {
node: NodeDef_Input
@@ -326,6 +495,7 @@ function PanelBody({
onChange: (next: NodeDef_Input) => void
onRenameMessage: (previous: string, next: string) => void
onSaveSource: (code: string) => void
onShared: () => void
onToggleExpand: () => void
}) {
const hasSource = nodeType?.has_source ?? node.type === "python"
@@ -387,12 +557,17 @@ function PanelBody({
params={node.params ?? {}}
onChange={(params) => onChange({ ...node, params })}
/>
{hasSource ? (
<SharingSection flow={flow} node={node} onShared={onShared} />
) : null}
</div>
{hasSource ? (
<div className="flex min-h-[280px] flex-1 flex-col gap-2 px-4 pb-4">
<div className="flex items-center justify-between">
<span className={SECTION}>Code</span>
<span className={SECTION}>
{node.source_ref ? `Shared code · ${node.source_ref}` : "Code"}
</span>
<Button
variant="ghost"
size="icon-sm"
@@ -440,6 +615,7 @@ export function NodePanel({
onChange,
onRenameMessage,
onSaveSource,
onShared,
onToggleExpand,
onClose,
onDelete,
@@ -452,6 +628,7 @@ export function NodePanel({
onChange: (next: NodeDef_Input) => void
onRenameMessage: (previous: string, next: string) => void
onSaveSource: (code: string) => void
onShared: () => void
onToggleExpand: () => void
onClose: () => void
onDelete: () => void
@@ -506,6 +683,7 @@ export function NodePanel({
onChange={onChange}
onRenameMessage={onRenameMessage}
onSaveSource={onSaveSource}
onShared={onShared}
onToggleExpand={onToggleExpand}
/>
) : null}
+27 -2
View File
@@ -5,7 +5,12 @@ import {
} from "@tanstack/react-query"
import { useCallback, useEffect, useRef, useState } from "react"
import { ApiError, type FlowDef_Input, FlowsService } from "@/client"
import {
ApiError,
type FlowDef_Input,
FlowsService,
SecretsService,
} from "@/client"
export const flowKeys = {
all: ["flows"] as const,
@@ -15,6 +20,7 @@ export const flowKeys = {
history: (name: string, message: string) =>
["flows", name, "history", message] as const,
nodeTypes: ["flows", "node-types"] as const,
library: ["flows", "library"] as const,
}
export const flowsQueryOptions = () => ({
@@ -27,6 +33,18 @@ export const flowQueryOptions = (name: string) => ({
queryFn: () => FlowsService.readFlow({ name }),
})
/** Names of the stored secrets, for pointing a credential parameter at one. */
export const secretsQueryOptions = () => ({
queryKey: ["secrets"] as const,
queryFn: () => SecretsService.readSecrets(),
})
/** Node sources shared across flows, with the nodes using each. */
export const libraryQueryOptions = () => ({
queryKey: flowKeys.library,
queryFn: () => FlowsService.readLibrary(),
})
export const nodeTypesQueryOptions = () => ({
queryKey: flowKeys.nodeTypes,
queryFn: () => FlowsService.readNodeTypes(),
@@ -107,7 +125,14 @@ export function useAutosave(name: string): {
mutationFn: (definition: FlowDef_Input) =>
FlowsService.saveFlow({
name,
requestBody: { ...definition, version: version.current ?? undefined },
// The ref holds the freshest version this client has seen; before the
// first save that is the one the document was loaded with. Sending
// `undefined` would drop the field and let the server read the default,
// which conflicts with every flow saved more than once.
requestBody: {
...definition,
version: version.current ?? definition.version ?? 1,
},
}),
onMutate: (definition) => {
inFlight.current = true