@tensorify.io/sdk
Version:
TypeScript SDK for developing Tensorify plugins with V2-Alpha definition/execution pattern and legacy compatibility
793 lines (696 loc) • 23.8 kB
text/typescript
/**
* TensorifyPlugin - The core abstract class for all Tensorify plugins
*
* This is the single class that all plugin developers must extend.
* It enforces frontend requirements, provides validation, and handles
* manifest generation for the CLI publishing system.
*/
import {
CorePluginSettings,
PluginSettings,
PluginCodeGenerationContext,
NodeType,
PluginCapability,
PluginRequirements,
} from "../types/core";
import {
IPluginDefinition,
FrontendPluginManifest,
PackageJsonInfo,
PluginValidationResult,
PluginValidationError,
PluginValidationWarning,
} from "../types/plugin";
import {
InputHandle,
OutputHandle,
NodeVisualConfig,
HandleViewType,
HandlePosition,
EdgeType,
NodeViewContainerType,
IconType,
} from "../types/visual";
import {
SettingsField,
SettingsGroup,
SettingsUIType,
SettingsDataType,
UI_TYPE_TO_DATA_TYPE_MAP,
} from "../types/settings";
/**
* Abstract base class for all Tensorify plugins
*
* Every plugin must extend this class and implement the required methods.
* This class enforces frontend visual requirements and provides utilities
* for plugin development.
*/
export abstract class TensorifyPlugin {
/** Plugin definition containing all configuration */
protected readonly definition: IPluginDefinition;
/** Current SDK version */
private static readonly SDK_VERSION = "1.0.0";
/** Manifest format version */
private static readonly MANIFEST_VERSION = "1.0.0";
/**
* Constructor - Creates a new plugin instance
* @param definition Complete plugin definition
*/
constructor(definition: IPluginDefinition) {
this.definition = definition;
this.validateDefinition();
}
// ========================================
// ABSTRACT METHODS (Must be implemented)
// ========================================
/**
* Generate executable code for this plugin
*
* This is the core method that every plugin must implement.
* It generates the actual code that will be executed in the workflow.
*
* @param settings Plugin settings extending CorePluginSettings
* @param children Connected child plugins (keep as any for flexibility)
* @param context Code generation context with input data access
* @returns Generated code as string
*/
public abstract getTranslationCode(
settings: PluginSettings,
children?: any,
context?: PluginCodeGenerationContext
): string;
// ========================================
// INPUT ACCESS HELPER
// ========================================
/**
* Helper method to access input data from handles
*
* @param context Code generation context
* @param handleNumber Handle index (0-based)
* @returns Input data from the specified handle
*/
protected getInput(
context: PluginCodeGenerationContext,
handleNumber: number
): any {
return context?.inputData[handleNumber] || null;
}
/**
* Get all input data as an array
*
* @param context Code generation context
* @returns Array of all input data
*/
protected getAllInputs(context: PluginCodeGenerationContext): any[] {
if (!context?.inputData) return [];
const maxHandle = Math.max(...Object.keys(context.inputData).map(Number));
const inputs: any[] = [];
for (let i = 0; i <= maxHandle; i++) {
inputs.push(context.inputData[i] || null);
}
return inputs;
}
// ========================================
// PLUGIN DEFINITION ACCESSORS
// ========================================
/** Get plugin ID (may be undefined if not provided in definition) */
public getId(): string | undefined {
return this.definition.id;
}
/** Get plugin name (may be undefined if not provided in definition) */
public getName(): string | undefined {
return this.definition.name;
}
/** Get plugin description (may be undefined if not provided in definition) */
public getDescription(): string | undefined {
return this.definition.description;
}
/** Get plugin version (may be undefined if not provided in definition) */
public getVersion(): string | undefined {
return this.definition.version;
}
/** Get node type (may be undefined if not provided in definition) */
public getNodeType(): NodeType | undefined {
return this.definition.nodeType;
}
/** Get visual configuration */
public getVisualConfig(): NodeVisualConfig {
return this.definition.visual;
}
/** Get input handles */
public getInputHandles(): InputHandle[] {
return this.definition.inputHandles;
}
/** Get output handles */
public getOutputHandles(): OutputHandle[] {
return this.definition.outputHandles;
}
/** Get settings fields */
public getSettingsFields(): SettingsField[] {
return this.definition.settingsFields;
}
/** Get settings groups */
public getSettingsGroups(): SettingsGroup[] {
return this.definition.settingsGroups || [];
}
/** Get capabilities */
public getCapabilities(): PluginCapability[] {
return this.definition.capabilities;
}
/** Get requirements */
public getRequirements(): PluginRequirements {
return this.definition.requirements;
}
/** Get complete definition */
public getDefinition(): IPluginDefinition {
return { ...this.definition };
}
// ========================================
// VALIDATION METHODS
// ========================================
/**
* Validate plugin settings before code generation
*
* @param settings Settings to validate
* @returns Validation result
*/
public validateSettings(settings: PluginSettings): PluginValidationResult {
const errors: PluginValidationError[] = [];
const warnings: PluginValidationWarning[] = [];
if (!settings.labelName) {
errors.push({
type: "missing_property",
message: "labelName is required in plugin settings",
path: "labelName",
expected: "string",
actual: settings.labelName,
});
}
// Validate each settings field
for (const field of this.definition.settingsFields) {
const value = settings[field.key];
// Check required fields
if (
field.required &&
(value === undefined || value === null || value === "")
) {
errors.push({
type: "missing_property",
message: `Required field '${field.key}' is missing`,
path: field.key,
expected: field.dataType,
actual: value,
});
continue;
}
// Skip validation if field is not required and not provided
if (value === undefined || value === null) continue;
// Validate data type
if (!this.validateFieldDataType(value, field.dataType, field.key)) {
errors.push({
type: "invalid_type",
message: `Invalid type for field '${field.key}'`,
path: field.key,
expected: field.dataType,
actual: typeof value,
});
}
// Validate field-specific rules
if (field.validation) {
const fieldErrors = this.validateFieldRules(value, field);
errors.push(...fieldErrors);
}
}
return {
isValid: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate the plugin definition structure
*/
private validateDefinition(): void {
const errors: string[] = [];
// Note: All core metadata (including nodeType) can be derived from package.json
// Only visual configuration is truly required at definition time
// Validate visual config
if (!this.definition.visual) {
errors.push("Plugin visual configuration is required");
} else {
this.validateVisualConfig(errors);
}
// Validate handles
this.validateHandles(errors);
// Validate settings fields
this.validateSettingsFields(errors);
// Enforce emits presence (empty structure allowed)
const emits = (this.definition as any).emits;
if (!emits || typeof emits !== "object") {
errors.push(
"Plugin definition must include 'emits' object with variables/imports arrays"
);
} else {
if (!Array.isArray(emits.variables)) {
errors.push("Plugin 'emits.variables' must be an array (can be empty)");
}
if (!Array.isArray(emits.imports)) {
errors.push("Plugin 'emits.imports' must be an array (can be empty)");
}
}
if (errors.length > 0) {
throw new Error(
`Plugin definition validation failed:\n${errors.join("\n")}`
);
}
}
/**
* Validate visual configuration
*/
private validateVisualConfig(errors: string[]): void {
const visual = this.definition.visual;
if (!visual.containerType) {
errors.push("Visual containerType is required");
}
if (!visual.size || !visual.size.width || !visual.size.height) {
errors.push("Visual size (width and height) is required");
}
if (visual.size.width < 50 || visual.size.height < 30) {
errors.push("Visual size too small (minimum 50x30)");
}
}
/**
* Check if this plugin is a variable provider type
*/
private isVariableProviderPlugin(): boolean {
const nodeType = this.definition.nodeType;
if (!nodeType) return false;
const variableProviderTypes = [
NodeType.DATASET,
NodeType.DATALOADER,
// Add more variable provider types as needed
];
return variableProviderTypes.includes(nodeType);
}
/**
* Validate handle configurations
*/
private validateHandles(errors: string[]): void {
const inputIds = new Set<string>();
const outputIds = new Set<string>();
let hasPrev = false;
let hasNext = false;
// Validate input handles
for (const handle of this.definition.inputHandles) {
if (!handle.id) {
errors.push("Input handle id is required");
continue;
}
if (inputIds.has(handle.id)) {
errors.push(`Duplicate input handle id: ${handle.id}`);
}
inputIds.add(handle.id);
if (!handle.position) {
errors.push(`Input handle ${handle.id} position is required`);
}
if (!handle.viewType) {
errors.push(`Input handle ${handle.id} viewType is required`);
}
if (handle.id === "prev") {
hasPrev = true;
if (handle.position !== HandlePosition.LEFT) {
errors.push("Input handle 'prev' must be on the LEFT side");
}
// V2-Alpha: prev handle is optional for new architecture
// Only validate position if present, not required flag
}
}
// Validate output handles
for (const handle of this.definition.outputHandles) {
if (!handle.id) {
errors.push("Output handle id is required");
continue;
}
if (outputIds.has(handle.id)) {
errors.push(`Duplicate output handle id: ${handle.id}`);
}
outputIds.add(handle.id);
if (!handle.position) {
errors.push(`Output handle ${handle.id} position is required`);
}
if (!handle.viewType) {
errors.push(`Output handle ${handle.id} viewType is required`);
}
if (handle.id === "next") {
hasNext = true;
if (handle.position !== HandlePosition.RIGHT) {
errors.push("Output handle 'next' must be on the RIGHT side");
}
}
}
// V2-Alpha: prev/next handles are optional in new architecture
// They were required for path-based V2-Beta but not needed for graph-based V2-Alpha
// const isVariableProvider = this.isVariableProviderPlugin();
// if (!isVariableProvider && !hasPrev) {
// errors.push("Plugin must define an input handle with id 'prev'");
// }
// if (!isVariableProvider && !hasNext) {
// errors.push("Plugin must define an output handle with id 'next'");
// }
}
/**
* Validate settings fields configuration
*/
private validateSettingsFields(errors: string[]): void {
const fieldKeys = new Set<string>();
for (const field of this.definition.settingsFields) {
if (!field.key) {
errors.push("Settings field key is required");
continue;
}
if (fieldKeys.has(field.key)) {
errors.push(`Duplicate settings field key: ${field.key}`);
}
fieldKeys.add(field.key);
if (!field.label) {
errors.push(`Settings field ${field.key} label is required`);
}
if (!field.type) {
errors.push(`Settings field ${field.key} type is required`);
}
if (!field.dataType) {
errors.push(`Settings field ${field.key} dataType is required`);
}
// Validate type compatibility
if (field.type && field.dataType) {
const compatibleTypes = UI_TYPE_TO_DATA_TYPE_MAP[field.type];
if (compatibleTypes && !compatibleTypes.includes(field.dataType)) {
errors.push(
`Settings field ${field.key} has incompatible type/dataType combination`
);
}
}
}
// Enforce emits.variables switch constraints if emits is provided
if (
this.definition.emits &&
Array.isArray(this.definition.emits.variables)
) {
const byKey: Record<string, SettingsField> = {};
for (const f of this.definition.settingsFields) {
byKey[f.key] = f;
}
for (const variable of this.definition.emits.variables) {
if (!variable || !variable.switchKey) continue;
const rawKey = variable.switchKey.includes(".")
? variable.switchKey.split(".").pop()!
: variable.switchKey;
const toggleField = byKey[rawKey];
if (!toggleField) {
errors.push(
`Emitted variable '${variable.value}' requires a boolean toggle settings field '${rawKey}' (from switchKey '${variable.switchKey}')`
);
continue;
}
// Must be toggle boolean and required
if (toggleField.type !== SettingsUIType.TOGGLE) {
errors.push(
`Settings field '${rawKey}' must be of UI type TOGGLE because it controls emitted variable '${variable.value}'`
);
}
if (toggleField.dataType !== SettingsDataType.BOOLEAN) {
errors.push(
`Settings field '${rawKey}' must have dataType BOOLEAN because it controls emitted variable '${variable.value}'`
);
}
if (toggleField.required !== true) {
errors.push(
`Settings field '${rawKey}' must be required: true because it controls emitted variable '${variable.value}'`
);
}
// Default matches isOnByDefault when provided
if (
typeof variable.isOnByDefault === "boolean" &&
toggleField.defaultValue !== variable.isOnByDefault
) {
errors.push(
`Settings field '${rawKey}'.defaultValue (${String(
toggleField.defaultValue
)}) must match isOnByDefault (${String(
variable.isOnByDefault
)}) for emitted variable '${variable.value}'`
);
}
}
}
}
/**
* Validate individual field data type
*/
private validateFieldDataType(
value: any,
expectedType: SettingsDataType,
fieldKey: string
): boolean {
switch (expectedType) {
case SettingsDataType.STRING:
return typeof value === "string";
case SettingsDataType.NUMBER:
return typeof value === "number" && !isNaN(value);
case SettingsDataType.BOOLEAN:
return typeof value === "boolean";
case SettingsDataType.ARRAY:
return Array.isArray(value);
case SettingsDataType.OBJECT:
return (
typeof value === "object" && value !== null && !Array.isArray(value)
);
default:
return true;
}
}
/**
* Validate field-specific rules
*/
private validateFieldRules(
value: any,
field: SettingsField
): PluginValidationError[] {
const errors: PluginValidationError[] = [];
const validation = field.validation!;
if (typeof value === "string") {
if (
validation.minLength !== undefined &&
value.length < validation.minLength
) {
errors.push({
type: "invalid_value",
message: `Field '${field.key}' is too short (minimum ${validation.minLength} characters)`,
path: field.key,
expected: `length >= ${validation.minLength}`,
actual: value.length,
});
}
if (
validation.maxLength !== undefined &&
value.length > validation.maxLength
) {
errors.push({
type: "invalid_value",
message: `Field '${field.key}' is too long (maximum ${validation.maxLength} characters)`,
path: field.key,
expected: `length <= ${validation.maxLength}`,
actual: value.length,
});
}
if (validation.pattern) {
const regex = new RegExp(validation.pattern);
if (!regex.test(value)) {
errors.push({
type: "invalid_value",
message: `Field '${field.key}' does not match required pattern`,
path: field.key,
expected: validation.pattern,
actual: value,
});
}
}
}
if (typeof value === "number") {
if (validation.min !== undefined && value < validation.min) {
errors.push({
type: "invalid_value",
message: `Field '${field.key}' is too small (minimum ${validation.min})`,
path: field.key,
expected: `>= ${validation.min}`,
actual: value,
});
}
if (validation.max !== undefined && value > validation.max) {
errors.push({
type: "invalid_value",
message: `Field '${field.key}' is too large (maximum ${validation.max})`,
path: field.key,
expected: `<= ${validation.max}`,
actual: value,
});
}
}
return errors;
}
// ========================================
// MANIFEST GENERATION
// ========================================
/**
* Derive plugin ID from package name
* @param packageName Package name (e.g., "@org/my-plugin" or "my-plugin")
* @returns Derived plugin ID
*/
private derivePluginId(packageName: string): string {
// Remove scope prefix if present (@org/my-plugin -> my-plugin)
return packageName.replace(/^@[^/]+\//, "");
}
/**
* Derive nodeType from package.json tensorify.pluginType
* @param pluginType Plugin type from package.json tensorify section
* @returns Derived NodeType or default to CUSTOM
*/
private deriveNodeType(pluginType?: string): NodeType {
if (!pluginType) return NodeType.CUSTOM;
// Map string values to NodeType enum
const typeMap: Record<string, NodeType> = {
custom: NodeType.CUSTOM,
trainer: NodeType.TRAINER,
evaluator: NodeType.EVALUATOR,
model: NodeType.MODEL,
model_layer: NodeType.MODEL_LAYER,
sequence: NodeType.SEQUENCE,
dataloader: NodeType.DATALOADER,
preprocessor: NodeType.PREPROCESSOR,
postprocessor: NodeType.POSTPROCESSOR,
augmentation_stack: NodeType.AUGMENTATION_STACK,
optimizer: NodeType.OPTIMIZER,
loss_function: NodeType.LOSS_FUNCTION,
metric: NodeType.METRIC,
scheduler: NodeType.SCHEDULER,
regularizer: NodeType.REGULARIZER,
function: NodeType.FUNCTION,
pipeline: NodeType.PIPELINE,
report: NodeType.REPORT,
};
return typeMap[pluginType.toLowerCase()] || NodeType.CUSTOM;
}
/**
* Generate frontend manifest for CLI publishing
*
* @param packageInfo Package.json information
* @param entrypointClassName Exact class name for instantiation
* @returns Frontend plugin manifest
*/
public generateManifest(
packageInfo: PackageJsonInfo,
entrypointClassName: string
): FrontendPluginManifest {
// Derive missing core metadata from package.json
const derivedId =
this.definition.id || this.derivePluginId(packageInfo.name);
const derivedName = this.definition.name || packageInfo.name;
const derivedDescription =
this.definition.description || packageInfo.description || "";
const derivedVersion = this.definition.version || packageInfo.version;
const derivedNodeType =
this.definition.nodeType ||
this.deriveNodeType(packageInfo.tensorify?.pluginType);
// Validate that we have all required information after derivation
if (!derivedId) {
throw new Error("Plugin ID could not be derived from package name");
}
if (!derivedName) {
throw new Error("Plugin name could not be derived from package name");
}
if (!derivedVersion) {
throw new Error("Plugin version could not be derived from package.json");
}
const manifest: FrontendPluginManifest = {
// Package Information
name: packageInfo.name,
version: packageInfo.version,
description: packageInfo.description || derivedDescription,
author: packageInfo.author || this.definition.author || "",
main: packageInfo.main || "dist/index.js",
entrypointClassName,
keywords: packageInfo.keywords || this.definition.keywords || [],
repository: packageInfo.repository,
pluginType: packageInfo.tensorify?.pluginType,
tensorify: packageInfo.tensorify,
// Frontend Configuration
frontendConfigs: {
id: derivedId,
name: derivedName,
category: derivedNodeType,
nodeType: derivedNodeType,
visual: this.definition.visual,
inputHandles: this.definition.inputHandles,
outputHandles: this.definition.outputHandles,
settingsFields: this.definition.settingsFields,
settingsGroups: this.definition.settingsGroups,
},
// Plugin Metadata
capabilities: this.definition.capabilities,
requirements: this.definition.requirements,
// Emits (variables/imports) are included in manifest; ensure arrays exist
emits: {
variables: this.definition.emits?.variables || [],
imports: this.definition.emits?.imports || [],
},
// Generation Metadata
sdkVersion: TensorifyPlugin.SDK_VERSION,
generatedAt: new Date().toISOString(),
manifestVersion: TensorifyPlugin.MANIFEST_VERSION,
};
return manifest;
}
// ========================================
// UTILITY METHODS
// ========================================
/**
* Create default settings object from field definitions
*/
public createDefaultSettings(): PluginSettings {
const settings: PluginSettings = {
variableName: `${this.definition.id || "plugin"}_${Date.now()}`,
labelName: this.definition.name || "Plugin",
};
for (const field of this.definition.settingsFields) {
if (field.defaultValue !== undefined) {
settings[field.key] = field.defaultValue;
}
}
return settings;
}
/**
* Process dynamic label template with settings values
*/
public generateDynamicLabel(settings: PluginSettings): string {
const template = this.definition.visual.labels.dynamicLabelTemplate;
if (!template) return "";
let result = template;
for (const [key, value] of Object.entries(settings)) {
const placeholder = `{${key}}`;
result = result.replace(new RegExp(placeholder, "g"), String(value));
}
return result;
}
/**
* Get current SDK version
*/
public static getSDKVersion(): string {
return TensorifyPlugin.SDK_VERSION;
}
/**
* Get manifest format version
*/
public static getManifestVersion(): string {
return TensorifyPlugin.MANIFEST_VERSION;
}
}