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:
co-authored by
Claude Fable 5
parent
7344eac262
commit
3724b68f23
@@ -27,6 +27,7 @@ from app.flow.schemas import (
|
||||
FlowStatePublic,
|
||||
FlowSummary,
|
||||
HistoryPoint,
|
||||
LibraryNode,
|
||||
MessageHistory,
|
||||
MessageValue,
|
||||
NodeSource,
|
||||
@@ -34,7 +35,13 @@ from app.flow.schemas import (
|
||||
NodeTypeInfo,
|
||||
)
|
||||
from app.flow.state import as_number
|
||||
from app.flow.store import FlowExists, FlowNotFound, StaleVersion
|
||||
from app.flow.store import (
|
||||
FlowExists,
|
||||
FlowNotFound,
|
||||
LibExists,
|
||||
LibNotFound,
|
||||
StaleVersion,
|
||||
)
|
||||
from app.models import Message
|
||||
|
||||
router = APIRouter(
|
||||
@@ -73,6 +80,10 @@ class PublishRequest(BaseModel):
|
||||
version: int
|
||||
|
||||
|
||||
class ShareRequest(BaseModel):
|
||||
lib_name: str
|
||||
|
||||
|
||||
class RunRequest(BaseModel):
|
||||
inputs: dict[str, Any] = {}
|
||||
|
||||
@@ -114,6 +125,12 @@ def _read_flow(controller: FlowController, name: str) -> FlowDef:
|
||||
raise HTTPException(status_code=404, detail=f"No flow named '{name}'")
|
||||
|
||||
|
||||
def _source_ref(definition: FlowDef, node_id: str) -> str | None:
|
||||
"""The library source this node runs, if it is a shared one."""
|
||||
node = next((n for n in definition.nodes if n.id == node_id), None)
|
||||
return node.source_ref if node else None
|
||||
|
||||
|
||||
def _require_enabled(controller: FlowController, name: str) -> None:
|
||||
if not controller.is_enabled(name):
|
||||
raise HTTPException(
|
||||
@@ -168,6 +185,39 @@ def read_node_types() -> Any:
|
||||
return node_type_info()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Shared nodes
|
||||
#
|
||||
# Declared above the "/{name}" routes: "library" would otherwise be read as a
|
||||
# flow name.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/library", response_model=list[LibraryNode])
|
||||
def read_library(controller: FlowControllerDep) -> Any:
|
||||
"""The node sources shared across flows, and which nodes use each."""
|
||||
return [
|
||||
LibraryNode(name=name, used_by=controller.store.usages(name))
|
||||
for name in controller.store.list_lib()
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/library/{lib_name}", response_model=Message)
|
||||
async def delete_shared_node(lib_name: str, controller: FlowControllerDep) -> Any:
|
||||
"""Remove a shared source, as long as no flow still runs it."""
|
||||
used_by = controller.store.usages(lib_name)
|
||||
if used_by:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"'{lib_name}' is still used by {', '.join(used_by)}",
|
||||
)
|
||||
try:
|
||||
await run_in_threadpool(controller.store.delete_lib_source, lib_name)
|
||||
except LibNotFound:
|
||||
raise HTTPException(status_code=404, detail=f"No shared node '{lib_name}'")
|
||||
return Message(message=f"Deleted shared node '{lib_name}'")
|
||||
|
||||
|
||||
@router.get("/{name}", response_model=FlowDetail)
|
||||
def read_flow(name: str, controller: FlowControllerDep) -> Any:
|
||||
"""Read one flow, with the state of its nodes."""
|
||||
@@ -303,7 +353,13 @@ def read_node_source(
|
||||
controller: FlowControllerDep,
|
||||
) -> Any:
|
||||
"""Read a node's Python source, including unpublished edits."""
|
||||
_read_flow(controller, name)
|
||||
definition = _read_flow(controller, name)
|
||||
ref = _source_ref(definition, node_id)
|
||||
if ref:
|
||||
try:
|
||||
return NodeSource(code=controller.store.read_lib_source(ref))
|
||||
except LibNotFound:
|
||||
raise HTTPException(status_code=404, detail=f"No shared node '{ref}'")
|
||||
return NodeSource(code=controller.store.read_node_source(name, node_id, draft=True))
|
||||
|
||||
|
||||
@@ -319,11 +375,23 @@ async def save_node_source(
|
||||
The answer comes from compiling the code rather than from the running
|
||||
pipeline: a draft is not deployed, and compiling is both faster and more
|
||||
precise about what the author just typed.
|
||||
|
||||
A shared node writes to the library, so the fix reaches every flow using
|
||||
it — and that one is live immediately rather than waiting for a publish,
|
||||
because the copy is not any single flow's to hold back.
|
||||
"""
|
||||
_read_flow(controller, name)
|
||||
await run_in_threadpool(
|
||||
controller.store.write_node_source, name, node_id, source.code, True
|
||||
)
|
||||
definition = _read_flow(controller, name)
|
||||
ref = _source_ref(definition, node_id)
|
||||
if ref:
|
||||
changed = await run_in_threadpool(
|
||||
controller.store.write_lib_source, ref, source.code
|
||||
)
|
||||
if changed:
|
||||
await controller.reload()
|
||||
else:
|
||||
await run_in_threadpool(
|
||||
controller.store.write_node_source, name, node_id, source.code, True
|
||||
)
|
||||
error = await run_in_threadpool(
|
||||
controller.compile_check, name, node_id, source.code
|
||||
)
|
||||
@@ -334,6 +402,46 @@ async def save_node_source(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{name}/nodes/{node_id}/share", response_model=FlowDetail)
|
||||
async def share_node(
|
||||
name: str,
|
||||
node_id: str,
|
||||
body: ShareRequest,
|
||||
controller: FlowControllerDep,
|
||||
) -> Any:
|
||||
"""Move this node's code into the library so other flows can run it too."""
|
||||
_read_flow(controller, name)
|
||||
if not NAME_PATTERN.match(body.lib_name):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Use lowercase letters, digits and underscores, starting with a letter"
|
||||
),
|
||||
)
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
controller.store.share_node, name, node_id, body.lib_name
|
||||
)
|
||||
except LibExists as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
return _detail(controller, _read_flow(controller, name))
|
||||
|
||||
|
||||
@router.post("/{name}/nodes/{node_id}/unshare", response_model=FlowDetail)
|
||||
async def unshare_node(
|
||||
name: str,
|
||||
node_id: str,
|
||||
controller: FlowControllerDep,
|
||||
) -> Any:
|
||||
"""Take a private copy of the shared code back into this flow."""
|
||||
_read_flow(controller, name)
|
||||
try:
|
||||
await run_in_threadpool(controller.store.unshare_node, name, node_id)
|
||||
except LibNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
return _detail(controller, _read_flow(controller, name))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Running, stopped, paused
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user