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
+94 -2
View File
@@ -343,6 +343,26 @@ export const HistoryPointSchema = {
description: 'One numeric value a message carried, and when.'
} as const;
export const LibraryNodeSchema = {
properties: {
name: {
type: 'string',
title: 'Name'
},
used_by: {
items: {
type: 'string'
},
type: 'array',
title: 'Used By'
}
},
type: 'object',
required: ['name'],
title: 'LibraryNode',
description: 'A node source shared across flows, and who is using it.'
} as const;
export const MessageSchema = {
properties: {
message: {
@@ -398,6 +418,12 @@ export const MessageSpecSchema = {
dtype: {
'$ref': '#/components/schemas/DType',
default: 'float'
},
interval: {
type: 'number',
minimum: 0,
title: 'Interval',
default: 0
}
},
type: 'object',
@@ -408,7 +434,11 @@ export const MessageSpecSchema = {
the flow name at load time; empty means the port is unbound.
: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 dtype: Payload type, validated on every message that passes through.
: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
delivery is skipped.`
} as const;
export const MessageValueSchema = {
@@ -492,6 +522,17 @@ export const NodeDef_InputSchema = {
},
type: 'array',
title: 'Provides'
},
source_ref: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Source Ref'
}
},
type: 'object',
@@ -541,6 +582,17 @@ export const NodeDef_OutputSchema = {
},
type: 'array',
title: 'Provides'
},
source_ref: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Source Ref'
}
},
type: 'object',
@@ -583,12 +635,29 @@ export const NodeStatusPublicSchema = {
}
],
title: 'Error'
},
health: {
type: 'string',
enum: ['ok', 'degraded', 'down'],
title: 'Health',
default: 'ok'
},
health_detail: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Health Detail'
}
},
type: 'object',
required: ['id'],
title: 'NodeStatusPublic',
description: 'Whether a node loaded, and why not.'
description: 'Whether a node loaded, and how its connection is doing.'
} as const;
export const NodeTypeInfoSchema = {
@@ -614,6 +683,17 @@ export const NodeTypeInfoSchema = {
type: 'boolean',
title: 'Has Source',
default: false
},
plugin: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Plugin'
}
},
type: 'object',
@@ -733,6 +813,18 @@ export const SecretValueSchema = {
title: 'SecretValue'
} as const;
export const ShareRequestSchema = {
properties: {
lib_name: {
type: 'string',
title: 'Lib Name'
}
},
type: 'object',
required: ['lib_name'],
title: 'ShareRequest'
} as const;
export const TokenSchema = {
properties: {
access_token: {
+88 -1
View File
@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request';
import type { FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
import type { FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
export class FlowsService {
/**
@@ -32,6 +32,40 @@ export class FlowsService {
});
}
/**
* Read Library
* The node sources shared across flows, and which nodes use each.
* @returns LibraryNode Successful Response
* @throws ApiError
*/
public static readLibrary(): CancelablePromise<FlowsReadLibraryResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/flows/library'
});
}
/**
* Delete Shared Node
* Remove a shared source, as long as no flow still runs it.
* @param data The data for the request.
* @param data.libName
* @returns Message Successful Response
* @throws ApiError
*/
public static deleteSharedNode(data: FlowsDeleteSharedNodeData): CancelablePromise<FlowsDeleteSharedNodeResponse> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/api/v1/flows/library/{lib_name}',
path: {
lib_name: data.libName
},
errors: {
422: 'Validation Error'
}
});
}
/**
* Read Flow
* Read one flow, with the state of its nodes.
@@ -202,6 +236,10 @@ export class FlowsService {
* 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.
* @param data The data for the request.
* @param data.name
* @param data.nodeId
@@ -225,6 +263,55 @@ export class FlowsService {
});
}
/**
* Share Node
* Move this node's code into the library so other flows can run it too.
* @param data The data for the request.
* @param data.name
* @param data.nodeId
* @param data.requestBody
* @returns FlowDetail Successful Response
* @throws ApiError
*/
public static shareNode(data: FlowsShareNodeData): CancelablePromise<FlowsShareNodeResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/flows/{name}/nodes/{node_id}/share',
path: {
name: data.name,
node_id: data.nodeId
},
body: data.requestBody,
mediaType: 'application/json',
errors: {
422: 'Validation Error'
}
});
}
/**
* Unshare Node
* Take a private copy of the shared code back into this flow.
* @param data The data for the request.
* @param data.name
* @param data.nodeId
* @returns FlowDetail Successful Response
* @throws ApiError
*/
public static unshareNode(data: FlowsUnshareNodeData): CancelablePromise<FlowsUnshareNodeResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/flows/{name}/nodes/{node_id}/unshare',
path: {
name: data.name,
node_id: data.nodeId
},
errors: {
422: 'Validation Error'
}
});
}
/**
* Start Flow
* Let the engine run this flow again.
+48 -1
View File
@@ -105,6 +105,14 @@ export type HTTPValidationError = {
detail?: Array<ValidationError>;
};
/**
* A node source shared across flows, and who is using it.
*/
export type LibraryNode = {
name: string;
used_by?: Array<(string)>;
};
export type Message = {
message: string;
};
@@ -129,11 +137,16 @@ export type MessageHistory = {
* :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 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
* delivery is skipped.
*/
export type MessageSpec = {
name?: string;
port?: string;
dtype?: DType;
interval?: number;
};
/**
@@ -162,6 +175,7 @@ export type NodeDef_Input = {
};
requires?: Array<MessageSpec>;
provides?: Array<MessageSpec>;
source_ref?: (string | null);
};
/**
@@ -177,6 +191,7 @@ export type NodeDef_Output = {
};
requires?: Array<MessageSpec>;
provides?: Array<MessageSpec>;
source_ref?: (string | null);
};
/**
@@ -187,14 +202,18 @@ export type NodeSource = {
};
/**
* Whether a node loaded, and why not.
* Whether a node loaded, and how its connection is doing.
*/
export type NodeStatusPublic = {
id: string;
status?: string;
error?: (string | null);
health?: 'ok' | 'degraded' | 'down';
health_detail?: (string | null);
};
export type health = 'ok' | 'degraded' | 'down';
/**
* A node type the editor can offer, with its parameter schema.
*/
@@ -206,6 +225,7 @@ export type NodeTypeInfo = {
[key: string]: unknown;
};
has_source?: boolean;
plugin?: (string | null);
};
/**
@@ -246,6 +266,10 @@ export type SecretValue = {
value: string;
};
export type ShareRequest = {
lib_name: string;
};
export type Token = {
access_token: string;
token_type?: string;
@@ -332,6 +356,14 @@ export type FlowsReadFlowsResponse = (FlowsPublic);
export type FlowsReadNodeTypesResponse = (Array<NodeTypeInfo>);
export type FlowsReadLibraryResponse = (Array<LibraryNode>);
export type FlowsDeleteSharedNodeData = {
libName: string;
};
export type FlowsDeleteSharedNodeResponse = (Message);
export type FlowsReadFlowData = {
name: string;
};
@@ -386,6 +418,21 @@ export type FlowsSaveNodeSourceData = {
export type FlowsSaveNodeSourceResponse = (NodeStatusPublic);
export type FlowsShareNodeData = {
name: string;
nodeId: string;
requestBody: ShareRequest;
};
export type FlowsShareNodeResponse = (FlowDetail);
export type FlowsUnshareNodeData = {
name: string;
nodeId: string;
};
export type FlowsUnshareNodeResponse = (FlowDetail);
export type FlowsStartFlowData = {
name: string;
};