Repository: kestra-io/kestra (java)

Issue:
WorkingDirectory crash server with invalid `runIf`
```yaml 

id: working-dir-crashed
namespace: company.team

tasks:
  - id: workingDirecttory 
    type: io.kestra.plugin.core.flow.WorkingDirectory
    tasks:
      - id: s1
        type: io.kestra.plugin.scripts.shell.Commands
        commands: 
          - echo 1 
          
      - id: s2
        type: io.kestra.plugin.scripts.shell.Commands
        runIf: "{{ outputs.failed }}"
        commands: 
          - echo 2
          
```

result : 

```
10:05:43.955 INFO  worker_0     task.working-dir-crashed.s1 [tenant: lde] [namespace: company.team] [flow: working-dir-crashed] [task: s1] [execution: 6aNIwSPKtoiTgbKWvx4UvV] [taskrun: To2mwX4myMI0hFFZ0luka] Type Commands with state SUCCESS completed in 00:00:00.729
10:05:43.249 ERROR worker_0     flow.working-dir-crashed.s2 Failed evaluating runIf: Unable to find `failed` used in the expression `{{ outputs.failed }}` at line 1
io.kestra.core.exceptions.IllegalVariableEvaluationException: Unable to find `failed` used in the expression `{{ outputs.failed }}` at line 1
	at io.kestra.core.runners.VariableRenderer.properPebbleException(VariableRenderer.java:57)
	at io.kestra.core.runners.VariableRenderer.renderOnce(VariableRenderer.java:117)
	at io.kestra.core.runners.VariableRenderer.render(VariableRenderer.java:88)
	at io.kestra.core.runners.VariableRenderer.render(VariableRenderer.java:73)
	at io.kestra.core.runners.VariableRenderer.render(VariableRenderer.java:65)
	at io.kestra.core.runners.DefaultRunContext.render(DefaultRunContext.java:237)
	at io.kestra.core.runners.Worker.handleTask(Worker.java:389)
	at io.kestra.core.runners.Worker.lambda$run$8(Worker.java:276)
	at io.micrometer.core.instrument.internal.TimedRunnable.run(TimedRunnable.java:49)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
	at java.base/java.lang.Thread.run(Thread.java:1583)
10:05:43.898 ERROR worker_0     .c.u.ThreadUncaughtExceptionHandlers Caught an exception in Thread[#232,worker_0,5,main]. Shutting down.
10:05:43.683 INFO  worker_0     t.w.workingDirecttory [tenant: lde] [namespace: company.team] [flow: working-dir-crashed] [task: workingDirecttory] [execution: 6aNIwSPKtoiTgbKWvx4UvV] [taskrun: 5zzvQrKcUDV7RKKPfN1o5U] Type WorkingDirectory with state CREATED completed in 00:00:00.862
java.lang.NullPointerException: Cannot invoke "io.kestra.core.runners.WorkerTaskResult.getTaskRun()" because "workerTaskResult" is null
	at io.kestra.core.runners.Worker.handleTask(Worker.java:402)
	at io.kestra.core.runners.Worker.lambda$run$8(Worker.java:276)
	at io.micrometer.core.instrument.internal.TimedRunnable.run(TimedRunnable.java:49)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
	at java.base/java.lang.Thread.run(Thread.java:1583)
10:05:44.356 INFO  worker_0     io.kestra.core.runners.Worker All working threads are terminated.
10:05:44.683 WARN  command-shutdown io.kestra.cli.AbstractCommand Receiving shutdown ! Try to graceful exit
10:05:44.911 INFO  command-shutdown i.kestra.core.contexts.KestraContext Kestra server - Shutdown initiated
10:05:44.005 INFO  command-shutdown i.kestra.core.contexts.KestraContext Kestra server - Shutdown completed
```


Pull request:
fix(system)*: runIf inside a WorkingDirectory can crash the Worker
Fixes #9639


Patch:
diff --git a/core/src/main/java/io/kestra/core/runners/Worker.java b/core/src/main/java/io/kestra/core/runners/Worker.java
index bb9aba1fb..8e8eb6f61 100644
--- a/core/src/main/java/io/kestra/core/runners/Worker.java
+++ b/core/src/main/java/io/kestra/core/runners/Worker.java
@@ -395,11 +395,16 @@ public class Worker implements Service, Runnable, AutoCloseable {
                     } catch (IllegalVariableEvaluationException e) {
                         RunContextLogger contextLogger = runContextLoggerFactory.create(currentWorkerTask);
                         contextLogger.logger().error("Failed evaluating runIf: {}", e.getMessage(), e);
+                        try {
+                            this.workerTaskResultQueue.emit(new WorkerTaskResult(workerTask.fail()));
+                        } catch (QueueException ex) {
+                            log.error("Unable to emit the worker task result for task {} taskrun {}", currentWorkerTask.getTask().getId(), currentWorkerTask.getTaskRun().getId(), e);
+                        }
                     } catch (QueueException e) {
                         log.error("Unable to emit the worker task result for task {} taskrun {}", currentWorkerTask.getTask().getId(), currentWorkerTask.getTaskRun().getId(), e);
                     }
 
-                    if (workerTaskResult.getTaskRun().getState().isFailed() && !currentWorkerTask.getTask().isAllowFailure()) {
+                    if (workerTaskResult == null || workerTaskResult.getTaskRun().getState().isFailed() && !currentWorkerTask.getTask().isAllowFailure()) {
                         break;
                     }
 
diff --git a/ui/src/components/flows/tasks/TaskExpression.vue b/ui/src/components/flows/tasks/TaskExpression.vue
index f1714d0e9..1a9c2a05b 100644
--- a/ui/src/components/flows/tasks/TaskExpression.vue
+++ b/ui/src/components/flows/tasks/TaskExpression.vue
@@ -42,7 +42,7 @@
             return props.modelValue;
         }
 
-        return YAML_UTILS.stringify(props.modelValue);
+        return typeof props.modelValue !== "undefined" ? YAML_UTILS.stringify(props.modelValue) : "";
     })
 
     const localEditorValue = ref(editorValue.value)
diff --git a/ui/src/components/inputs/EditorSidebar.vue b/ui/src/components/inputs/EditorSidebar.vue
index 3834d9f2e..fbc203676 100644
--- a/ui/src/components/inputs/EditorSidebar.vue
+++ b/ui/src/components/inputs/EditorSidebar.vue
@@ -116,16 +116,7 @@
             v-loading="items === undefined"
             :props="{class: 'node', isLeaf: 'leaf'}"
             class="mt-3"
-            @node-click="
-                (data, node) =>
-                    data.leaf
-                        ? openTab({
-                            name: data.fileName,
-                            extension: data.fileName.split('.').pop(),
-                            path: getPath(node),
-                        })
-                        : undefined
-            "
+            @node-click="handleNodeClick"
             @node-drag-start="
                 nodeBeforeDrag = {
                     parent: $event.parent.data.id,
@@ -133,7 +124,7 @@
                 }
             "
             @node-drop="nodeMoved"
-            @keydown.delete.prevent="deleteKeystroke"
+            @keydown.delete.prevent="removeSelectedFiles"
         >
             <template #empty>
                 <div class="m-4 empty">
@@ -151,7 +142,12 @@
                     trigger="contextmenu"
                     class="w-100"
                 >
-                    <el-row justify="space-between" class="w-100">
+                    <el-row
+                        justify="space-between"
+                        class="w-100"
+                        :class="{'selected-node': selectedNodes.includes(data.id)}"
+                        @click="(event) => handleNodeClick(data, node)"
+                    >
                         <el-col class="w-100">
                             <TypeIcon
                                 :name="data.fileName"
@@ -164,24 +160,25 @@
                     <template #dropdown>
                         <el-dropdown-menu>
                             <el-dropdown-item
-                                v-if="!data.leaf"
+                                v-if="!data.leaf && !multiSelected"
                                 @click="toggleDialog(true, 'file', node)"
                             >
                                 {{ $t("namespace files.create.file") }}
                             </el-dropdown-item>
                             <el-dropdown-item
-                                v-if="!data.leaf"
+                                v-if="!data.leaf && !multiSelected"
                                 @click="toggleDialog(true, 'folder', node)"
                             >
                                 {{ $t("namespace files.create.folder") }}
                             </el-dropdown-item>
-                            <el-dropdown-item @click="copyPath(data)">
+                            <el-dropdown-item v-if="!multiSelected" @click="copyPath(data)">
                                 {{ $t("namespace files.path.copy") }}
                             </el-dropdown-item>
-                            <el-dropdown-item v-if="data.leaf" @click="exportFile(node, data)">
+                            <el-dropdown-item v-if="data.leaf && !multiSelected" @click="exportFile(node, data)">
                                 {{ $t("namespace files.export_single") }}
                             </el-dropdown-item>
                             <el-dropdown-item
+                                v-if="data.leaf && !multiSelected"
                                 @click="
                                     toggleRenameDialog(
                                         true,
@@ -199,13 +196,17 @@
                                     )
                                 }}
                             </el-dropdown-item>
-                            <el-dropdown-item @click="confirmRemove(node)">
+                            <el-dropdown-item @click="removeSelectedFiles()">
                                 {{
-                                    $t(
+                                    selectedNodes.length <= 1 ? $t(
                                         `namespace files.delete.${
                                             !data.leaf ? "folder" : "file"
                                         }`,
-                                    )
+                                    ) : $t(
+                                        `namespace files.delete.${
+                                            !data.leaf ? "folders" : "files"
+                                        }`
+                                        , {count: selectedNodes.length})
                                 }}
                             </el-dropdown-item>
                         </el-dropdown-menu>
@@ -307,21 +308,17 @@
 
         <el-dialog
             v-model="confirmation.visible"
-            :title="
-                Array.isArray(confirmation.node?.data?.children)
-                    ? $t('namespace files.dialog.folder_deletion')
-                    : $t('namespace files.dialog.file_deletion')
-            "
+            :title="confirmationTitle"
             width="500"
             @keydown.enter.prevent="removeItem()"
         >
             <span class="py-3">
                 {{
-                    Array.isArray(confirmation.node?.data?.children)
-                        ? $t(
-                            "namespace files.dialog.folder_deletion_description",
-                        )
-                        : $t("namespace files.dialog.file_deletion_description")
+                    confirmation.nodes.length > 1
+                        ? $t("namespace files.dialog.file_deletion_description")
+                        : confirmation.nodes[0]?.type === "Directory"
+                            ? $t("namespace files.dialog.folder_deletion_description")
+                            : $t("namespace files.dialog.file_deletion_description")
                 }}
             </span>
             <template #footer>
@@ -414,6 +411,9 @@
                 nodeBeforeDrag: undefined,
                 searchResults: [],
                 tabContextMenu: {visible: false, x: 0, y: 0},
+                selectedFiles: [], // Tracks selected file paths
+                selectedNodes: [], // Tracks selected node IDs
+                lastClickedIndex: null, // Tracks the last clicked file index
             };
         },
         computed: {
@@ -425,6 +425,9 @@
             namespaceId() {
                 return this.currentNS ?? this.$route.params.namespace;
             },
+            multiSelected() {
+                return this.selectedNodes.length > 1;
+            },
             folders() {
                 function extractPaths(basePath = "", array) {
                     const paths = [];
@@ -446,6 +449,22 @@
 
                 return extractPaths(undefined, this.items);
             },
+            confirmationTitle() {
+                if (!this.confirmation.nodes || this.confirmation.nodes.length === 0) {
+                    return ""; // Return an empty string if no nodes are selected
+                }
+
+                if (this.confirmation.nodes.length > 1) {
+                    // Bulk deletion title
+                    return this.$t("namespace files.dialog.file_deletion");
+                }
+
+                // Single node deletion title
+                const node = this.confirmation.nodes[0];
+                return node.type === "Directory"
+                    ? this.$t("namespace files.dialog.folder_deletion")
+                    : this.$t("namespace files.dialog.file_deletion");
+            },
         },
         methods: {
             ...mapMutations("editor", [
@@ -467,6 +486,79 @@
                 "importFileDirectory",
                 "exportFileDirectory",
             ]),
+
+            flattenTree(items, parentPath = "") {
+                const result = [];
+
+                for (const item of items) {
+                    const fullPath = `${parentPath}${item.fileName}`;
+                    result.push({path: fullPath, fileName: item.fileName, id: item.id});
+
+                    if (item.children && item.children.length > 0) {
+                        result.push(...this.flattenTree(item.children, `${fullPath}/`));
+                    }
+                }
+
+                return result.filter(i => i.path);
+            },
+            handleNodeClick(data, node) {
+                const path = this.getPath(node);
+                const flatList = this.flattenTree(this.items);
+                const currentIndex = flatList.findIndex(item => item.path === path);
+
+                if (window.event.shiftKey && this.lastClickedIndex !== null) {
+                    // Handle shift-click for range selection
+                    const start = Math.min(this.lastClickedIndex, currentIndex);
+                    const end = Math.max(this.lastClickedIndex, currentIndex);
+
+                    this.selectedFiles = flatList.slice(start, end + 1).map(item => item.path);
+                    this.selectedNodes = flatList.slice(start, end + 1).map(item => item.id);
+                } else {
+                    // Handle single-click selection
+                    this.selectedFiles = [path];
+                    this.selectedNodes = [node.data.id];
+                    this.lastClickedIndex = currentIndex;
+                    if (data.leaf) {
+                        this.openTab({
+                            name: data.fileName,
+                            path: path,
+                            extension: data.fileName.split(".").pop(),
+                        });
+                    }
+                }
+            },
+
+            async removeSelectedFiles() {
+                const nodes = this.selectedFiles.map((filePath) => {
+                    const node = this.findNodeByPath(filePath);
+                    return node;
+                });
+
+                this.confirmRemove(nodes);
+            },
+
+            findNodeByPath(path, items = this.items, parentPath = "") {
+                for (const item of items) {
+                    const fullPath = `${parentPath}${item.fileName}`;
+
+                    if (fullPath === path) {
+                        return item;
+                    }
+
+                    if (item.children && item.children.length > 0) {
+                        const foundNode = this.findNodeByPath(
+                            path,
+                            item.children,
+                            `${fullPath}/`
+                        );
+                        if (foundNode) {
+                            return foundNode;
+                        }
+                    }
+                }
+
+                return null;
+            },
             sorted(items) {
                 return items.sort((a, b) => {
                     if (a.type === "Directory" && b.type !== "Directory") return -1;
@@ -894,38 +986,41 @@
                     this.dialog = {...DIALOG_DEFAULTS};
                 }
             },
-            confirmRemove(node) {
-                this.confirmation = {visible: true, node};
+            confirmRemove(nodes) {
+                if (Array.isArray(nodes)) {
+                    this.confirmation = {
+                        visible: true,
+                        nodes,
+                    };
+                } else {
+                    this.confirmation = {
+                        visible: true,
+                        nodes: [nodes],
+                    };
+                }
             },
             async removeItem() {
-                const {
-                    node,
-                    node: {data},
-                } = this.confirmation;
-
-                await this.deleteFileDirectory({
-                    namespace: this.namespaceId,
-                    path: this.getPath(node),
-                    name: data.fileName,
-                    type: data.type,
-                });
-
-                this.$refs.tree.remove(data.id);
-
-                this.closeTab({
-                    name: data.fileName,
-                });
-
-                this.confirmation = {visible: false, node: undefined};
-            },
-            deleteKeystroke() {
-                if (this.$refs.tree.getCurrentNode()) {
-                    this.confirmRemove(
-                        this.$refs.tree.getNode(
-                            this.$refs.tree.getCurrentNode().id,
-                        ),
-                    );
+                for (const node of this.confirmation.nodes) {
+                    try {
+                        await this.deleteFileDirectory({
+                            namespace: this.currentNS ?? this.$route.params.namespace,
+                            path: this.getPath(node),
+                            name: node.data.fileName,
+                            type: node.data.type,
+                        });
+                        this.$refs.tree.remove(node.data.id);
+                        this.closeTab({
+                            name: node.data.fileName,
+                        });
+                    } catch (error) {
+                        console.error(`Failed to delete file: ${node.data.fileName}`, error);
+                        this.$toast().error(`Failed to delete file: ${node.data.fileName}`);
+                    }
                 }
+
+                // Clear the confirmation state after deletion
+                this.confirmation = {visible: false, nodes: []};
+                this.$toast().success("Selected files deleted successfully.");
             },
             async addFolder(folder, creation) {
                 const {fileName} = folder
@@ -1057,6 +1152,17 @@
                 this.tabContextMenu.visible = false;
                 document.removeEventListener("click", this.hideTabContextMenu);
             },
+            clearSelection() {
+                this.selectedFiles = [];
+                this.selectedNodes = [];
+                this.lastClickedIndex = null;
+            },
+        },
+        mounted() {
+            document.addEventListener("click", this.clearSelection);
+        },
+        beforeUnmount() {
+            document.removeEventListener("click", this.clearSelection);
         },
         watch: {
             flow: {
@@ -1209,10 +1315,11 @@
 
         .el-tree-node.is-current > .el-tree-node__content {
             min-width: fit-content;
-
-            html.dark &{
-                background-color: $primary;
-            }
+            border: 1px solid var(--ks-border-active)
+        }
+        .el-tree-node:has(.selected-node) > .el-tree-node__content {
+            background-color: var(--ks-button-background-primary);
+            min-width: fit-content;
         }
     }
 }
diff --git a/ui/src/translations/en.json b/ui/src/translations/en.json
index 4e21b596e..b45286e9e 100644
--- a/ui/src/translations/en.json
+++ b/ui/src/translations/en.json
@@ -665,7 +665,9 @@
       "delete": {
         "label": "Delete",
         "file": "Delete file",
-        "folder": "Delete folder"
+        "folder": "Delete folder",
+        "files": "Delete {count} selected files",
+        "folders": "Delete {count} selected folders"
       },
       "import": {
         "import": "Import",
@@ -684,7 +686,7 @@
         "parent_folder": "Parent folder:",
         "file_deletion": "Confirm file deletion",
         "folder_deletion": "Confirm folder deletion",
-        "file_deletion_description": "Are you sure you want to delete this file?",
+        "file_deletion_description": "Are you sure you want to delete the selected file(s)?",
         "folder_deletion_description": "Are you sure you want to delete this folder and all of its contents?",
         "confirm": "Confirm"
       },


Test patch:
diff --git a/core/src/test/java/io/kestra/core/repositories/AbstractSettingRepositoryTest.java b/core/src/test/java/io/kestra/core/repositories/AbstractSettingRepositoryTest.java
index 30db0b904..be4975341 100644
--- a/core/src/test/java/io/kestra/core/repositories/AbstractSettingRepositoryTest.java
+++ b/core/src/test/java/io/kestra/core/repositories/AbstractSettingRepositoryTest.java
@@ -39,7 +39,7 @@ public abstract class AbstractSettingRepositoryTest {
         assertThat(find.get().getValue()).isEqualTo(save.getValue());
 
         List<Setting> all = settingRepository.findAll();
-        assertThat(all.size()).isGreaterThan(1); // ES have the version setting in test but not JDBC I don't know why
+        assertThat(all.size()).isGreaterThanOrEqualTo(1); // ES have the version setting in test but not JDBC I don't know why
         assertThat(all)
             .extracting(Setting::getValue)
             .contains(setting.getValue());
diff --git a/core/src/test/java/io/kestra/plugin/core/flow/WorkingDirectoryTest.java b/core/src/test/java/io/kestra/plugin/core/flow/WorkingDirectoryTest.java
index e0bd349ee..f2b6da56d 100644
--- a/core/src/test/java/io/kestra/plugin/core/flow/WorkingDirectoryTest.java
+++ b/core/src/test/java/io/kestra/plugin/core/flow/WorkingDirectoryTest.java
@@ -111,6 +111,12 @@ public class WorkingDirectoryTest {
         suite.encryption(runnerUtils, runContextFactory);
     }
 
+    @Test
+    @LoadFlows({"flows/valids/working-directory-invalid-runif.yaml"})
+    void invalidRunIf() throws Exception {
+        suite.invalidRunIf(runnerUtils);
+    }
+
     @Singleton
     public static class Suite {
         @Inject
@@ -310,6 +316,15 @@ public class WorkingDirectoryTest {
             assertThat(execution.findTaskRunsByTaskId("decrypted").getFirst().getOutputs().get("value")).isEqualTo("Hello World");
         }
 
+        public void invalidRunIf(RunnerUtils runnerUtils) throws TimeoutException, QueueException {
+            Execution execution = runnerUtils.runOne(MAIN_TENANT, "io.kestra.tests", "working-directory-invalid-runif", null,
+                (f, e) -> ImmutableMap.of("failed", "false"), Duration.ofSeconds(60)
+            );
+
+            assertThat(execution.getTaskRunList()).hasSize(2);
+            assertThat(execution.getState().getCurrent()).isEqualTo(State.Type.FAILED);
+        }
+
         private void put(String path, String content) throws IOException {
             put(path, content, "io.kestra.tests");
         }
diff --git a/core/src/test/resources/flows/valids/working-directory-invalid-runif.yaml b/core/src/test/resources/flows/valids/working-directory-invalid-runif.yaml
new file mode 100644
index 000000000..3d38114c3
--- /dev/null
+++ b/core/src/test/resources/flows/valids/working-directory-invalid-runif.yaml
@@ -0,0 +1,15 @@
+id: working-directory-invalid-runif
+namespace: io.kestra.tests
+
+tasks:
+  - id: workingDirectory
+    type: io.kestra.plugin.core.flow.WorkingDirectory
+    tasks:
+      - id: s1
+        type: io.kestra.plugin.core.debug.Return
+        format: 1
+
+      - id: s2
+        type: io.kestra.plugin.core.debug.Return
+        runIf: "{{ outputs.failed }}"
+        format: 2
\ No newline at end of file
diff --git a/script/src/test/java/io/kestra/plugin/scripts/runners/LogConsumerTest.java b/script/src/test/java/io/kestra/plugin/scripts/runners/LogConsumerTest.java
index 03cf1097d..f1ea8fcb4 100644
--- a/script/src/test/java/io/kestra/plugin/scripts/runners/LogConsumerTest.java
+++ b/script/src/test/java/io/kestra/plugin/scripts/runners/LogConsumerTest.java
@@ -116,7 +116,7 @@ class LogConsumerTest {
             Collections.emptyList()
         );
 
-        Await.until(() -> logs.size() == 10, null, Duration.ofSeconds(5));
+        Await.until(() -> logs.size() >= 10, null, Duration.ofSeconds(5));
         receive.blockLast();
 
         assertThat(logs.stream().filter(m -> m.getLevel().equals(Level.INFO)).count()).isEqualTo(1L);