Header of process and node ports follow each other both ways: a panel edit rewrites only the def line of edited code, a code save reads the header back into input ports and settings (draft and response); Space types in the node editor again

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
This commit is contained in:
2026-10-15 18:19:19 +02:00
co-authored by Claude Opus 5.5
parent 92c562a7d5
commit 762e7fb1fe
15 changed files with 645 additions and 48 deletions
+26 -4
View File
@@ -273,6 +273,7 @@ function FlowEditorInner({
flush,
conflict,
resolveConflict,
adopt,
mutation: saving,
} = useAutosave(flowName)
const publish = usePublish(flowName)
@@ -855,13 +856,34 @@ function FlowEditorInner({
})
const sourceMutation = useMutation({
mutationFn: ({ nodeId, code }: { nodeId: string; code: string }) =>
FlowsService.saveNodeSource({
mutationFn: async ({ nodeId, code }: { nodeId: string; code: string }) => {
// The server reads the ports off the header and compares them with its
// draft, so the ports on the canvas have to be there first.
await flush()
return FlowsService.saveNodeSource({
name: flowName,
nodeId,
requestBody: { code },
}),
onSuccess: () => {
})
},
onSuccess: (saved) => {
const followed = saved.node
if (followed && saved.version) {
// The header changed the ports in the draft: take them, and the
// version they moved it to, so the next save is not a conflict.
adopt(saved.version)
commit(
(latest.current.nodes ?? []).map((node) =>
node.id === followed.id
? {
...node,
requires: followed.requires,
params: followed.params,
}
: node,
),
)
}
queryClient.invalidateQueries({ queryKey: ["flows", flowName] })
},
})
+24 -34
View File
@@ -49,6 +49,13 @@ import useCustomToast from "@/hooks/useCustomToast"
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
import { cn } from "@/lib/utils"
import { DtypeValue } from "./FlowBoundary"
import {
IDENTIFIER,
portName,
processArgs,
RESERVED_PARAMS,
withHeader,
} from "./header"
import { JsonField } from "./JsonField"
import { MessageSparkline } from "./MessageSparkline"
import {
@@ -92,12 +99,6 @@ const NO_SECRET = "__none__"
const SECTION = PANEL_SECTION
/** Settings the engine reads itself, so they are not the author's to name. */
const RESERVED_PARAMS = new Set(["synchronous"])
/** A setting reaches the function by name, so the name has to be one. */
const IDENTIFIER = /^[A-Za-z_]\w*$/
/** Sentinels for the resources dropdown, which has two options that are not sizes. */
const NO_RESOURCES = "__shared__"
const CUSTOM_RESOURCES = "__custom__"
@@ -582,6 +583,8 @@ function FreeParamsForm({
// remount its row and take the half-typed value with it.
<div key={`param-${index}`} className="flex items-center gap-1.5">
<Input
// Its own key, so a name the code's header changed shows up.
key={key}
defaultValue={key}
placeholder="name"
aria-label="Setting name"
@@ -1364,10 +1367,6 @@ function StoredStateSection({ nodeType }: { nodeType: string }) {
)
}
/** The name the node function sees; the engine derives it the same way. */
const portName = (spec: MessageSpec) =>
spec.port || (spec.name ?? "").split(".").pop() || ""
/**
* Whether a port was named by hand rather than after its message. Nothing
* stores that: a port whose name is not the one its message gives it is one.
@@ -1376,25 +1375,11 @@ const ownsName = (spec: MessageSpec) =>
Boolean(spec.port) && spec.port !== portName({ name: spec.name })
/**
* A `process` that takes this node's inputs and settings, and returns its
* outputs.
*
* Both arrive by name, so both are arguments — the ports first, in the order
* they are declared, then the settings.
* A `process` that takes this node's inputs and settings (`processArgs`), and
* returns its outputs.
*/
function scaffoldFor(node: NodeDef_Input): string {
const args = [
...new Set(
[
...(node.requires ?? []).map(portName),
...Object.keys(node.params ?? {}).filter(
(name) => !RESERVED_PARAMS.has(name),
),
]
// Anything else cannot be a keyword argument, so it cannot be one.
.filter((name) => IDENTIFIER.test(name)),
),
]
const args = processArgs(node)
const returns = (node.provides ?? [])
.filter((spec) => portName(spec))
.map((spec) => `"${portName(spec)}": ${PLACEHOLDER[spec.dtype ?? "float"]}`)
@@ -1470,11 +1455,12 @@ function PanelBody({
}
/**
* Change the node, and keep an untouched scaffold in step with its ports.
* Change the node, and keep its code in step with its ports and settings.
*
* Only code that is still exactly what this panel generates is rewritten —
* one edit of your own and it is yours, ports or no ports. Shared code is
* never rewritten either: the other flows using it have their own ports.
* Code that is still exactly what this panel generates is generated again,
* outputs included. Code of your own only has the header of `process`
* rewritten, and the body is left as it is. Shared code is never rewritten:
* the other flows using it have their own ports.
*/
const editNode = (next: NodeDef_Input) => {
onChange(next)
@@ -1487,8 +1473,10 @@ function PanelBody({
current === undefined
)
return
const wanted = scaffoldFor(next)
if (current !== wanted && SCAFFOLD_SHAPE.test(current)) editCode(wanted)
const wanted = SCAFFOLD_SHAPE.test(current)
? scaffoldFor(next)
: withHeader(current, processArgs(next))
if (current !== wanted) editCode(wanted)
}
/**
@@ -1705,7 +1693,9 @@ function PanelBody({
{CODE_LOCKED}
</p>
)}
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-border">
{/* `nokey`: Monaco types through a plain div (EditContext), which
the canvas takes for itself, so Space would pan instead. */}
<div className="nokey min-h-0 flex-1 overflow-hidden rounded-md border border-border">
<Suspense
fallback={
<div className="h-full w-full animate-pulse bg-muted" />
@@ -0,0 +1,79 @@
/**
* The header of `process` kept in step with a node's ports and settings,
* checked. The server's half, reading them back, is `follow_header` in
* `backend/tests/flow/test_node_settings.py`.
*
* cd frontend && bun run src/components/Flow/header.check.ts
*/
import assert from "node:assert/strict"
import { processArgs, readHeader, withHeader } from "./header"
const BODY = ` # the body stays exactly as written
return {"out": reading * gain}
`
const code = (params: string) =>
`import math\n\n\ndef process(${params}) -> dict:\n${BODY}`
// Inputs in order, then settings; the engine's own setting is not one.
assert.deepEqual(
processArgs({
id: "n",
requires: [{ name: "house.reading" }, { name: "x", port: "level" }],
params: { gain: 2, synchronous: true },
}),
["reading", "level", "gain"],
)
// Config to header and back: the names written are the names read.
const written = withHeader(code("reading, gain=2.0"), [
"reading",
"level",
"gain",
])
assert.equal(written, code("reading, level, gain=2.0"))
assert.deepEqual(
readHeader(written)?.params.map((param) => param.name),
["reading", "level", "gain"],
)
// A rename keeps the annotation; a dropped name goes; the body never moves.
assert.equal(
withHeader(code("reading: float, gain=2.0"), ["value", "gain"]),
code("value: float, gain=2.0"),
)
assert.equal(withHeader(code("reading, gain"), ["reading"]), code("reading"))
// A new setting without a default cannot follow one with a default.
assert.equal(
withHeader(code("reading, gain=2.0"), ["reading", "gain", "offset"]),
code("reading, offset, gain=2.0"),
)
// Nothing to change is no rewrite, not even of the spacing.
assert.equal(
withHeader(code("reading ,gain"), ["reading", "gain"]),
code("reading ,gain"),
)
// Commas inside a default or a string are not parameter boundaries.
assert.deepEqual(
readHeader(code('reading, sep=", ", sizes=(1, 2)'))?.params.map(
(param) => param.name,
),
["reading", "sep", "sizes"],
)
// What the server will not read is not rewritten either.
for (const params of [
"*values",
"reading, **settings",
"reading, /",
"a, # why\n b",
])
assert.equal(withHeader(code(params), ["other"]), code(params))
const decorated = `@cache\n${code("reading").slice("import math\n\n\n".length)}`
assert.equal(withHeader(decorated, ["other"]), decorated)
console.log("header: ok")
+140
View File
@@ -0,0 +1,140 @@
/**
* The header of a function node's `process`, and keeping it in step with the
* node's ports and settings.
*
* The other direction, reading ports and settings back off a header, is the
* server's: `follow_header` runs on every save of the code.
*/
import type { MessageSpec, NodeDef_Input } from "@/client"
/** Settings the engine reads itself, so they are not the author's to name. */
export const RESERVED_PARAMS = new Set(["synchronous"])
/** A setting reaches the function by name, so the name has to be one. */
export const IDENTIFIER = /^[A-Za-z_]\w*$/
/** The name the node function sees; the engine derives it the same way. */
export const portName = (spec: MessageSpec) =>
spec.port || (spec.name ?? "").split(".").pop() || ""
/**
* The parameters `process` takes for this node.
*
* Inputs and settings both arrive by name, so both are parameters, the inputs
* first in the order they are declared, then the settings.
*/
export function processArgs(node: NodeDef_Input): string[] {
return [
...new Set(
[
...(node.requires ?? []).map(portName),
...Object.keys(node.params ?? {}).filter(
(name) => !RESERVED_PARAMS.has(name),
),
]
// Anything else cannot be a keyword argument, so it cannot be one.
.filter((name) => IDENTIFIER.test(name)),
),
]
}
type Param = { name: string; text: string; hasDefault: boolean }
/** Where the parameters of the last top-level `def process(` sit. */
const PROCESS_DEF = /^def process\(/gm
/**
* The parameters of `process` as written, and the span they take up.
*
* Null for a header this cannot read for certain, the same ones the server
* leaves alone: no top-level `def process`, a decorator, `*args`, `**kwargs`,
* a bare `*` or `/`, or a comment inside the parentheses.
*/
export function readHeader(
code: string,
): { from: number; to: number; params: Param[] } | null {
// Python keeps the last definition, so that is the one that runs.
const match = [...code.matchAll(PROCESS_DEF)].pop()
if (match?.index === undefined) return null
const above = code.slice(0, match.index).trimEnd()
if (above.slice(above.lastIndexOf("\n") + 1).startsWith("@")) return null
const from = match.index + match[0].length
const params: Param[] = []
let start = from
let depth = 0
let hasDefault = false
const take = (end: number) => {
const text = code.slice(start, end).trim()
const name = /^[A-Za-z_]\w*/.exec(text)?.[0]
params.push({ name: name ?? "", text, hasDefault })
start = end + 1
hasDefault = false
return Boolean(name)
}
for (let i = from; i < code.length; i++) {
const char = code[i]
if (char === '"' || char === "'") {
const quote = code.startsWith(char.repeat(3), i) ? char.repeat(3) : char
let j = i + quote.length
while (j < code.length && !code.startsWith(quote, j))
j += code[j] === "\\" ? 2 : 1
if (j >= code.length) return null
i = j + quote.length - 1
} else if (char === "#") {
return null
} else if ("([{".includes(char)) {
depth++
} else if (")]}".includes(char) && depth > 0) {
depth--
} else if (char === ")") {
// A trailing comma leaves nothing after it, which is not a parameter.
if (code.slice(start, i).trim() && !take(i)) return null
return { from, to: i, params }
} else if (depth === 0 && char === ",") {
if (!take(i)) return null
} else if (depth === 0 && char === "=") {
hasDefault = true
}
}
return null
}
/**
* `code` with `process` taking `args`, and every other byte as it was.
*
* A parameter that stays keeps its annotation and default, and so does one
* renamed (one name gone, one new). Python wants the parameters without a
* default first, so a new one goes ahead of any that have one. A header this
* cannot read is left alone.
*/
export function withHeader(code: string, args: string[]): string {
const header = readHeader(code)
if (!header) return code
if (header.params.map((param) => param.name).join() === args.join())
return code
const written = new Map(header.params.map((param) => [param.name, param]))
const gone = header.params.filter((param) => !args.includes(param.name))
const fresh = args.filter((name) => !written.has(name))
if (gone.length === 1 && fresh.length === 1) {
const [was] = gone
written.set(fresh[0], {
...was,
name: fresh[0],
text: fresh[0] + was.text.slice(was.name.length),
})
}
const params = args.map(
(name) => written.get(name) ?? { name, text: name, hasDefault: false },
)
const ordered = [
...params.filter((param) => !param.hasDefault),
...params.filter((param) => param.hasDefault),
]
return (
code.slice(0, header.from) +
ordered.map((param) => param.text).join(", ") +
code.slice(header.to)
)
}
+7 -1
View File
@@ -172,6 +172,8 @@ export function useAutosave(name: string): {
flush: () => Promise<void>
conflict: boolean
resolveConflict: (mode: "theirs" | "mine") => Promise<void>
/** Take a version the server moved the draft to by itself. */
adopt: (next: number) => void
mutation: UseMutationResult<unknown, unknown, FlowDef_Input, unknown>
} {
const queryClient = useQueryClient()
@@ -283,5 +285,9 @@ export function useAutosave(name: string): {
}
}, [flush])
return { save, flush, conflict, resolveConflict, mutation }
const adopt = useCallback((next: number) => {
version.current = next
}, [])
return { save, flush, conflict, resolveConflict, adopt, mutation }
}