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) => (