diff --git a/src/__tests__/editor-integration.test.ts b/src/__tests__/editor-integration.test.ts
index 00dc463..1942b9b 100644
--- a/src/__tests__/editor-integration.test.ts
+++ b/src/__tests__/editor-integration.test.ts
@@ -86,6 +86,29 @@ triggers: {}
expect(names).toContain('health-handler');
});
+ it('round-trips module satisfies keys for derived IaC modules', () => {
+ const { config, yaml } = roundTrip(`
+modules:
+ - name: otel-collector
+ type: infra.otel_collector
+ satisfies:
+ - observability.telemetry.default
+ - observability.metrics.default
+ config:
+ endpoint: "\${OTEL_EXPORTER_OTLP_ENDPOINT}"
+workflows: {}
+triggers: {}
+`);
+
+ const collector = config.modules.find((m) => m.name === 'otel-collector');
+ expect(collector?.satisfies).toEqual([
+ 'observability.telemetry.default',
+ 'observability.metrics.default',
+ ]);
+ expect(yaml).toContain('satisfies:');
+ expect(yaml).toContain('observability.telemetry.default');
+ });
+
it('adding a node and exporting produces 4 modules', () => {
const { nodes, edges, parsed } = roundTrip(httpYaml);
// Simulate adding a middleware node
diff --git a/src/components/properties/PropertyPanel.test.tsx b/src/components/properties/PropertyPanel.test.tsx
index 6cce058..fffab09 100644
--- a/src/components/properties/PropertyPanel.test.tsx
+++ b/src/components/properties/PropertyPanel.test.tsx
@@ -133,6 +133,55 @@ describe('PropertyPanel', () => {
expect(updatedNode?.data.label).toBe('My Custom Server');
});
+ it('edits module satisfies keys outside config', () => {
+ act(() => {
+ useModuleSchemaStore.getState().loadEditorBundle({
+ version: 'editor-bundle/v1',
+ moduleSchemas: {
+ 'infra.otel_collector': {
+ type: 'infra.otel_collector',
+ label: 'OTel Collector',
+ category: 'observability',
+ configFields: [],
+ defaultConfig: {},
+ },
+ },
+ coercionRules: {},
+ contracts: {},
+ messages: {},
+ schemas: { app: {} },
+ });
+ useWorkflowStore.setState({
+ nodes: [{
+ id: 'otel-collector',
+ type: 'observabilityNode',
+ position: { x: 0, y: 0 },
+ data: {
+ moduleType: 'infra.otel_collector',
+ label: 'otel-collector',
+ config: {},
+ satisfies: ['observability.telemetry.default'],
+ },
+ }],
+ selectedNodeId: 'otel-collector',
+ });
+ });
+
+ render();
+
+ const satisfiesInput = screen.getByLabelText('Satisfies');
+ fireEvent.change(satisfiesInput, {
+ target: { value: 'observability.telemetry.default\nobservability.metrics.default' },
+ });
+
+ const updatedNode = useWorkflowStore.getState().nodes.find((n) => n.id === 'otel-collector');
+ expect(updatedNode?.data.satisfies).toEqual([
+ 'observability.telemetry.default',
+ 'observability.metrics.default',
+ ]);
+ expect(updatedNode?.data.config.satisfies).toBeUndefined();
+ });
+
it('close button clears selection', () => {
act(() => {
useWorkflowStore.getState().addNode('http.server', { x: 0, y: 0 });
diff --git a/src/components/properties/PropertyPanel.tsx b/src/components/properties/PropertyPanel.tsx
index fd0c182..0fde2f3 100644
--- a/src/components/properties/PropertyPanel.tsx
+++ b/src/components/properties/PropertyPanel.tsx
@@ -59,6 +59,7 @@ export default function PropertyPanel() {
const selectedNodeId = useWorkflowStore((s) => s.selectedNodeId);
const updateNodeConfig = useWorkflowStore((s) => s.updateNodeConfig);
const updateNodeName = useWorkflowStore((s) => s.updateNodeName);
+ const updateNodeSatisfies = useWorkflowStore((s) => s.updateNodeSatisfies);
const removeNode = useWorkflowStore((s) => s.removeNode);
const setSelectedNode = useWorkflowStore((s) => s.setSelectedNode);
@@ -477,6 +478,18 @@ export default function PropertyPanel() {
)}
+ {/* Module requirement keys */}
+
+
{/* I/O Signature */}
{info?.ioSignature && (info.ioSignature.inputs.length > 0 || info.ioSignature.outputs.length > 0) && (
diff --git a/src/stores/workflowStore.ts b/src/stores/workflowStore.ts
index af81f1e..aa454d8 100644
--- a/src/stores/workflowStore.ts
+++ b/src/stores/workflowStore.ts
@@ -31,6 +31,7 @@ export interface WorkflowNodeData extends Record {
moduleType: string;
label: string;
config: Record;
+ satisfies?: string[];
synthesized?: boolean;
/** Source file path this node originated from (multi-file configs) */
sourceFile?: string;
@@ -93,6 +94,7 @@ interface WorkflowStore {
removeNode: (id: string) => void;
updateNodeConfig: (id: string, config: Record) => void;
updateNodeName: (id: string, name: string) => void;
+ updateNodeSatisfies: (id: string, satisfies: string[]) => void;
updateHandlerRoutes: (nodeId: string, routes: Array<{
method: string;
path: string;
@@ -453,6 +455,16 @@ const useWorkflowStore = create()(
});
},
+ updateNodeSatisfies: (id, satisfies) => {
+ get().pushHistory();
+ const next = satisfies.map((key) => key.trim()).filter(Boolean);
+ set({
+ nodes: get().nodes.map((n) =>
+ n.id === id ? { ...n, data: { ...n.data, satisfies: next } } : n
+ ),
+ });
+ },
+
updateHandlerRoutes: (nodeId, routes) => {
get().pushHistory();
set({
diff --git a/src/types/workflow.ts b/src/types/workflow.ts
index 6db2158..9d2062c 100644
--- a/src/types/workflow.ts
+++ b/src/types/workflow.ts
@@ -6,6 +6,7 @@ export interface ModuleConfig {
type: string;
config?: Record;
dependsOn?: string[];
+ satisfies?: string[];
branches?: Record;
ui_position?: { x: number; y: number };
}
diff --git a/src/utils/serialization.ts b/src/utils/serialization.ts
index e04f9f4..5663c4b 100644
--- a/src/utils/serialization.ts
+++ b/src/utils/serialization.ts
@@ -294,6 +294,10 @@ export function nodesToConfig(
mod.config = { ...node.data.config };
}
+ if (node.data.satisfies && node.data.satisfies.length > 0) {
+ mod.satisfies = [...node.data.satisfies];
+ }
+
const deps = dependencyMap[node.id];
if (deps && deps.length > 0) {
mod.dependsOn = deps;
@@ -575,6 +579,7 @@ export function configToNodes(
moduleType: mod.type,
label: mod.name,
config: mod.config ?? (info ? { ...info.defaultConfig } : {}),
+ ...(mod.satisfies && mod.satisfies.length > 0 ? { satisfies: [...mod.satisfies] } : {}),
...(sourceFile ? { sourceFile } : {}),
},
});