diff --git a/bin/core/src/api/execute/action.rs b/bin/core/src/api/execute/action.rs index 8d602ef32..3b04a6d09 100644 --- a/bin/core/src/api/execute/action.rs +++ b/bin/core/src/api/execute/action.rs @@ -2,6 +2,7 @@ use std::{ collections::HashSet, path::{Path, PathBuf}, sync::OnceLock, + time::Duration, }; use anyhow::Context as _; @@ -133,6 +134,10 @@ impl Resolve for RunAction { let mut update = update.clone(); + let timeout = (action.config.execution_timeout > 0).then(|| { + Duration::from_secs(action.config.execution_timeout as u64) + }); + update_update(update.clone()).await?; let default_args = parse_action_arguments( @@ -219,7 +224,7 @@ impl Resolve for RunAction { "deno run --allow-all{https_cert_flag}{reload} {}", path.display() ), - CommandOptions::default().cancel(cancel), + CommandOptions::default().cancel(cancel).timeout(timeout), ) .await; diff --git a/client/core/rs/src/entities/action.rs b/client/core/rs/src/entities/action.rs index b24e82390..b06a33fb2 100644 --- a/client/core/rs/src/entities/action.rs +++ b/client/core/rs/src/entities/action.rs @@ -162,6 +162,13 @@ pub struct ActionConfig { #[builder(default)] pub reload_deno_deps: bool, + /// Maximum seconds the Action may run before its process group is + /// killed and the run failed. 0 or below means no timeout. + #[serde(default = "default_execution_timeout")] + #[builder(default = "default_execution_timeout()")] + #[partial_default(default_execution_timeout())] + pub execution_timeout: i32, + /// Typescript file contents using pre-initialized `komodo` client. /// Supports variable / secret interpolation. #[serde(default, deserialize_with = "file_contents_deserializer")] @@ -204,6 +211,10 @@ fn default_run_at_startup() -> bool { false } +fn default_execution_timeout() -> i32 { + 0 +} + fn default_webhook_enabled() -> bool { true } @@ -227,6 +238,7 @@ impl Default for ActionConfig { webhook_enabled: default_webhook_enabled(), webhook_secret: Default::default(), reload_deno_deps: Default::default(), + execution_timeout: default_execution_timeout(), arguments_format: Default::default(), file_contents: Default::default(), arguments: Default::default(), diff --git a/client/core/ts/src/types.ts b/client/core/ts/src/types.ts index f17660f49..beea389b3 100644 --- a/client/core/ts/src/types.ts +++ b/client/core/ts/src/types.ts @@ -120,6 +120,11 @@ export interface ActionConfig { * this can usually be kept false outside of development. */ reload_deno_deps?: boolean; + /** + * Maximum seconds the Action may run before its process group is + * killed and the run failed. 0 or below means no timeout. + */ + execution_timeout: number; /** * Typescript file contents using pre-initialized `komodo` client. * Supports variable / secret interpolation. diff --git a/ui/public/schema/resources.json b/ui/public/schema/resources.json index 81a6d006c..e5baf2a07 100644 --- a/ui/public/schema/resources.json +++ b/ui/public/schema/resources.json @@ -5042,6 +5042,14 @@ "null" ] }, + "execution_timeout": { + "description": "Maximum seconds the Action may run before its process group is\nkilled and the run failed. 0 or below means no timeout.", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, "file_contents": { "description": "Typescript file contents using pre-initialized `komodo` client.\nSupports variable / secret interpolation.", "type": [ diff --git a/ui/src/resources/action/config.tsx b/ui/src/resources/action/config.tsx index 1f575552b..0119ef527 100644 --- a/ui/src/resources/action/config.tsx +++ b/ui/src/resources/action/config.tsx @@ -1,6 +1,7 @@ import { Anchor, Group, Select, Stack, Text, TextInput } from "@mantine/core"; import { useLocalStorage } from "@mantine/hooks"; -import { useState } from "react"; +import { notifications } from "@mantine/notifications"; +import { useEffect, useState } from "react"; import { Types } from "komodo_client"; import { usePermissions, @@ -139,6 +140,19 @@ export default function ActionConfig({ id }: { id: string }) { }, }, }, + { + label: "Timeout", + labelHidden: true, + fields: { + execution_timeout: (execution_timeout, set) => ( + + ), + }, + }, { label: "Schedule", description: @@ -298,6 +312,57 @@ export default function ActionConfig({ id }: { id: string }) { ); } +function ExecutionTimeout({ + arg, + set, + disabled, +}: { + arg: number; + set: (input: Partial) => void; + disabled: boolean; +}) { + const [input, setInput] = useState(arg.toString()); + useEffect(() => { + setInput(arg.toString()); + }, [arg]); + // Integral and within i32, matching the server field. + const valid = (value: string) => { + const num = Number(value); + return Number.isInteger(num) && num >= -2147483648 && num <= 2147483647; + }; + const error = valid(input) + ? undefined + : "Timeout must be a whole number of seconds"; + return ( + + + setInput(e.target.value)} + onBlur={(e) => { + if (valid(e.target.value)) { + set({ execution_timeout: Number(e.target.value) }); + } else { + notifications.show({ + message: "Execution timeout must be a whole number of seconds", + color: "red", + }); + } + }} + error={error} + disabled={disabled} + /> + seconds + + + ); +} + const defaultArguments = (format: Types.FileFormat) => { switch (format) { case Types.FileFormat.KeyValue: