From 447f1360688180a134afdaaa610c7d7d23c12860 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:21:04 +0000 Subject: [PATCH 1/7] Initial plan From 0928bc5143c9728e42ea6e265ea19b4dc2e10e60 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:33:35 +0000 Subject: [PATCH 2/7] Fix YAML reformatting: preserve unknown top-level keys, fix triggers injection, fix parseYamlSafe consistency Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-editor/sessions/fdecd5e6-5e99-4497-be49-ba1ed4033a96 Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- src/stores/workflowStore.ts | 32 ++- src/types/workflow.ts | 2 + src/utils/serialization-bugs.test.ts | 296 ++++++++++++++++++++++++++- src/utils/serialization.ts | 109 ++++++++-- 4 files changed, 420 insertions(+), 19 deletions(-) diff --git a/src/stores/workflowStore.ts b/src/stores/workflowStore.ts index 2c57bd3..8586082 100644 --- a/src/stores/workflowStore.ts +++ b/src/stores/workflowStore.ts @@ -105,6 +105,14 @@ interface WorkflowStore { importedTriggers: Record; importedPipelines: Record; + /** + * Passthrough metadata from the most recently imported WorkflowConfig. + * Stores non-visual top-level fields (name, version, _originalKeys, _extraTopLevelKeys, imports, + * requires, platform, infrastructure, sidecars) so that exportToConfig() can reconstruct + * a complete WorkflowConfig without losing unknown keys or reformatting structure. + */ + importedPassthrough: Omit | null; + // Multi-file resolution: maps module name → source file path sourceMap: Map; setSourceMap: (sourceMap: Map) => void; @@ -218,6 +226,7 @@ const useWorkflowStore = create()( importedWorkflows: {}, importedTriggers: {}, importedPipelines: {}, + importedPassthrough: null, // Multi-file resolution sourceMap: new Map(), @@ -453,9 +462,15 @@ const useWorkflowStore = create()( }, exportToConfig: () => { - const { nodes, edges, importedWorkflows, importedTriggers, importedPipelines } = get(); + const { nodes, edges, importedWorkflows, importedTriggers, importedPipelines, importedPassthrough } = get(); const moduleTypeMap = useModuleSchemaStore.getState().moduleTypeMap; - const config = nodesToConfig(nodes, edges, moduleTypeMap); + // Pass importedPassthrough as originalConfig so that nodesToConfig can restore + // non-visual fields: name, version, _originalKeys, _extraTopLevelKeys, imports, + // requires, platform, infrastructure, sidecars. + const originalConfig: import('../types/workflow.ts').WorkflowConfig | undefined = importedPassthrough + ? { modules: [], workflows: {}, triggers: {}, ...importedPassthrough } + : undefined; + const config = nodesToConfig(nodes, edges, moduleTypeMap, originalConfig); if (Object.keys(config.workflows).length === 0 && Object.keys(importedWorkflows).length > 0) { config.workflows = importedWorkflows; } @@ -484,6 +499,18 @@ const useWorkflowStore = create()( get().pushHistory(); const moduleTypeMap = useModuleSchemaStore.getState().moduleTypeMap; const { nodes, edges } = configToNodes(config, moduleTypeMap, sourceMap); + // Extract passthrough metadata (everything except the visual module/workflow/trigger data) + const importedPassthrough: WorkflowStore['importedPassthrough'] = { + ...(config.name !== undefined ? { name: config.name } : {}), + ...(config.version !== undefined ? { version: config.version } : {}), + ...(config._originalKeys ? { _originalKeys: config._originalKeys } : {}), + ...(config._extraTopLevelKeys ? { _extraTopLevelKeys: config._extraTopLevelKeys } : {}), + ...(config.imports ? { imports: config.imports } : {}), + ...(config.requires ? { requires: config.requires } : {}), + ...(config.platform ? { platform: config.platform } : {}), + ...(config.infrastructure ? { infrastructure: config.infrastructure } : {}), + ...(config.sidecars ? { sidecars: config.sidecars } : {}), + }; const updates: Partial = { nodes, edges, @@ -491,6 +518,7 @@ const useWorkflowStore = create()( importedWorkflows: config.workflows ?? {}, importedTriggers: config.triggers ?? {}, importedPipelines: config.pipelines ?? {}, + importedPassthrough, }; if (sourceMap) { updates.sourceMap = sourceMap; diff --git a/src/types/workflow.ts b/src/types/workflow.ts index 2b14df2..8fe68ea 100644 --- a/src/types/workflow.ts +++ b/src/types/workflow.ts @@ -23,6 +23,8 @@ export interface WorkflowConfig { infrastructure?: Record; sidecars?: unknown[]; _originalKeys?: string[]; + /** Preserves unknown top-level keys that are not part of the known schema (e.g. engine:, custom config blocks). */ + _extraTopLevelKeys?: Record; } // Workflow section types for edge extraction diff --git a/src/utils/serialization-bugs.test.ts b/src/utils/serialization-bugs.test.ts index 92bd0fe..ed6ef83 100644 --- a/src/utils/serialization-bugs.test.ts +++ b/src/utils/serialization-bugs.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { configToNodes, nodesToConfig, configToYaml, parseYamlSafe } from './serialization.ts'; +import { configToNodes, nodesToConfig, configToYaml, parseYamlSafe, parseYaml } from './serialization.ts'; import { MODULE_TYPE_MAP } from '../types/workflow.ts'; describe('Bug 1: name and version round-trip', () => { @@ -136,3 +136,297 @@ triggers: {} expect(isPartial).toBe(false); }); }); + +describe('Bug 4: unknown top-level keys (e.g. engine:) must not be dropped', () => { + it('parseYaml captures unknown top-level keys in _extraTopLevelKeys', () => { + const yamlText = ` +name: my-service +engine: + validation: + templateRefs: warn +modules: [] +workflows: {} +`; + const config = parseYaml(yamlText); + expect(config._extraTopLevelKeys).toBeDefined(); + expect(config._extraTopLevelKeys!['engine']).toEqual({ validation: { templateRefs: 'warn' } }); + }); + + it('parseYamlSafe captures unknown top-level keys in _extraTopLevelKeys', () => { + const yamlText = ` +name: my-service +engine: + validation: + templateRefs: warn +modules: [] +`; + const { config } = parseYamlSafe(yamlText); + expect(config._extraTopLevelKeys).toBeDefined(); + expect(config._extraTopLevelKeys!['engine']).toEqual({ validation: { templateRefs: 'warn' } }); + }); + + it('configToYaml emits unknown top-level keys', () => { + const yamlText = ` +name: my-service +engine: + validation: + templateRefs: warn +modules: [] +workflows: {} +`; + const config = parseYaml(yamlText); + const out = configToYaml(config); + expect(out).toContain('engine:'); + expect(out).toContain('templateRefs: warn'); + }); + + it('nodesToConfig passes through _extraTopLevelKeys from originalConfig', () => { + const yamlText = ` +name: my-service +engine: + validation: + templateRefs: warn +modules: + - name: server + type: http.server + config: + address: ":8080" +workflows: {} +`; + const config = parseYaml(yamlText); + const { nodes, edges } = configToNodes(config, MODULE_TYPE_MAP); + const exported = nodesToConfig(nodes, edges, MODULE_TYPE_MAP, config); + expect(exported._extraTopLevelKeys).toBeDefined(); + expect(exported._extraTopLevelKeys!['engine']).toEqual({ validation: { templateRefs: 'warn' } }); + const out = configToYaml(exported); + expect(out).toContain('engine:'); + }); + + it('full round-trip preserves engine block and original key ordering', () => { + const yamlText = `name: my-service +version: "1.0" +engine: + validation: + templateRefs: warn +modules: + - name: server + type: http.server + config: + address: ':8080' +pipelines: + health: + trigger: + type: http + method: GET + path: /healthz +`; + const config = parseYaml(yamlText); + const { nodes, edges } = configToNodes(config, MODULE_TYPE_MAP); + const exported = nodesToConfig(nodes, edges, MODULE_TYPE_MAP, config); + const out = configToYaml(exported); + + // engine block must be present + expect(out).toContain('engine:'); + expect(out).toContain('templateRefs: warn'); + + // Key ordering: name comes before engine comes before modules + const nameIdx = out.indexOf('name:'); + const engineIdx = out.indexOf('engine:'); + const modulesIdx = out.indexOf('modules:'); + expect(nameIdx).toBeGreaterThanOrEqual(0); + expect(engineIdx).toBeGreaterThan(nameIdx); + expect(modulesIdx).toBeGreaterThan(engineIdx); + }); +}); + +describe('Bug 5: triggers: {} must not be injected when not in original', () => { + it('does not add triggers: {} when original YAML had no triggers key', () => { + const yamlText = ` +name: my-service +modules: + - name: server + type: http.server + config: + address: ":8080" +pipelines: + health: + trigger: + type: http + method: GET + path: /healthz +`; + const config = parseYaml(yamlText); + // triggers not in original keys + expect(config._originalKeys).not.toContain('triggers'); + + const { nodes, edges } = configToNodes(config, MODULE_TYPE_MAP); + const exported = nodesToConfig(nodes, edges, MODULE_TYPE_MAP, config); + const out = configToYaml(exported); + + // triggers: {} must NOT appear in output + expect(out).not.toMatch(/^triggers:/m); + }); +}); + +describe('Bug 6: parseYamlSafe is consistent with parseYaml for all fields', () => { + it('parseYamlSafe preserves imports, requires, platform, infrastructure, sidecars', () => { + const yamlText = ` +name: my-service +imports: + - other.yaml +requires: + some-service: ">=1.0" +platform: + target: kubernetes +infrastructure: + database: + type: postgres +sidecars: + - name: proxy + image: envoy:latest +modules: [] +`; + const { config } = parseYamlSafe(yamlText); + expect(config.imports).toEqual(['other.yaml']); + expect(config.requires).toEqual({ 'some-service': '>=1.0' }); + expect(config.platform).toEqual({ target: 'kubernetes' }); + expect(config.infrastructure).toEqual({ database: { type: 'postgres' } }); + expect(config.sidecars).toHaveLength(1); + }); +}); + + +describe('Bug 1: name and version round-trip', () => { + it('preserves name and version through parse → configToNodes → nodesToConfig', () => { + const yamlText = ` +name: my-app +version: "1.0.0" +modules: + - name: http-server + type: http.server + config: + port: 8080 +workflows: + http: + router: router + server: http-server + routes: [] +`; + const { config } = parseYamlSafe(yamlText); + expect(config.name).toBe('my-app'); + expect(config.version).toBe('1.0.0'); + + const { nodes, edges } = configToNodes(config, MODULE_TYPE_MAP); + const exported = nodesToConfig(nodes, edges, MODULE_TYPE_MAP, config); + expect(exported.name).toBe('my-app'); + expect(exported.version).toBe('1.0.0'); + }); + + it('preserves name and version in configToYaml output', () => { + const yamlText = ` +name: my-app +version: "2.3.1" +modules: [] +workflows: {} +triggers: {} +`; + const { config } = parseYamlSafe(yamlText); + const out = configToYaml(config); + expect(out).toContain('name: my-app'); + expect(out).toContain('version:'); + }); + + it('does not emit name/version when not present', () => { + const yamlText = ` +modules: [] +workflows: {} +triggers: {} +`; + const { config } = parseYamlSafe(yamlText); + const out = configToYaml(config); + expect(out).not.toContain('name:'); + expect(out).not.toContain('version:'); + }); +}); + +describe('Bug 2: partial files do not get empty modules/workflows added', () => { + it('partial file with only pipelines does not get empty modules/workflows/triggers', () => { + const yamlText = ` +pipelines: + my-pipeline: + steps: + - name: greet + type: step.set + config: + values: + hello: world +`; + const { config } = parseYamlSafe(yamlText); + const { nodes, edges } = configToNodes(config, MODULE_TYPE_MAP); + const exported = nodesToConfig(nodes, edges, MODULE_TYPE_MAP, config); + const out = configToYaml(exported); + expect(out).not.toContain('modules:'); + expect(out).not.toContain('workflows:'); + expect(out).not.toContain('triggers:'); + expect(out).toContain('pipelines:'); + }); + + it('full config with modules/workflows/triggers preserves them even when empty', () => { + const yamlText = ` +modules: [] +workflows: {} +triggers: {} +`; + const { config } = parseYamlSafe(yamlText); + const out = configToYaml(config); + expect(out).toContain('modules:'); + expect(out).toContain('workflows:'); + expect(out).toContain('triggers:'); + }); + + it('config with non-empty modules always includes modules in output', () => { + const yamlText = ` +pipelines: + p: {} +modules: + - name: s + type: http.server +`; + const { config } = parseYamlSafe(yamlText); + const out = configToYaml(config); + expect(out).toContain('modules:'); + }); +}); + +describe('Bug 3: partial config detection', () => { + it('detects partial config (pipelines only, no modules)', () => { + const yamlText = ` +pipelines: + test: + steps: + - name: s + type: step.set +`; + const { config } = parseYamlSafe(yamlText); + expect(config.modules.length).toBe(0); + const isPartial = + config.modules.length === 0 && + Object.keys(config.pipelines ?? {}).length > 0; + expect(isPartial).toBe(true); + }); + + it('does not flag full config as partial', () => { + const yamlText = ` +modules: + - name: s + type: http.server +workflows: {} +triggers: {} +`; + const { config } = parseYamlSafe(yamlText); + const isPartial = + config.modules.length === 0 && + Object.keys(config.pipelines ?? {}).length > 0; + expect(isPartial).toBe(false); + }); +}); diff --git a/src/utils/serialization.ts b/src/utils/serialization.ts index e0ed137..26da7d8 100644 --- a/src/utils/serialization.ts +++ b/src/utils/serialization.ts @@ -540,6 +540,9 @@ export function nodesToConfig( if (originalKeys) { result._originalKeys = originalKeys; } + if (originalConfig?._extraTopLevelKeys && Object.keys(originalConfig._extraTopLevelKeys).length > 0) { + result._extraTopLevelKeys = originalConfig._extraTopLevelKeys; + } return result; } @@ -809,31 +812,76 @@ export function configToYaml(config: WorkflowConfig): string { // Strip internal tracking fields and omit empty top-level arrays/objects // that were not present in the original YAML const originalKeys = config._originalKeys; - const out: Record = {}; - - // Field order: name, version, imports, requires, modules, workflows, triggers, pipelines, platform, infrastructure, sidecars - if (config.name !== undefined) out.name = config.name; - if (config.version !== undefined) out.version = config.version; - if (config.imports && config.imports.length > 0) out.imports = config.imports; - if (config.requires && Object.keys(config.requires).length > 0) out.requires = config.requires; + // Determine which known fields to include const includeModules = !originalKeys || originalKeys.includes('modules') || (config.modules?.length ?? 0) > 0; - if (includeModules && config.modules !== undefined) out.modules = config.modules; - const includeWorkflows = !originalKeys || originalKeys.includes('workflows') || Object.keys(config.workflows ?? {}).length > 0; - if (includeWorkflows && config.workflows !== undefined) out.workflows = config.workflows; - const includeTriggers = !originalKeys || originalKeys.includes('triggers') || Object.keys(config.triggers ?? {}).length > 0; - if (includeTriggers && config.triggers !== undefined) out.triggers = config.triggers; - if (config.pipelines && Object.keys(config.pipelines).length > 0) out.pipelines = config.pipelines; - if (config.platform && Object.keys(config.platform).length > 0) out.platform = config.platform; - if (config.infrastructure && Object.keys(config.infrastructure).length > 0) out.infrastructure = config.infrastructure; - if (config.sidecars && config.sidecars.length > 0) out.sidecars = config.sidecars; + // Build a map of all key→value pairs that should be emitted + const valueMap: Record = {}; + if (config.name !== undefined) valueMap.name = config.name; + if (config.version !== undefined) valueMap.version = config.version; + if (config.imports && config.imports.length > 0) valueMap.imports = config.imports; + if (config.requires && Object.keys(config.requires).length > 0) valueMap.requires = config.requires; + if (includeModules && config.modules !== undefined) valueMap.modules = config.modules; + if (includeWorkflows && config.workflows !== undefined) valueMap.workflows = config.workflows; + if (includeTriggers && config.triggers !== undefined) valueMap.triggers = config.triggers; + if (config.pipelines && Object.keys(config.pipelines).length > 0) valueMap.pipelines = config.pipelines; + if (config.platform && Object.keys(config.platform).length > 0) valueMap.platform = config.platform; + if (config.infrastructure && Object.keys(config.infrastructure).length > 0) valueMap.infrastructure = config.infrastructure; + if (config.sidecars && config.sidecars.length > 0) valueMap.sidecars = config.sidecars; + // Include any extra/unknown top-level keys (e.g. engine:, custom config blocks) + if (config._extraTopLevelKeys) { + for (const [k, v] of Object.entries(config._extraTopLevelKeys)) { + valueMap[k] = v; + } + } + + // Build the output object — if we have the original key order, honour it so that + // the serialised YAML preserves the author's top-level key ordering. + const out: Record = {}; + if (originalKeys && originalKeys.length > 0) { + // First: emit keys in their original order + for (const key of originalKeys) { + if (key in valueMap) { + out[key] = valueMap[key]; + } + } + // Then: emit any new keys that weren't in the original (e.g. added by the editor) + for (const [key, value] of Object.entries(valueMap)) { + if (!originalKeys.includes(key)) { + out[key] = value; + } + } + } else { + // No original key order — use the fixed default order: + // name, version, imports, requires, modules, workflows, triggers, pipelines, platform, infrastructure, sidecars, [extra] + for (const [key, value] of Object.entries(valueMap)) { + out[key] = value; + } + } return yaml.dump(out, { lineWidth: -1, noRefs: true, sortKeys: false }); } +/** Known top-level keys in WorkflowConfig — anything else is preserved as an extra key. */ +const KNOWN_TOP_LEVEL_KEYS = new Set([ + 'name', 'version', 'modules', 'workflows', 'triggers', + 'pipelines', 'imports', 'requires', 'platform', 'infrastructure', 'sidecars', +]); + +/** Extract unknown top-level keys from a parsed YAML object into _extraTopLevelKeys. */ +function extractExtraTopLevelKeys(parsed: Record): Record | undefined { + const extra: Record = {}; + for (const key of Object.keys(parsed)) { + if (!KNOWN_TOP_LEVEL_KEYS.has(key)) { + extra[key] = parsed[key]; + } + } + return Object.keys(extra).length > 0 ? extra : undefined; +} + export function parseYaml(text: string): WorkflowConfig { try { const parsed = yaml.load(text) as Record; @@ -871,6 +919,10 @@ export function parseYaml(text: string): WorkflowConfig { if (parsed.sidecars) { config.sidecars = parsed.sidecars as unknown[]; } + const extraTopLevelKeys = extractExtraTopLevelKeys(parsed); + if (extraTopLevelKeys) { + config._extraTopLevelKeys = extraTopLevelKeys; + } return config; } catch { return { modules: [], workflows: {}, triggers: {}, _originalKeys: [] }; @@ -899,6 +951,25 @@ export function parseYamlSafe(text: string): { config: WorkflowConfig; error?: s if (parsed.pipelines) { config.pipelines = parsed.pipelines as Record; } + if (parsed.imports) { + config.imports = parsed.imports as string[]; + } + if (parsed.requires) { + config.requires = parsed.requires as Record; + } + if (parsed.platform) { + config.platform = parsed.platform as Record; + } + if (parsed.infrastructure) { + config.infrastructure = parsed.infrastructure as Record; + } + if (parsed.sidecars) { + config.sidecars = parsed.sidecars as unknown[]; + } + const extraTopLevelKeys = extractExtraTopLevelKeys(parsed); + if (extraTopLevelKeys) { + config._extraTopLevelKeys = extraTopLevelKeys; + } return { config }; } catch (e) { return { config: { modules: [], workflows: {}, triggers: {}, _originalKeys: [] }, error: (e as Error).message }; @@ -1187,6 +1258,12 @@ export async function resolveImports( const configVersion = (parsed.version ?? application?.version) as string | undefined; if (configName) config.name = configName; if (configVersion) config.version = configVersion; + // Preserve top-level key ordering and unknown keys from the main file + config._originalKeys = Object.keys(parsed); + const extraTopLevelKeys = extractExtraTopLevelKeys(parsed); + if (extraTopLevelKeys) { + config._extraTopLevelKeys = extraTopLevelKeys; + } return { config, From 001b279e26197571290c79f5b4c49d0b0256b7d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:39:23 +0000 Subject: [PATCH 3/7] Address code review: explicit extraTopLevelKeys null check, explicit default key ordering, simplified passthrough extraction Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-editor/sessions/fdecd5e6-5e99-4497-be49-ba1ed4033a96 Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- src/stores/workflowStore.ts | 13 +-- src/utils/serialization-bugs.test.ts | 136 --------------------------- src/utils/serialization.ts | 16 +++- 3 files changed, 15 insertions(+), 150 deletions(-) diff --git a/src/stores/workflowStore.ts b/src/stores/workflowStore.ts index 8586082..3a8ca08 100644 --- a/src/stores/workflowStore.ts +++ b/src/stores/workflowStore.ts @@ -500,17 +500,8 @@ const useWorkflowStore = create()( const moduleTypeMap = useModuleSchemaStore.getState().moduleTypeMap; const { nodes, edges } = configToNodes(config, moduleTypeMap, sourceMap); // Extract passthrough metadata (everything except the visual module/workflow/trigger data) - const importedPassthrough: WorkflowStore['importedPassthrough'] = { - ...(config.name !== undefined ? { name: config.name } : {}), - ...(config.version !== undefined ? { version: config.version } : {}), - ...(config._originalKeys ? { _originalKeys: config._originalKeys } : {}), - ...(config._extraTopLevelKeys ? { _extraTopLevelKeys: config._extraTopLevelKeys } : {}), - ...(config.imports ? { imports: config.imports } : {}), - ...(config.requires ? { requires: config.requires } : {}), - ...(config.platform ? { platform: config.platform } : {}), - ...(config.infrastructure ? { infrastructure: config.infrastructure } : {}), - ...(config.sidecars ? { sidecars: config.sidecars } : {}), - }; + const { modules: _m, workflows: _w, triggers: _t, pipelines: _p, ...passthroughFields } = config; + const importedPassthrough: WorkflowStore['importedPassthrough'] = passthroughFields; const updates: Partial = { nodes, edges, diff --git a/src/utils/serialization-bugs.test.ts b/src/utils/serialization-bugs.test.ts index ed6ef83..5644e48 100644 --- a/src/utils/serialization-bugs.test.ts +++ b/src/utils/serialization-bugs.test.ts @@ -294,139 +294,3 @@ modules: [] expect(config.sidecars).toHaveLength(1); }); }); - - -describe('Bug 1: name and version round-trip', () => { - it('preserves name and version through parse → configToNodes → nodesToConfig', () => { - const yamlText = ` -name: my-app -version: "1.0.0" -modules: - - name: http-server - type: http.server - config: - port: 8080 -workflows: - http: - router: router - server: http-server - routes: [] -`; - const { config } = parseYamlSafe(yamlText); - expect(config.name).toBe('my-app'); - expect(config.version).toBe('1.0.0'); - - const { nodes, edges } = configToNodes(config, MODULE_TYPE_MAP); - const exported = nodesToConfig(nodes, edges, MODULE_TYPE_MAP, config); - expect(exported.name).toBe('my-app'); - expect(exported.version).toBe('1.0.0'); - }); - - it('preserves name and version in configToYaml output', () => { - const yamlText = ` -name: my-app -version: "2.3.1" -modules: [] -workflows: {} -triggers: {} -`; - const { config } = parseYamlSafe(yamlText); - const out = configToYaml(config); - expect(out).toContain('name: my-app'); - expect(out).toContain('version:'); - }); - - it('does not emit name/version when not present', () => { - const yamlText = ` -modules: [] -workflows: {} -triggers: {} -`; - const { config } = parseYamlSafe(yamlText); - const out = configToYaml(config); - expect(out).not.toContain('name:'); - expect(out).not.toContain('version:'); - }); -}); - -describe('Bug 2: partial files do not get empty modules/workflows added', () => { - it('partial file with only pipelines does not get empty modules/workflows/triggers', () => { - const yamlText = ` -pipelines: - my-pipeline: - steps: - - name: greet - type: step.set - config: - values: - hello: world -`; - const { config } = parseYamlSafe(yamlText); - const { nodes, edges } = configToNodes(config, MODULE_TYPE_MAP); - const exported = nodesToConfig(nodes, edges, MODULE_TYPE_MAP, config); - const out = configToYaml(exported); - expect(out).not.toContain('modules:'); - expect(out).not.toContain('workflows:'); - expect(out).not.toContain('triggers:'); - expect(out).toContain('pipelines:'); - }); - - it('full config with modules/workflows/triggers preserves them even when empty', () => { - const yamlText = ` -modules: [] -workflows: {} -triggers: {} -`; - const { config } = parseYamlSafe(yamlText); - const out = configToYaml(config); - expect(out).toContain('modules:'); - expect(out).toContain('workflows:'); - expect(out).toContain('triggers:'); - }); - - it('config with non-empty modules always includes modules in output', () => { - const yamlText = ` -pipelines: - p: {} -modules: - - name: s - type: http.server -`; - const { config } = parseYamlSafe(yamlText); - const out = configToYaml(config); - expect(out).toContain('modules:'); - }); -}); - -describe('Bug 3: partial config detection', () => { - it('detects partial config (pipelines only, no modules)', () => { - const yamlText = ` -pipelines: - test: - steps: - - name: s - type: step.set -`; - const { config } = parseYamlSafe(yamlText); - expect(config.modules.length).toBe(0); - const isPartial = - config.modules.length === 0 && - Object.keys(config.pipelines ?? {}).length > 0; - expect(isPartial).toBe(true); - }); - - it('does not flag full config as partial', () => { - const yamlText = ` -modules: - - name: s - type: http.server -workflows: {} -triggers: {} -`; - const { config } = parseYamlSafe(yamlText); - const isPartial = - config.modules.length === 0 && - Object.keys(config.pipelines ?? {}).length > 0; - expect(isPartial).toBe(false); - }); -}); diff --git a/src/utils/serialization.ts b/src/utils/serialization.ts index 26da7d8..3d7da59 100644 --- a/src/utils/serialization.ts +++ b/src/utils/serialization.ts @@ -540,8 +540,9 @@ export function nodesToConfig( if (originalKeys) { result._originalKeys = originalKeys; } - if (originalConfig?._extraTopLevelKeys && Object.keys(originalConfig._extraTopLevelKeys).length > 0) { - result._extraTopLevelKeys = originalConfig._extraTopLevelKeys; + const extraTopLevelKeys = originalConfig?._extraTopLevelKeys; + if (extraTopLevelKeys && Object.keys(extraTopLevelKeys).length > 0) { + result._extraTopLevelKeys = extraTopLevelKeys; } return result; } @@ -857,8 +858,17 @@ export function configToYaml(config: WorkflowConfig): string { } else { // No original key order — use the fixed default order: // name, version, imports, requires, modules, workflows, triggers, pipelines, platform, infrastructure, sidecars, [extra] + const DEFAULT_ORDER = ['name', 'version', 'imports', 'requires', 'modules', 'workflows', 'triggers', 'pipelines', 'platform', 'infrastructure', 'sidecars']; + for (const key of DEFAULT_ORDER) { + if (key in valueMap) { + out[key] = valueMap[key]; + } + } + // Append any extra top-level keys after the known fields for (const [key, value] of Object.entries(valueMap)) { - out[key] = value; + if (!DEFAULT_ORDER.includes(key)) { + out[key] = value; + } } } From 1f14b2b110f56074037d23832a48f8483c861119 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:41:37 +0000 Subject: [PATCH 4/7] Address code review: move KNOWN_TOP_LEVEL_KEYS before configToYaml, use clearer destructuring names Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-editor/sessions/fdecd5e6-5e99-4497-be49-ba1ed4033a96 Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- src/stores/workflowStore.ts | 2 +- src/utils/serialization.ts | 34 +++++++++++++++++----------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/stores/workflowStore.ts b/src/stores/workflowStore.ts index 3a8ca08..7fd99cb 100644 --- a/src/stores/workflowStore.ts +++ b/src/stores/workflowStore.ts @@ -500,7 +500,7 @@ const useWorkflowStore = create()( const moduleTypeMap = useModuleSchemaStore.getState().moduleTypeMap; const { nodes, edges } = configToNodes(config, moduleTypeMap, sourceMap); // Extract passthrough metadata (everything except the visual module/workflow/trigger data) - const { modules: _m, workflows: _w, triggers: _t, pipelines: _p, ...passthroughFields } = config; + const { modules: _modules, workflows: _workflows, triggers: _triggers, pipelines: _pipelines, ...passthroughFields } = config; const importedPassthrough: WorkflowStore['importedPassthrough'] = passthroughFields; const updates: Partial = { nodes, diff --git a/src/utils/serialization.ts b/src/utils/serialization.ts index 3d7da59..ad41272 100644 --- a/src/utils/serialization.ts +++ b/src/utils/serialization.ts @@ -809,6 +809,23 @@ export function nodeComponentType(moduleType: string): string { return 'infrastructureNode'; } +/** Known top-level keys in WorkflowConfig — anything else is preserved as an extra key. */ +const KNOWN_TOP_LEVEL_KEYS = new Set([ + 'name', 'version', 'modules', 'workflows', 'triggers', + 'pipelines', 'imports', 'requires', 'platform', 'infrastructure', 'sidecars', +]); + +/** Extract unknown top-level keys from a parsed YAML object into _extraTopLevelKeys. */ +function extractExtraTopLevelKeys(parsed: Record): Record | undefined { + const extra: Record = {}; + for (const key of Object.keys(parsed)) { + if (!KNOWN_TOP_LEVEL_KEYS.has(key)) { + extra[key] = parsed[key]; + } + } + return Object.keys(extra).length > 0 ? extra : undefined; +} + export function configToYaml(config: WorkflowConfig): string { // Strip internal tracking fields and omit empty top-level arrays/objects // that were not present in the original YAML @@ -875,23 +892,6 @@ export function configToYaml(config: WorkflowConfig): string { return yaml.dump(out, { lineWidth: -1, noRefs: true, sortKeys: false }); } -/** Known top-level keys in WorkflowConfig — anything else is preserved as an extra key. */ -const KNOWN_TOP_LEVEL_KEYS = new Set([ - 'name', 'version', 'modules', 'workflows', 'triggers', - 'pipelines', 'imports', 'requires', 'platform', 'infrastructure', 'sidecars', -]); - -/** Extract unknown top-level keys from a parsed YAML object into _extraTopLevelKeys. */ -function extractExtraTopLevelKeys(parsed: Record): Record | undefined { - const extra: Record = {}; - for (const key of Object.keys(parsed)) { - if (!KNOWN_TOP_LEVEL_KEYS.has(key)) { - extra[key] = parsed[key]; - } - } - return Object.keys(extra).length > 0 ? extra : undefined; -} - export function parseYaml(text: string): WorkflowConfig { try { const parsed = yaml.load(text) as Record; From 917109ec5086dc3404547144b7637f149ce7254b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:16:36 +0000 Subject: [PATCH 5/7] Fix review feedback: clearCanvas resets importedPassthrough, exclude pipelines from passthrough type, fix buildMainFileContent for multi-file key ordering and unknown keys Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-editor/sessions/cbe50c21-b5cc-4238-ab7b-36cb53bc6e40 Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- src/stores/workflowStore.ts | 5 ++-- src/utils/serialization.ts | 47 +++++++++++++++++++++++-------------- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/src/stores/workflowStore.ts b/src/stores/workflowStore.ts index 7fd99cb..0bdaef9 100644 --- a/src/stores/workflowStore.ts +++ b/src/stores/workflowStore.ts @@ -110,8 +110,9 @@ interface WorkflowStore { * Stores non-visual top-level fields (name, version, _originalKeys, _extraTopLevelKeys, imports, * requires, platform, infrastructure, sidecars) so that exportToConfig() can reconstruct * a complete WorkflowConfig without losing unknown keys or reformatting structure. + * Pipelines are excluded here because they are tracked separately via importedPipelines. */ - importedPassthrough: Omit | null; + importedPassthrough: Omit | null; // Multi-file resolution: maps module name → source file path sourceMap: Map; @@ -520,7 +521,7 @@ const useWorkflowStore = create()( clearCanvas: () => { get().pushHistory(); - set({ nodes: [], edges: [], selectedNodeId: null, selectedEdgeId: null, nodeCounter: 0, importedWorkflows: {}, importedTriggers: {}, importedPipelines: {} }); + set({ nodes: [], edges: [], selectedNodeId: null, selectedEdgeId: null, nodeCounter: 0, importedWorkflows: {}, importedTriggers: {}, importedPipelines: {}, importedPassthrough: null }); }, exportLayout: (): LayoutData => { diff --git a/src/utils/serialization.ts b/src/utils/serialization.ts index ad41272..c01f242 100644 --- a/src/utils/serialization.ts +++ b/src/utils/serialization.ts @@ -813,6 +813,8 @@ export function nodeComponentType(moduleType: string): string { const KNOWN_TOP_LEVEL_KEYS = new Set([ 'name', 'version', 'modules', 'workflows', 'triggers', 'pipelines', 'imports', 'requires', 'platform', 'infrastructure', 'sidecars', + // application: is the legacy multi-file format key — handled by resolveImports, not a passthrough extra + 'application', ]); /** Extract unknown top-level keys from a parsed YAML object into _extraTopLevelKeys. */ @@ -1361,6 +1363,8 @@ export function exportMainFileYaml( /** * Internal helper: build the main-file YAML string and collect imported file paths. + * Delegates to configToYaml so that _extraTopLevelKeys and _originalKeys ordering + * are honoured for the main-file content (same as single-file round-trips). */ function buildMainFileContent( config: WorkflowConfig, @@ -1368,20 +1372,7 @@ function buildMainFileContent( filePipelines: Map>, ): { yaml: string; importedFiles: string[] } { const mainModules = fileModules.get(null) ?? []; - const mainConfig: Record = {}; - if (config.name !== undefined) mainConfig.name = config.name; - if (config.version !== undefined) mainConfig.version = config.version; - mainConfig.modules = mainModules; - if (Object.keys(config.workflows).length > 0) { - mainConfig.workflows = config.workflows; - } - if (Object.keys(config.triggers).length > 0) { - mainConfig.triggers = config.triggers; - } const mainPipelines = filePipelines.get(null); - if (mainPipelines && Object.keys(mainPipelines).length > 0) { - mainConfig.pipelines = mainPipelines; - } // Collect all non-null file paths across modules and pipelines const importedFiles = [ @@ -1390,11 +1381,33 @@ function buildMainFileContent( ...[...filePipelines.keys()].filter((k) => k !== null), ]), ] as string[]; - if (importedFiles.length > 0) { - mainConfig.imports = importedFiles; - } - return { yaml: yaml.dump(mainConfig, { lineWidth: -1, noRefs: true, sortKeys: false }), importedFiles }; + // For multi-file main files, ensure 'modules' is in _originalKeys so configToYaml + // always includes the modules section (even if empty, since sub-files provide modules + // via imports:). Without this, configs whose original main file had no top-level + // 'modules:' key (e.g. application: format) would omit modules from the export. + const computedOriginalKeys = config._originalKeys + ? (config._originalKeys.includes('modules') + ? config._originalKeys + : [...config._originalKeys, 'modules']) + : undefined; + + // Build a temporary config that represents only the main-file content. + // By reusing the full config (with its _originalKeys, _extraTopLevelKeys, workflows, + // triggers, name, version, etc.) but substituting the main-file-only modules, pipelines, + // and the dynamically computed imports list, we can delegate to configToYaml and get + // correct key ordering and unknown-key preservation for free. + const mainOnlyConfig: WorkflowConfig = { + ...config, + modules: mainModules, + // Override imports with the computed list of imported file paths + imports: importedFiles.length > 0 ? importedFiles : undefined, + // Only include main-file pipelines + pipelines: mainPipelines && Object.keys(mainPipelines).length > 0 ? mainPipelines : undefined, + _originalKeys: computedOriginalKeys, + }; + + return { yaml: configToYaml(mainOnlyConfig), importedFiles }; } /** From fc9c9c714caad5f60745f7914eb48e87cfc8fd98 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:18:59 +0000 Subject: [PATCH 6/7] Refactor nested ternary in buildMainFileContent to if-else for clarity Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-editor/sessions/cbe50c21-b5cc-4238-ab7b-36cb53bc6e40 Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- src/utils/serialization.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/utils/serialization.ts b/src/utils/serialization.ts index c01f242..4bcfb4e 100644 --- a/src/utils/serialization.ts +++ b/src/utils/serialization.ts @@ -1386,11 +1386,10 @@ function buildMainFileContent( // always includes the modules section (even if empty, since sub-files provide modules // via imports:). Without this, configs whose original main file had no top-level // 'modules:' key (e.g. application: format) would omit modules from the export. - const computedOriginalKeys = config._originalKeys - ? (config._originalKeys.includes('modules') - ? config._originalKeys - : [...config._originalKeys, 'modules']) - : undefined; + let computedOriginalKeys = config._originalKeys; + if (computedOriginalKeys && !computedOriginalKeys.includes('modules')) { + computedOriginalKeys = [...computedOriginalKeys, 'modules']; + } // Build a temporary config that represents only the main-file content. // By reusing the full config (with its _originalKeys, _extraTopLevelKeys, workflows, From 6c5ce0e8be531517422ab0d2e0a98c45a4531478 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:21:06 +0000 Subject: [PATCH 7/7] Style: use adjustedOriginalKeys and conditional spread for imports in buildMainFileContent Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-editor/sessions/cbe50c21-b5cc-4238-ab7b-36cb53bc6e40 Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- src/utils/serialization.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/utils/serialization.ts b/src/utils/serialization.ts index 4bcfb4e..f209c4d 100644 --- a/src/utils/serialization.ts +++ b/src/utils/serialization.ts @@ -1386,10 +1386,11 @@ function buildMainFileContent( // always includes the modules section (even if empty, since sub-files provide modules // via imports:). Without this, configs whose original main file had no top-level // 'modules:' key (e.g. application: format) would omit modules from the export. - let computedOriginalKeys = config._originalKeys; - if (computedOriginalKeys && !computedOriginalKeys.includes('modules')) { - computedOriginalKeys = [...computedOriginalKeys, 'modules']; - } + const sourceKeys = config._originalKeys; + const adjustedOriginalKeys = + sourceKeys && !sourceKeys.includes('modules') + ? [...sourceKeys, 'modules'] + : sourceKeys; // Build a temporary config that represents only the main-file content. // By reusing the full config (with its _originalKeys, _extraTopLevelKeys, workflows, @@ -1399,11 +1400,12 @@ function buildMainFileContent( const mainOnlyConfig: WorkflowConfig = { ...config, modules: mainModules, - // Override imports with the computed list of imported file paths - imports: importedFiles.length > 0 ? importedFiles : undefined, + // Override imports with the computed list of imported file paths (omit the property + // entirely when there are no sub-files so configToYaml does not emit `imports: []`) + ...(importedFiles.length > 0 ? { imports: importedFiles } : { imports: undefined }), // Only include main-file pipelines pipelines: mainPipelines && Object.keys(mainPipelines).length > 0 ? mainPipelines : undefined, - _originalKeys: computedOriginalKeys, + _originalKeys: adjustedOriginalKeys, }; return { yaml: configToYaml(mainOnlyConfig), importedFiles };