diff --git a/src/components/WorkflowEditor.test.tsx b/src/components/WorkflowEditor.test.tsx index 07ce253..93fba6b 100644 --- a/src/components/WorkflowEditor.test.tsx +++ b/src/components/WorkflowEditor.test.tsx @@ -1,8 +1,14 @@ -import { render } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { WorkflowEditor } from './WorkflowEditor.tsx'; +import { useModuleSchemaStore } from '../stores/moduleSchemaStore.ts'; describe('WorkflowEditor', () => { + beforeEach(() => { + vi.clearAllMocks(); + useModuleSchemaStore.getState().resetSchemaState(); + }); + it('renders without crashing', () => { const { container } = render(); expect(container).toBeTruthy(); @@ -36,4 +42,156 @@ triggers: {} render(); expect(onPluginSchemaRequest).toHaveBeenCalled(); }); + + it('prefers onEditorBundleRequest over legacy schema callbacks when provided', async () => { + const onEditorBundleRequest = vi.fn().mockResolvedValue({ + version: 'editor-bundle/v1', + workflowVersion: '0.0.0-test', + moduleSchemas: { + 'plugin.greeter': { + type: 'plugin.greeter', + label: 'Greeter', + category: 'integration', + configFields: [], + defaultConfig: {}, + }, + }, + stepSchemas: { + 'step.sayHello': { + type: 'step.sayHello', + plugin: 'greeter', + description: 'Say hello', + configFields: [], + outputs: [{ key: 'reply', type: 'demo.GreetResponse' }], + }, + }, + coercionRules: { + 'demo.GreetResponse': ['any'], + }, + contracts: { + 'greeter:module:plugin.greeter': { + id: 'greeter:module:plugin.greeter', + plugin: 'greeter', + ownerType: 'module', + ownerKey: 'plugin.greeter', + mode: 'strict', + requestMessage: 'demo.GreetRequest', + responseMessage: 'demo.GreetResponse', + configMessage: 'demo.GreeterConfig', + source: 'plugin-contracts-json', + }, + }, + messages: { + 'demo.GreetRequest': { id: 'demo.GreetRequest', name: 'GreetRequest', fullName: 'demo.GreetRequest', fields: [] }, + 'demo.GreetResponse': { id: 'demo.GreetResponse', name: 'GreetResponse', fullName: 'demo.GreetResponse', fields: [] }, + 'demo.GreeterConfig': { id: 'demo.GreeterConfig', name: 'GreeterConfig', fullName: 'demo.GreeterConfig', fields: [] }, + }, + schemas: { + app: { type: 'object' }, + infra: { type: 'object' }, + wfctl: { type: 'object' }, + }, + }); + const onSchemaRequest = vi.fn().mockResolvedValue({ modules: {}, services: [] }); + const onPluginSchemaRequest = vi.fn().mockResolvedValue([]); + + render( + , + ); + + await waitFor(() => expect(onEditorBundleRequest).toHaveBeenCalledTimes(1)); + expect(onSchemaRequest).not.toHaveBeenCalled(); + expect(onPluginSchemaRequest).not.toHaveBeenCalled(); + + const state = useModuleSchemaStore.getState(); + expect(state.moduleTypeMap['plugin.greeter']?.label).toBe('Greeter'); + expect(state.stepTypeMap['step.sayHello']?.description).toBe('Say hello'); + expect(state.coercionRules['demo.GreetResponse']).toEqual(['any']); + expect(state.getContractByOwner('module', 'plugin.greeter')?.requestMessage).toBe('demo.GreetRequest'); + expect(state.messages['demo.GreetResponse']?.fullName).toBe('demo.GreetResponse'); + expect(state.yamlSchemas.wfctl).toEqual({ type: 'object' }); + }); + + it('continues to use legacy schema callbacks when no editor bundle callback exists', async () => { + const onSchemaRequest = vi.fn().mockResolvedValue({ + modules: { + 'legacy.hosted': { + label: 'Legacy Hosted', + category: 'integration', + configFields: [], + defaultConfig: {}, + }, + }, + services: [], + }); + const onPluginSchemaRequest = vi.fn().mockResolvedValue([ + { + pluginName: 'legacy-plugin', + modules: { + 'legacy.plugin': { + label: 'Legacy Plugin', + category: 'integration', + configFields: [], + defaultConfig: {}, + }, + }, + }, + ]); + + render(); + + await waitFor(() => expect(onSchemaRequest).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(onPluginSchemaRequest).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(useModuleSchemaStore.getState().moduleTypeMap['legacy.plugin']?.label).toBe('Legacy Plugin')); + }); + + it('falls back to legacy callbacks when the editor bundle callback returns null', async () => { + const onEditorBundleRequest = vi.fn().mockResolvedValue(null); + const onSchemaRequest = vi.fn().mockResolvedValue({ + modules: { + 'fallback.hosted': { + label: 'Fallback Hosted', + category: 'integration', + configFields: [], + defaultConfig: {}, + }, + }, + services: [], + }); + + render(); + + await waitFor(() => expect(onEditorBundleRequest).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(onSchemaRequest).toHaveBeenCalledTimes(1)); + expect(useModuleSchemaStore.getState().moduleTypeMap['fallback.hosted']?.label).toBe('Fallback Hosted'); + }); + + it('falls back to legacy callbacks when the editor bundle callback rejects', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const onEditorBundleRequest = vi.fn().mockRejectedValue(new Error('bundle unavailable')); + const onPluginSchemaRequest = vi.fn().mockResolvedValue([ + { + pluginName: 'fallback-plugin', + modules: { + 'fallback.plugin': { + label: 'Fallback Plugin', + category: 'integration', + configFields: [], + defaultConfig: {}, + }, + }, + }, + ]); + + render(); + + await waitFor(() => expect(onEditorBundleRequest).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(onPluginSchemaRequest).toHaveBeenCalledTimes(1)); + expect(useModuleSchemaStore.getState().moduleTypeMap['fallback.plugin']?.label).toBe('Fallback Plugin'); + warn.mockRestore(); + }); }); diff --git a/src/components/WorkflowEditor.tsx b/src/components/WorkflowEditor.tsx index d666a37..8fca3f0 100644 --- a/src/components/WorkflowEditor.tsx +++ b/src/components/WorkflowEditor.tsx @@ -19,7 +19,7 @@ import dslReferenceData from '../generated/dsl-reference.json'; import { useEffect, useRef, useState, useMemo } from 'react'; export function WorkflowEditor(props: WorkflowEditorProps) { - const { initialYaml, onSave, onNavigateToSource, onSchemaRequest, onPluginSchemaRequest, embedded, onAIRequest, onChange, onResolveFile, mode, testResults, onTestRun, sourceMap: sourceMapProp, onSaveToFile, showYamlPane, showDslReference } = props; + const { initialYaml, onSave, onNavigateToSource, onSchemaRequest, onPluginSchemaRequest, onEditorBundleRequest, embedded, onAIRequest, onChange, onResolveFile, mode, testResults, onTestRun, sourceMap: sourceMapProp, onSaveToFile, showYamlPane, showDslReference } = props; const importFromConfig = useWorkflowStore((s) => s.importFromConfig); const exportToConfig = useWorkflowStore((s) => s.exportToConfig); const exportToFileMap = useWorkflowStore((s) => s.exportToFileMap); @@ -29,6 +29,7 @@ export function WorkflowEditor(props: WorkflowEditorProps) { const setTestResults = useWorkflowStore((s) => s.setTestResults); const loadSchemas = useModuleSchemaStore((s) => s.loadSchemas); const loadPluginSchemas = useModuleSchemaStore((s) => s.loadPluginSchemas); + const loadEditorBundle = useModuleSchemaStore((s) => s.loadEditorBundle); const importingRef = useRef(false); const hasMultiFileRef = useRef(false); @@ -111,17 +112,41 @@ export function WorkflowEditor(props: WorkflowEditorProps) { // Request schemas from host useEffect(() => { - if (onSchemaRequest) { - onSchemaRequest().then((data) => { - if (data) loadSchemas(data.modules as Parameters[0]); - }); - } - if (onPluginSchemaRequest) { - onPluginSchemaRequest().then((plugins) => { - if (plugins) loadPluginSchemas(plugins); - }); - } - }, [onSchemaRequest, onPluginSchemaRequest, loadSchemas, loadPluginSchemas]); + let cancelled = false; + + const loadLegacySchemas = async () => { + const [schemaData, pluginData] = await Promise.all([ + onSchemaRequest ? onSchemaRequest() : Promise.resolve(null), + onPluginSchemaRequest ? onPluginSchemaRequest() : Promise.resolve(null), + ]); + if (cancelled) return; + if (schemaData) loadSchemas(schemaData.modules as Parameters[0]); + if (pluginData) loadPluginSchemas(pluginData); + }; + + const loadHostSchemas = async () => { + if (onEditorBundleRequest) { + try { + const bundle = await onEditorBundleRequest(); + if (cancelled) return; + if (bundle) { + loadEditorBundle(bundle); + return; + } + } catch (error) { + console.warn('Failed to load editor contract bundle, falling back to legacy schemas:', error); + if (cancelled) return; + } + } + + await loadLegacySchemas(); + }; + + void loadHostSchemas(); + return () => { + cancelled = true; + }; + }, [onSchemaRequest, onPluginSchemaRequest, onEditorBundleRequest, loadSchemas, loadPluginSchemas, loadEditorBundle]); const nodePaletteCollapsed = useUILayoutStore((s) => s.nodePaletteCollapsed); const propertyPanelCollapsed = useUILayoutStore((s) => s.propertyPanelCollapsed); diff --git a/src/components/properties/PropertyPanel.test.tsx b/src/components/properties/PropertyPanel.test.tsx index c158b05..6cce058 100644 --- a/src/components/properties/PropertyPanel.test.tsx +++ b/src/components/properties/PropertyPanel.test.tsx @@ -3,6 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { act } from '@testing-library/react'; import PropertyPanel from './PropertyPanel.tsx'; import useWorkflowStore from '../../stores/workflowStore.ts'; +import { useModuleSchemaStore } from '../../stores/moduleSchemaStore.ts'; function resetStore() { useWorkflowStore.setState({ @@ -16,6 +17,7 @@ function resetStore() { showAIPanel: false, showComponentBrowser: false, }); + useModuleSchemaStore.getState().resetSchemaState(); } describe('PropertyPanel', () => { @@ -195,4 +197,109 @@ describe('PropertyPanel', () => { const rpsInput = screen.getByDisplayValue('60'); expect(rpsInput).toHaveAttribute('type', 'number'); }); + + it('shows contract metadata for the selected node when a descriptor exists', () => { + act(() => { + useModuleSchemaStore.getState().loadEditorBundle({ + version: 'editor-bundle/v1', + moduleSchemas: { + 'plugin.greeter': { + type: 'plugin.greeter', + label: 'Greeter', + category: 'integration', + configFields: [], + defaultConfig: {}, + }, + }, + coercionRules: {}, + contracts: { + 'greeter:module:plugin.greeter': { + id: 'greeter:module:plugin.greeter', + plugin: 'greeter', + ownerType: 'module', + ownerKey: 'plugin.greeter', + mode: 'proto_with_legacy', + requestMessage: 'demo.GreetRequest', + responseMessage: 'demo.GreetResponse', + configMessage: 'demo.GreeterConfig', + descriptorSetRef: 'buf.build/demo/greeter', + source: 'plugin-contracts-json', + }, + }, + messages: {}, + schemas: { app: {} }, + }); + useWorkflowStore.getState().addNode('plugin.greeter', { x: 0, y: 0 }); + useWorkflowStore.getState().setSelectedNode( + useWorkflowStore.getState().nodes[0].id + ); + }); + + render(); + + const section = screen.getByRole('region', { name: 'Contract metadata' }); + expect(section).toHaveTextContent('proto_with_legacy'); + expect(section).toHaveTextContent('greeter'); + expect(section).toHaveTextContent('plugin-contracts-json'); + expect(section).toHaveTextContent('demo.GreetRequest'); + expect(section).toHaveTextContent('demo.GreetResponse'); + expect(section).toHaveTextContent('demo.GreeterConfig'); + expect(section).toHaveTextContent('buf.build/demo/greeter'); + }); + + it('shows step contract metadata for selected pipeline step nodes', () => { + act(() => { + useModuleSchemaStore.getState().loadEditorBundle({ + version: 'editor-bundle/v1', + moduleSchemas: { + 'step.transform': { + type: 'step.transform', + label: 'Transform', + category: 'pipeline', + configFields: [], + defaultConfig: {}, + }, + }, + stepSchemas: { + 'step.transform': { + type: 'step.transform', + plugin: 'transformer', + description: 'Transform payload', + configFields: [], + outputs: [{ key: 'result', type: 'demo.TransformResponse' }], + }, + }, + coercionRules: {}, + contracts: { + 'transformer:step:step.transform': { + id: 'transformer:step:step.transform', + plugin: 'transformer', + ownerType: 'step', + ownerKey: 'step.transform', + mode: 'strict', + requestMessage: 'demo.TransformRequest', + responseMessage: 'demo.TransformResponse', + configMessage: 'demo.TransformConfig', + source: 'plugin-manifest', + }, + }, + messages: {}, + schemas: { app: {} }, + }); + useWorkflowStore.getState().addNode('step.transform', { x: 0, y: 0 }); + useWorkflowStore.getState().setSelectedNode( + useWorkflowStore.getState().nodes[0].id + ); + }); + + render(); + + const section = screen.getByRole('region', { name: 'Contract metadata' }); + expect(section).toHaveTextContent('strict'); + expect(section).toHaveTextContent('transformer'); + expect(section).toHaveTextContent('plugin-manifest'); + expect(section).toHaveTextContent('demo.TransformRequest'); + expect(section).toHaveTextContent('demo.TransformResponse'); + expect(section).toHaveTextContent('demo.TransformConfig'); + }); }); diff --git a/src/components/properties/PropertyPanel.tsx b/src/components/properties/PropertyPanel.tsx index 5398bf7..fd0c182 100644 --- a/src/components/properties/PropertyPanel.tsx +++ b/src/components/properties/PropertyPanel.tsx @@ -63,6 +63,8 @@ export default function PropertyPanel() { const setSelectedNode = useWorkflowStore((s) => s.setSelectedNode); const moduleTypeMap = useModuleSchemaStore((s) => s.moduleTypeMap); + const stepTypeMap = useModuleSchemaStore((s) => s.stepTypeMap); + const getContractByOwner = useModuleSchemaStore((s) => s.getContractByOwner); const getFieldEditor = useFieldEditorRegistry((s) => s.getEditor); const fetchSchemas = useModuleSchemaStore((s) => s.fetchSchemas); const schemasLoaded = useModuleSchemaStore((s) => s.loaded); @@ -74,6 +76,11 @@ export default function PropertyPanel() { const node = nodes.find((n) => n.id === selectedNodeId); const info = node ? moduleTypeMap[node.data.moduleType] : undefined; + const stepInfo = node?.data.moduleType.startsWith('step.') ? stepTypeMap[node.data.moduleType] : undefined; + const contract = node + ? (stepInfo ? getContractByOwner('step', node.data.moduleType) : undefined) + ?? getContractByOwner('module', node.data.moduleType) + : undefined; const fields: ConfigFieldDef[] = useMemo(() => info?.configFields ?? [], [info]); // Compute preceding steps for pipeline step nodes (for FieldPicker) @@ -243,6 +250,31 @@ export default function PropertyPanel() { + {contract && ( +
+ + Contract + + + + + + + + +
+ )} + {/* Config fields */} {fields.length > 0 && (
@@ -535,6 +567,16 @@ export default function PropertyPanel() { ); } +function ContractMetadataRow({ label, value }: { label: string; value?: string }) { + if (!value) return null; + return ( +
+ {label} + {value} +
+ ); +} + function SensitiveFieldInput({ value, onChange, diff --git a/src/generated/load-schemas.test.ts b/src/generated/load-schemas.test.ts index dbd680d..f5b2803 100644 --- a/src/generated/load-schemas.test.ts +++ b/src/generated/load-schemas.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { getEngineModuleTypes, getEngineCoercionRules, getEngineStepTypes } from './load-schemas'; +import { + getEngineModuleTypes, + getEngineCoercionRules, + getEngineStepTypes, + normalizeEditorContractBundle, +} from './load-schemas'; describe('getEngineModuleTypes', () => { it('loads http.server from engine schemas', () => { @@ -51,3 +56,78 @@ describe('getEngineStepTypes', () => { expect(Array.isArray(anyStep.outputs)).toBe(true); }); }); + +describe('normalizeEditorContractBundle', () => { + it('normalizes all editor bundle schema sections', () => { + const bundle = normalizeEditorContractBundle({ + version: 'editor-bundle/v1', + workflowVersion: '0.0.0-test', + moduleSchemas: { + 'plugin.greeter': { + type: 'plugin.greeter', + label: 'Greeter', + category: 'integration', + configFields: [], + defaultConfig: {}, + }, + }, + stepSchemas: { + 'step.sayHello': { + type: 'step.sayHello', + plugin: 'greeter', + description: 'Say hello', + configFields: [], + outputs: [{ key: 'reply', type: 'demo.GreetResponse' }], + }, + }, + coercionRules: { + 'demo.GreetResponse': ['any'], + }, + contracts: { + 'greeter:module:plugin.greeter': { + id: 'greeter:module:plugin.greeter', + ownerType: 'module', + ownerKey: 'plugin.greeter', + mode: 'strict', + requestMessage: 'demo.GreetRequest', + responseMessage: 'demo.GreetResponse', + configMessage: 'demo.GreeterConfig', + source: 'plugin-contracts-json', + }, + }, + messages: { + 'demo.GreetRequest': { id: 'demo.GreetRequest', name: 'GreetRequest', fullName: 'demo.GreetRequest', fields: [] }, + }, + schemas: { + app: { type: 'object' }, + infra: { type: 'object' }, + wfctl: { type: 'object' }, + }, + }); + + expect(bundle.moduleSchemas['plugin.greeter'].label).toBe('Greeter'); + expect(bundle.stepSchemas['step.sayHello'].outputs?.[0]?.type).toBe('demo.GreetResponse'); + expect(bundle.coercionRules['demo.GreetResponse']).toEqual(['any']); + expect(bundle.contracts['greeter:module:plugin.greeter'].mode).toBe('strict'); + expect(bundle.messages['demo.GreetRequest'].fullName).toBe('demo.GreetRequest'); + expect(bundle.schemas.wfctl).toEqual({ type: 'object' }); + }); + + it('does not throw when optional bundle sections are missing', () => { + const bundle = normalizeEditorContractBundle({ + version: 'editor-bundle/v1', + moduleSchemas: {}, + coercionRules: {}, + schemas: { + app: { type: 'object' }, + }, + }); + + expect(bundle.stepSchemas).toEqual({}); + expect(bundle.contracts).toEqual({}); + expect(bundle.messages).toEqual({}); + expect(bundle.schemas.app).toEqual({ type: 'object' }); + expect(bundle.schemas.infra).toBeUndefined(); + expect(bundle.schemas.wfctl).toBeUndefined(); + }); +}); diff --git a/src/generated/load-schemas.ts b/src/generated/load-schemas.ts index f24eaf2..3adc07d 100644 --- a/src/generated/load-schemas.ts +++ b/src/generated/load-schemas.ts @@ -1,5 +1,13 @@ import engineData from './engine-schemas.json'; import type { ModuleTypeInfo, IOPort, ConfigFieldDef } from '../types/workflow'; +import type { + EditorContractBundle, + EditorContractDescriptor, + EditorMessageDescriptor, + EditorYamlSchemas, + EngineBundleModuleSchema, + EngineBundleStepSchema, +} from '../types/editor'; interface EngineModuleSchema { type: string; @@ -29,6 +37,17 @@ interface EngineSchemas { coercionRules: Record; } +export interface NormalizedEditorContractBundle { + version: string; + workflowVersion?: string; + moduleSchemas: Record; + stepSchemas: Record; + coercionRules: Record; + contracts: Record; + messages: Record; + schemas: EditorYamlSchemas; +} + const data = engineData as EngineSchemas; function toIOPorts(defs?: { name: string; type: string }[]): IOPort[] { @@ -81,3 +100,16 @@ export function getEngineStepTypes(): Record { } return result; } + +export function normalizeEditorContractBundle(bundle: EditorContractBundle): NormalizedEditorContractBundle { + return { + version: bundle.version, + workflowVersion: bundle.workflowVersion, + moduleSchemas: bundle.moduleSchemas ?? {}, + stepSchemas: bundle.stepSchemas ?? {}, + coercionRules: bundle.coercionRules ?? {}, + contracts: bundle.contracts ?? {}, + messages: bundle.messages ?? {}, + schemas: bundle.schemas ?? { app: {} }, + }; +} diff --git a/src/stores/moduleSchemaStore.contracts.test.ts b/src/stores/moduleSchemaStore.contracts.test.ts new file mode 100644 index 0000000..244e4e2 --- /dev/null +++ b/src/stores/moduleSchemaStore.contracts.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useModuleSchemaStore } from './moduleSchemaStore.ts'; + +function resetModuleSchemaStore() { + useModuleSchemaStore.getState().resetSchemaState(); +} + +describe('moduleSchemaStore contract bundle loading', () => { + beforeEach(() => { + resetModuleSchemaStore(); + }); + + it('loads bundle modules, steps, coercion rules, contracts, messages, and YAML schemas', () => { + useModuleSchemaStore.getState().loadEditorBundle({ + version: 'editor-bundle/v1', + moduleSchemas: { + 'plugin.greeter': { + type: 'plugin.greeter', + label: 'Greeter', + category: 'integration', + configFields: [], + defaultConfig: { greeting: 'hello' }, + }, + }, + stepSchemas: { + 'step.sayHello': { + type: 'step.sayHello', + plugin: 'greeter', + description: 'Say hello', + configFields: [], + outputs: [{ key: 'reply', type: 'demo.GreetResponse' }], + }, + }, + coercionRules: { + 'demo.GreetResponse': ['any'], + }, + contracts: { + 'builtin:http.server': { + id: 'builtin:http.server', + ownerType: 'module', + ownerKey: 'http.server', + mode: 'strict', + requestMessage: 'workflow.http.ServerRequest', + responseMessage: 'workflow.http.ServerResponse', + source: 'builtin', + }, + 'greeter:module:plugin.greeter': { + id: 'greeter:module:plugin.greeter', + plugin: 'greeter', + ownerType: 'module', + ownerKey: 'plugin.greeter', + mode: 'proto_with_legacy', + requestMessage: 'demo.GreetRequest', + responseMessage: 'demo.GreetResponse', + configMessage: 'demo.GreeterConfig', + source: 'plugin-contracts-json', + descriptorSetRef: 'buf.build/demo/greeter', + }, + }, + messages: { + 'demo.GreetResponse': { id: 'demo.GreetResponse', name: 'GreetResponse', fullName: 'demo.GreetResponse', fields: [] }, + }, + schemas: { + app: { type: 'object' }, + wfctl: { type: 'object' }, + }, + }); + + const state = useModuleSchemaStore.getState(); + expect(state.moduleTypeMap['plugin.greeter']?.defaultConfig).toEqual({ greeting: 'hello' }); + expect(state.stepTypeMap['step.sayHello']?.outputs[0]?.type).toBe('demo.GreetResponse'); + expect(state.coercionRules['demo.GreetResponse']).toEqual(['any']); + expect(state.getContractByOwner('module', 'plugin.greeter')?.descriptorSetRef).toBe('buf.build/demo/greeter'); + expect(state.getMessage('demo.GreetResponse')?.name).toBe('GreetResponse'); + expect(state.getYamlSchema('wfctl')).toEqual({ type: 'object' }); + }); + + it('preserves bundle module ioSignature when no input/output arrays are present', () => { + useModuleSchemaStore.getState().loadEditorBundle({ + version: 'editor-bundle/v1', + moduleSchemas: { + 'plugin.stream': { + type: 'plugin.stream', + label: 'Stream', + category: 'integration', + configFields: [], + defaultConfig: {}, + ioSignature: { + inputs: [{ name: 'in', type: 'demo.StreamRequest' }], + outputs: [{ name: 'out', type: 'demo.StreamResponse' }], + }, + }, + }, + coercionRules: {}, + contracts: {}, + messages: {}, + schemas: { app: {} }, + }); + + expect(useModuleSchemaStore.getState().moduleTypeMap['plugin.stream']?.ioSignature).toEqual({ + inputs: [{ name: 'in', type: 'demo.StreamRequest' }], + outputs: [{ name: 'out', type: 'demo.StreamResponse' }], + }); + }); + + it('preserves built-in contract overlays when plugin contracts own a different key', () => { + useModuleSchemaStore.getState().loadEditorBundle({ + version: 'editor-bundle/v1', + moduleSchemas: {}, + coercionRules: {}, + contracts: { + 'builtin:http.server': { + id: 'builtin:http.server', + ownerType: 'module', + ownerKey: 'http.server', + mode: 'strict', + responseMessage: 'workflow.http.Response', + source: 'builtin', + }, + }, + messages: {}, + schemas: { app: {} }, + }); + + useModuleSchemaStore.getState().loadEditorBundle({ + version: 'editor-bundle/v1', + moduleSchemas: {}, + coercionRules: {}, + contracts: { + 'plugin:greeter': { + id: 'plugin:greeter', + plugin: 'greeter', + ownerType: 'module', + ownerKey: 'plugin.greeter', + mode: 'strict', + responseMessage: 'demo.GreetResponse', + source: 'plugin-contracts-json', + }, + }, + messages: {}, + schemas: { app: {} }, + }); + + expect(useModuleSchemaStore.getState().getContractByOwner('module', 'http.server')?.responseMessage).toBe('workflow.http.Response'); + expect(useModuleSchemaStore.getState().getContractByOwner('module', 'plugin.greeter')?.responseMessage).toBe('demo.GreetResponse'); + }); + + it('lets a later contract replace an existing contract for the same owner key', () => { + useModuleSchemaStore.getState().loadEditorBundle({ + version: 'editor-bundle/v1', + moduleSchemas: {}, + coercionRules: {}, + contracts: { + 'builtin:http.server': { + id: 'builtin:http.server', + ownerType: 'module', + ownerKey: 'http.server', + mode: 'legacy', + responseMessage: 'workflow.http.LegacyResponse', + source: 'builtin', + }, + }, + messages: {}, + schemas: { app: {} }, + }); + + useModuleSchemaStore.getState().loadEditorBundle({ + version: 'editor-bundle/v1', + moduleSchemas: {}, + coercionRules: {}, + contracts: { + 'plugin:http.server': { + id: 'plugin:http.server', + plugin: 'http-plugin', + ownerType: 'module', + ownerKey: 'http.server', + mode: 'strict', + responseMessage: 'plugin.http.Response', + source: 'plugin-contracts-json', + }, + }, + messages: {}, + schemas: { app: {} }, + }); + + const contract = useModuleSchemaStore.getState().getContractByOwner('module', 'http.server'); + expect(contract?.id).toBe('plugin:http.server'); + expect(contract?.responseMessage).toBe('plugin.http.Response'); + }); + + it('does not erase bundle contracts or active bundle schemas when legacy schema loaders run later', () => { + useModuleSchemaStore.getState().loadEditorBundle({ + version: 'editor-bundle/v1', + moduleSchemas: { + 'plugin.greeter': { + type: 'plugin.greeter', + label: 'Bundle Greeter', + category: 'integration', + configFields: [], + defaultConfig: { source: 'bundle' }, + }, + }, + coercionRules: {}, + contracts: { + 'plugin:greeter': { + id: 'plugin:greeter', + ownerType: 'module', + ownerKey: 'plugin.greeter', + mode: 'strict', + responseMessage: 'demo.GreetResponse', + source: 'plugin-contracts-json', + }, + }, + messages: { + 'demo.GreetResponse': { id: 'demo.GreetResponse', name: 'GreetResponse', fullName: 'demo.GreetResponse', fields: [] }, + }, + schemas: { app: {} }, + }); + + useModuleSchemaStore.getState().loadSchemas({ + 'plugin.greeter': { + type: 'plugin.greeter', + label: 'Legacy Greeter', + category: 'integration', + configFields: [], + defaultConfig: { source: 'legacy' }, + }, + }); + useModuleSchemaStore.getState().loadPluginSchemas([]); + + const state = useModuleSchemaStore.getState(); + expect(state.getContractByOwner('module', 'plugin.greeter')?.responseMessage).toBe('demo.GreetResponse'); + expect(state.getMessage('demo.GreetResponse')?.name).toBe('GreetResponse'); + expect(state.moduleTypeMap['plugin.greeter']?.label).toBe('Bundle Greeter'); + expect(state.serverSchemas['plugin.greeter']?.label).toBe('Bundle Greeter'); + }); +}); diff --git a/src/stores/moduleSchemaStore.ts b/src/stores/moduleSchemaStore.ts index 057e3d4..f4c77d1 100644 --- a/src/stores/moduleSchemaStore.ts +++ b/src/stores/moduleSchemaStore.ts @@ -1,7 +1,21 @@ import { create } from 'zustand'; import type { ModuleTypeInfo, ConfigFieldDef, ModuleCategory, IOSignature } from '../types/workflow.ts'; -import type { PluginSchemaData, ServerModuleSchema as EditorServerModuleSchema } from '../types/editor.ts'; -import { getEngineModuleTypes } from '../generated/load-schemas'; +import type { + EditorContractBundle, + EditorContractDescriptor, + EditorMessageDescriptor, + EditorYamlSchemas, + EngineBundleModuleSchema, + PluginSchemaData, + ServerModuleSchema as EditorServerModuleSchema, +} from '../types/editor.ts'; +import { + getEngineCoercionRules, + getEngineModuleTypes, + getEngineStepTypes, + normalizeEditorContractBundle, + type StepTypeInfo, +} from '../generated/load-schemas'; // Shape of a server-side I/O port definition interface ServerIODef { @@ -18,6 +32,7 @@ interface ServerModuleSchema { description?: string; inputs?: ServerIODef[]; outputs?: ServerIODef[]; + ioSignature?: IOSignature; configFields: ServerConfigField[]; defaultConfig?: Record; maxIncoming?: number | null; @@ -58,6 +73,20 @@ interface ModuleSchemaState { moduleTypes: ModuleTypeInfo[]; /** Module type map keyed by type string */ moduleTypeMap: Record; + /** Step type map keyed by step type string */ + stepTypeMap: Record; + /** Coercion rules keyed by source type */ + coercionRules: Record; + /** Strict contract descriptors keyed by descriptor id */ + contracts: Record; + /** Contract descriptor id by "ownerType:ownerKey" */ + contractOwnerIndex: Record; + /** Message descriptors keyed by descriptor id/full name */ + messages: Record; + /** YAML schemas from the editor bundle */ + yamlSchemas: EditorYamlSchemas; + /** Whether a host bundle has been loaded */ + bundleLoaded: boolean; /** Available services from the engine */ services: ServiceInfo[]; /** Whether services have been loaded */ @@ -70,6 +99,16 @@ interface ModuleSchemaState { loadSchemas: (schemas: Record) => void; /** Append plugin schemas to the existing module type map */ loadPluginSchemas: (plugins: PluginSchemaData[]) => void; + /** Load the canonical editor contract bundle provided by the host */ + loadEditorBundle: (bundle: EditorContractBundle) => void; + /** Lookup a contract descriptor by owner type and owner key */ + getContractByOwner: (ownerType: EditorContractDescriptor['ownerType'], ownerKey: string) => EditorContractDescriptor | undefined; + /** Lookup a message descriptor by id/full name */ + getMessage: (messageId: string) => EditorMessageDescriptor | undefined; + /** Lookup a YAML schema by bundle schema key */ + getYamlSchema: (schemaName: keyof EditorYamlSchemas | string) => Record | undefined; + /** Reset transient schema state to generated defaults */ + resetSchemaState: () => void; } /** Map server field types to UI field types */ @@ -159,7 +198,7 @@ function mergeSchemas( seen.add(staticType.type); const server = serverSchemas[staticType.type]; if (server) { - const serverIO = convertIOSignature(server.inputs, server.outputs); + const serverIO = server.ioSignature ?? convertIOSignature(server.inputs, server.outputs); merged.push({ ...staticType, label: server.label || staticType.label, @@ -184,7 +223,7 @@ function mergeSchemas( category: normalizeCategory(server.category), configFields: convertFields(server.configFields), defaultConfig: server.defaultConfig ?? {}, - ioSignature: convertIOSignature(server.inputs, server.outputs), + ioSignature: server.ioSignature ?? convertIOSignature(server.inputs, server.outputs), maxIncoming: server.maxIncoming, maxOutgoing: server.maxOutgoing, }); @@ -213,8 +252,63 @@ function editorSchemaToModuleTypeInfo( }; } +function bundleSchemaToServerSchema(type: string, schema: EngineBundleModuleSchema): ServerModuleSchema { + return { + type: schema.type ?? type, + label: schema.label ?? type, + category: schema.category ?? 'integration', + description: schema.description, + inputs: schema.inputs, + outputs: schema.outputs, + ioSignature: schema.ioSignature, + configFields: schema.configFields ?? [], + defaultConfig: schema.defaultConfig, + maxIncoming: schema.maxIncoming, + maxOutgoing: schema.maxOutgoing, + }; +} + +function bundleStepToStepTypeInfo(type: string, schema: NonNullable[string]): StepTypeInfo { + return { + type: schema.type ?? type, + plugin: schema.plugin, + description: schema.description ?? '', + configFields: schema.configFields ?? [], + outputs: schema.outputs ?? [], + }; +} + +function contractOwnerKey(ownerType: EditorContractDescriptor['ownerType'], ownerKey: string): string { + return `${ownerType}:${ownerKey}`; +} + +function mergeContractsByOwner( + currentContracts: Record, + currentIndex: Record, + incoming: Record, +): { contracts: Record; contractOwnerIndex: Record } { + const contracts = { ...currentContracts }; + const contractOwnerIndex = { ...currentIndex }; + + for (const [id, descriptor] of Object.entries(incoming)) { + const ownerIndexKey = contractOwnerKey(descriptor.ownerType, descriptor.ownerKey); + const previousId = contractOwnerIndex[ownerIndexKey]; + if (previousId && previousId !== id) { + delete contracts[previousId]; + } + contracts[id] = descriptor; + contractOwnerIndex[ownerIndexKey] = id; + } + + return { contracts, contractOwnerIndex }; +} + const initialModuleTypeMap = getEngineModuleTypes(); const initialModuleTypes = Object.values(initialModuleTypeMap); +const initialStepTypeMap = getEngineStepTypes(); +const initialCoercionRules = getEngineCoercionRules(); + +const initialYamlSchemas: EditorYamlSchemas = { app: {} }; const useModuleSchemaStore = create((set, get) => ({ loaded: false, @@ -222,6 +316,13 @@ const useModuleSchemaStore = create((set, get) => ({ serverSchemas: {}, moduleTypes: initialModuleTypes, moduleTypeMap: initialModuleTypeMap, + stepTypeMap: initialStepTypeMap, + coercionRules: initialCoercionRules, + contracts: {}, + contractOwnerIndex: {}, + messages: {}, + yamlSchemas: initialYamlSchemas, + bundleLoaded: false, services: [], servicesLoaded: false, @@ -241,6 +342,10 @@ const useModuleSchemaStore = create((set, get) => ({ return; } const schemas: Record = await res.json(); + if (get().bundleLoaded) { + set({ loaded: true, loading: false }); + return; + } const merged = mergeSchemas(initialModuleTypes, schemas); const mergedMap = Object.fromEntries(merged.map((t) => [t.type, t])); set({ @@ -282,6 +387,13 @@ const useModuleSchemaStore = create((set, get) => ({ }, loadSchemas: (schemas) => { + if (get().bundleLoaded) { + set({ + loaded: true, + loading: false, + }); + return; + } const merged = mergeSchemas(initialModuleTypes, schemas); const mergedMap = Object.fromEntries(merged.map((t) => [t.type, t])); set({ @@ -294,6 +406,8 @@ const useModuleSchemaStore = create((set, get) => ({ }, loadPluginSchemas: (plugins) => { + if (get().bundleLoaded) return; + const { moduleTypes, moduleTypeMap } = get(); const newTypes = [...moduleTypes]; const newMap = { ...moduleTypeMap }; @@ -313,6 +427,75 @@ const useModuleSchemaStore = create((set, get) => ({ set({ moduleTypes: newTypes, moduleTypeMap: newMap }); }, + + loadEditorBundle: (bundle) => { + const normalized = normalizeEditorContractBundle(bundle); + const bundleServerSchemas = Object.fromEntries( + Object.entries(normalized.moduleSchemas).map(([type, schema]) => [ + type, + bundleSchemaToServerSchema(type, schema), + ]), + ); + const merged = mergeSchemas(initialModuleTypes, bundleServerSchemas); + const mergedMap = Object.fromEntries(merged.map((t) => [t.type, t])); + const stepTypeMap = { + ...initialStepTypeMap, + ...Object.fromEntries( + Object.entries(normalized.stepSchemas).map(([type, schema]) => [ + type, + bundleStepToStepTypeInfo(type, schema), + ]), + ), + }; + const { contracts, contractOwnerIndex } = mergeContractsByOwner( + get().contracts, + get().contractOwnerIndex, + normalized.contracts, + ); + + set({ + serverSchemas: bundleServerSchemas, + moduleTypes: merged, + moduleTypeMap: mergedMap, + stepTypeMap, + coercionRules: { ...initialCoercionRules, ...normalized.coercionRules }, + contracts, + contractOwnerIndex, + messages: { ...get().messages, ...normalized.messages }, + yamlSchemas: normalized.schemas, + loaded: true, + loading: false, + bundleLoaded: true, + }); + }, + + getContractByOwner: (ownerType, ownerKey) => { + const id = get().contractOwnerIndex[contractOwnerKey(ownerType, ownerKey)]; + return id ? get().contracts[id] : undefined; + }, + + getMessage: (messageId) => get().messages[messageId], + + getYamlSchema: (schemaName) => get().yamlSchemas[schemaName], + + resetSchemaState: () => { + set({ + loaded: false, + loading: false, + serverSchemas: {}, + moduleTypes: initialModuleTypes, + moduleTypeMap: initialModuleTypeMap, + stepTypeMap: initialStepTypeMap, + coercionRules: initialCoercionRules, + contracts: {}, + contractOwnerIndex: {}, + messages: {}, + yamlSchemas: initialYamlSchemas, + bundleLoaded: false, + services: [], + servicesLoaded: false, + }); + }, })); export default useModuleSchemaStore; diff --git a/src/types/editor.ts b/src/types/editor.ts index 154ec35..9900ae3 100644 --- a/src/types/editor.ts +++ b/src/types/editor.ts @@ -75,6 +75,8 @@ export interface WorkflowEditorProps { onSchemaRequest?: () => Promise; /** Called when editor needs plugin schemas */ onPluginSchemaRequest?: () => Promise; + /** Called when editor needs the canonical Workflow editor contract bundle */ + onEditorBundleRequest?: () => Promise; /** Called when editor detects file: references in YAML and needs the host to resolve them. * The host reads the file at the given path (relative to the open document) and returns its content. * Returns null if file not found. */ @@ -130,6 +132,92 @@ export interface PluginSchemaData { modules: Record; } +export type EditorContractMode = 'strict' | 'proto_with_legacy' | 'legacy'; + +export type JsonSchemaObject = Record; + +export interface EditorYamlSchemas { + app: JsonSchemaObject; + infra?: JsonSchemaObject; + wfctl?: JsonSchemaObject; + [schemaName: string]: JsonSchemaObject | undefined; +} + +export interface EditorContractDescriptor { + id: string; + plugin?: string; + ownerType: 'module' | 'step' | 'service'; + ownerKey: string; + mode: EditorContractMode; + requestMessage?: string; + responseMessage?: string; + configMessage?: string; + descriptorSetRef?: string; + source: 'builtin' | 'plugin-manifest' | 'plugin-contracts-json' | 'live-plugin'; + [field: string]: unknown; +} + +export interface EditorMessageFieldDescriptor { + name: string; + type?: string; + label?: string; + number?: number; + repeated?: boolean; + required?: boolean; + description?: string; + defaultValue?: unknown; + [field: string]: unknown; +} + +export interface EditorMessageDescriptor { + id: string; + name: string; + fullName?: string; + package?: string; + fields?: EditorMessageFieldDescriptor[]; + [field: string]: unknown; +} + +export interface EditorContractBundle { + version: string; + workflowVersion?: string; + moduleSchemas: Record; + stepSchemas?: Record; + coercionRules: Record; + contracts?: Record; + messages?: Record; + schemas: EditorYamlSchemas; + snippets?: unknown[]; + descriptorSets?: Record; + dslReference?: unknown; + [field: string]: unknown; +} + +export interface EngineBundleModuleSchema { + type?: string; + label?: string; + category?: string; + description?: string; + inputs?: { name: string; type: string; description?: string }[]; + outputs?: { name: string; type: string; description?: string }[]; + configFields?: import('./workflow').ConfigFieldDef[]; + defaultConfig?: Record; + maxIncoming?: number | null; + maxOutgoing?: number | null; + ioSignature?: import('./workflow').IOSignature; + [field: string]: unknown; +} + +export interface EngineBundleStepSchema { + type?: string; + plugin?: string; + description?: string; + configFields?: import('./workflow').ConfigFieldDef[]; + outputs?: { key: string; type: string; description?: string }[]; + readKeys?: string[]; + [field: string]: unknown; +} + /** Server-side module schema (matches moduleSchemaStore's existing format) */ export interface ServerModuleSchema { label?: string;