From edc1dce6f54e2ddfa7ad40c2e326a14b16453b33 Mon Sep 17 00:00:00 2001 From: Omar Baradei Date: Mon, 27 Jul 2026 16:48:11 -0700 Subject: [PATCH] fix(workflows): normalize object-shaped manifest inputs at the source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorkflowExtension.inputs is documented as an array of plain type strings ('image' | 'text' | 'mesh' | 'audio'), and validateWorkflowPreflight relies on that to match declared input types against connected edges by strict equality. Some extension manifests (e.g. modly-Codex-image-extension's image-to-image node) instead declare inputs as an array of objects (name/label/type/required per slot). A string never equals such an object, so every declared input on that node is judged missing, and formatType's fallback branch mislabels the (object-typed) missing type as "text" regardless of what's actually missing — producing exactly the reported symptom: 'Image to Image needs an incoming text connection' on a node that has a valid image connection. Normalize inputs once, in buildAllWorkflowExtensions, at the manifest boundary where raw untyped JSON becomes a typed WorkflowExtension — mapping object entries to their .type field so everything downstream can trust the documented shape. Also make formatType fall back to String(type) instead of silently defaulting to 'text', so a future malformed type fails loudly instead of mislabeling. Addresses the first item in #241 (object-shaped inputs mislabeled as text); the requirements-install-order and Codex-extension-SDK items in that report are separate issues, untouched here. --- src/areas/workflows/mockExtensions.test.mjs | 90 +++++++++++++++++++++ src/areas/workflows/mockExtensions.ts | 19 ++++- src/areas/workflows/preflight.ts | 3 +- 3 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 src/areas/workflows/mockExtensions.test.mjs diff --git a/src/areas/workflows/mockExtensions.test.mjs b/src/areas/workflows/mockExtensions.test.mjs new file mode 100644 index 00000000..f1e22221 --- /dev/null +++ b/src/areas/workflows/mockExtensions.test.mjs @@ -0,0 +1,90 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildSync } from 'esbuild' +import { createRequire } from 'node:module' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +// Bundle mockExtensions.ts into CJS. Its only imports are type-only +// (@shared/*), so esbuild erases them and no path-alias resolution is +// required. +function loadModule() { + const outfile = join(mkdtempSync(join(tmpdir(), 'modly-mockExtensions-test-')), 'mockExtensions.cjs') + const require = createRequire(import.meta.url) + const result = buildSync({ + entryPoints: [resolve('src/areas/workflows/mockExtensions.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + }) + writeFileSync(outfile, result.outputFiles[0].text, 'utf8') + return require(outfile) +} + +function processExt(node) { + return { + type: 'process', + id: 'pack', + name: 'Pack', + author: 'tester', + trusted: true, + builtin: false, + entry: 'entry.py', + nodes: [node], + } +} + +test('object-shaped manifest inputs are normalized to plain type strings', () => { + const { buildAllWorkflowExtensions } = loadModule() + // Mirrors a real-world extension manifest that declared four named image + // slots as objects instead of the documented plain-string shape. + const ext = processExt({ + id: 'image-to-image', + name: 'Image to Image', + input: 'image', + inputs: [ + { name: 'front', label: 'Primary image', type: 'image', required: true }, + { name: 'left', label: 'Image 2', type: 'image', required: false }, + { name: 'back', label: 'Image 3', type: 'image', required: false }, + { name: 'right', label: 'Image 4', type: 'image', required: false }, + ], + output: 'image', + paramsSchema: [], + }) + + const [built] = buildAllWorkflowExtensions([], [ext]) + assert.deepEqual(built.inputs, ['image', 'image', 'image', 'image']) +}) + +test('already plain-string manifest inputs pass through unchanged', () => { + const { buildAllWorkflowExtensions } = loadModule() + const ext = processExt({ + id: 'sketch-to-photo', + name: 'Sketch to Photo', + input: 'image', + inputs: ['image', 'text'], + inputLabels: ['Your sketch', 'Look description'], + output: 'image', + paramsSchema: [], + }) + + const [built] = buildAllWorkflowExtensions([], [ext]) + assert.deepEqual(built.inputs, ['image', 'text']) + assert.deepEqual(built.inputLabels, ['Your sketch', 'Look description']) +}) + +test('nodes without a multi-input array are left with inputs undefined', () => { + const { buildAllWorkflowExtensions } = loadModule() + const ext = processExt({ + id: 'process-node', + name: 'Process Node', + input: 'mesh', + output: 'mesh', + paramsSchema: [], + }) + + const [built] = buildAllWorkflowExtensions([], [ext]) + assert.equal(built.inputs, undefined) +}) diff --git a/src/areas/workflows/mockExtensions.ts b/src/areas/workflows/mockExtensions.ts index 629a12be..f193a34b 100644 --- a/src/areas/workflows/mockExtensions.ts +++ b/src/areas/workflows/mockExtensions.ts @@ -31,6 +31,21 @@ function applyParamDefaults( ) } +// Extension manifests are untyped JSON at runtime. `inputs` is documented as +// an array of plain type strings, but some manifests declare it as an array +// of objects instead (e.g. `{ name, label, type, required }` slots). A string +// never equals such an object, so the workflow preflight check silently +// treats every declared input as missing. Normalize once here, at the +// manifest boundary, so everything downstream can trust the documented type. +function normalizeInputs( + raw: WorkflowExtension['inputs'], +): WorkflowExtension['inputs'] { + if (!raw) return raw + return raw.map((entry) => + typeof entry === 'string' ? entry : (entry as unknown as { type: WorkflowExtension['input'] }).type, + ) +} + export function buildAllWorkflowExtensions( modelExtensions: ModelExtension[], processExtensions: ProcessExtension[], @@ -48,7 +63,7 @@ export function buildAllWorkflowExtensions( name: node.name, description: ext.description ?? '', input: node.input, - inputs: node.inputs, + inputs: normalizeInputs(node.inputs), inputLabels: node.inputLabels, output: node.output, params: applyParamDefaults(node.paramsSchema as ParamSchema[], node.paramDefaults), @@ -69,7 +84,7 @@ export function buildAllWorkflowExtensions( name: node.name, description: ext.description ?? '', input: node.input, - inputs: node.inputs, + inputs: normalizeInputs(node.inputs), inputLabels: node.inputLabels, output: node.output, params: applyParamDefaults(node.paramsSchema as ParamSchema[], node.paramDefaults), diff --git a/src/areas/workflows/preflight.ts b/src/areas/workflows/preflight.ts index b33c1851..2693ac60 100644 --- a/src/areas/workflows/preflight.ts +++ b/src/areas/workflows/preflight.ts @@ -30,7 +30,8 @@ function formatType(type: DataType): string { if (type === 'mesh') return 'mesh' if (type === 'image') return 'image' if (type === 'audio') return 'audio' - return 'text' + if (type === 'text') return 'text' + return String(type) } function formatRequiredTypes(types: DataType[]): string {