feat: workspace dedicated workers (#7741)

* feat: workspace dedicated workers

* ref

* chore: update ee-repo-ref to a18ac31062ac092cb9a5fc87629e217d97f4911d

This commit updates the EE repository reference after PR #398 was merged in windmill-ee-private.

Previous ee-repo-ref: 98cfe3fef764d9d815d326d5056c734a03689d33

New ee-repo-ref: a18ac31062ac092cb9a5fc87629e217d97f4911d

Automated by sync-ee-ref workflow.

* fix(frontend): workspace script in flow steps

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
hugocasa
2026-01-29 18:32:48 +01:00
committed by GitHub
parent f45d9adf6a
commit 60858d1e20
16 changed files with 845 additions and 83 deletions
+1 -1
View File
@@ -1 +1 @@
c05572e93739e2697ab310d87efe2744cd0e1aaf
a18ac31062ac092cb9a5fc87629e217d97f4911d
@@ -0,0 +1 @@
ALTER TABLE worker_ping DROP COLUMN IF EXISTS dedicated_workers;
@@ -0,0 +1 @@
ALTER TABLE worker_ping ADD COLUMN IF NOT EXISTS dedicated_workers TEXT[];
+38 -28
View File
@@ -56,8 +56,8 @@ use windmill_common::{
HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
JOB_DEFAULT_TIMEOUT_SECS_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING,
LICENSE_KEY_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPM_CONFIG_REGISTRY_SETTING,
OTEL_TRACING_PROXY_SETTING, NUGET_CONFIG_SETTING, OTEL_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING,
POWERSHELL_REPO_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING,
NUGET_CONFIG_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING,
POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING,
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING,
SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, TIMEOUT_WAIT_RESULT_SETTING,
},
@@ -84,10 +84,10 @@ use windmill_common::{
use windmill_common::{client::AuthedClient, global_settings::APP_WORKSPACED_ROUTE_SETTING};
use windmill_queue::{cancel_job, get_queued_job_v2, SameWorkerPayload};
use windmill_worker::{
handle_job_error, JobCompletedSender, OtelTracingProxySettings, SameWorkerSender, BUNFIG_INSTALL_SCOPES,
INSTANCE_PYTHON_VERSION, JOB_DEFAULT_TIMEOUT, KEEP_JOB_DIR, MAVEN_REPOS,
OTEL_TRACING_PROXY_SETTINGS, NO_DEFAULT_MAVEN, NPM_CONFIG_REGISTRY, NUGET_CONFIG, PIP_EXTRA_INDEX_URL,
PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL,
handle_job_error, JobCompletedSender, OtelTracingProxySettings, SameWorkerSender,
BUNFIG_INSTALL_SCOPES, INSTANCE_PYTHON_VERSION, JOB_DEFAULT_TIMEOUT, KEEP_JOB_DIR, MAVEN_REPOS,
NO_DEFAULT_MAVEN, NPM_CONFIG_REGISTRY, NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS,
PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL,
};
#[cfg(feature = "parquet")]
@@ -246,6 +246,7 @@ pub async fn initial_load(
),
priority_tags_sorted: vec![],
dedicated_worker: None,
dedicated_workers: None,
init_bash: load_init_bash_from_env(),
periodic_script_bash: load_periodic_bash_script_from_env(),
periodic_script_interval_seconds: load_periodic_bash_script_interval_from_env(),
@@ -784,26 +785,24 @@ pub async fn load_keep_job_dir(conn: &Connection) {
pub async fn reload_otel_tracing_proxy_setting(conn: &Connection) {
match load_value_from_global_settings_with_conn(conn, OTEL_TRACING_PROXY_SETTING, true).await {
Ok(Some(settings)) => {
match serde_json::from_value::<OtelTracingProxySettings>(settings) {
Ok(new_settings) => {
let mut current = OTEL_TRACING_PROXY_SETTINGS.write().await;
if current.enabled != new_settings.enabled
|| current.enabled_languages != new_settings.enabled_languages
{
tracing::info!(
"OTEL tracing proxy settings changed: enabled={}, languages={:?}",
new_settings.enabled,
new_settings.enabled_languages
);
*current = new_settings;
}
}
Err(e) => {
tracing::error!("Error parsing OTEL tracing proxy settings: {e:#}");
Ok(Some(settings)) => match serde_json::from_value::<OtelTracingProxySettings>(settings) {
Ok(new_settings) => {
let mut current = OTEL_TRACING_PROXY_SETTINGS.write().await;
if current.enabled != new_settings.enabled
|| current.enabled_languages != new_settings.enabled_languages
{
tracing::info!(
"OTEL tracing proxy settings changed: enabled={}, languages={:?}",
new_settings.enabled,
new_settings.enabled_languages
);
*current = new_settings;
}
}
}
Err(e) => {
tracing::error!("Error parsing OTEL tracing proxy settings: {e:#}");
}
},
Err(e) => {
tracing::error!("Error loading OTEL tracing proxy setting: {e:#}");
}
@@ -985,7 +984,10 @@ pub async fn delete_expired_items(db: &DB) -> () {
);
}
}
Err(e) => tracing::error!("Error deleting expired MCP OAuth authorization codes: {:?}", e),
Err(e) => tracing::error!(
"Error deleting expired MCP OAuth authorization codes: {:?}",
e
),
}
let job_retention_secs = *JOB_RETENTION_SECS.read().await;
@@ -1866,7 +1868,13 @@ pub async fn monitor_db(
let update_min_worker_version_f = async {
#[cfg(not(feature = "test_job_debouncing"))]
windmill_common::min_version::update_min_version(conn, _worker_mode, WORKERS_NAMES.read().await.clone(), initial_load).await;
windmill_common::min_version::update_min_version(
conn,
_worker_mode,
WORKERS_NAMES.read().await.clone(),
initial_load,
)
.await;
};
// Run every 5 minutes (10 iterations * 30s = 5 minutes)
@@ -2060,10 +2068,12 @@ pub async fn reload_worker_config(db: &DB, tx: KillpillSender, kill_if_change: b
} else {
let wc = WORKER_CONFIG.read().await;
let config = config.unwrap();
if *wc != config || config.dedicated_worker.is_some() {
let has_dedicated = config.dedicated_worker.is_some() || config.dedicated_workers.as_ref().is_some_and(|dws| !dws.is_empty());
if *wc != config || has_dedicated {
if kill_if_change {
if config.dedicated_worker.is_some()
if has_dedicated
|| (*wc).dedicated_worker != config.dedicated_worker
|| (*wc).dedicated_workers != config.dedicated_workers
{
tracing::info!("Dedicated worker config changed, sending killpill. Expecting to be restarted by supervisor.");
let _ = tx.send();
+1
View File
@@ -995,6 +995,7 @@ TABLE: worker_ping
- custom_tags (text[])
- worker_group (character)
- dedicated_worker (character)
- dedicated_workers (text[])
- wm_version (character)
- current_job_id (uuid)
- current_job_workspace_id (character)
+16
View File
@@ -5751,6 +5751,14 @@ paths:
If true, the description field will be omitted from the response.
schema:
type: boolean
- name: dedicated_worker
in: query
description: |
(default regardless)
If true, show only scripts with dedicated_worker enabled.
If false, show only scripts with dedicated_worker disabled.
schema:
type: boolean
responses:
"200":
@@ -7178,6 +7186,14 @@ paths:
If true, the description field will be omitted from the response.
schema:
type: boolean
- name: dedicated_worker
in: query
description: |
(default regardless)
If true, show only flows with dedicated_worker enabled.
If false, show only flows with dedicated_worker disabled.
schema:
type: boolean
responses:
"200":
description: All flow
+3
View File
@@ -191,6 +191,9 @@ async fn list_flows(
if !lq.include_draft_only.unwrap_or(false) || authed.is_operator {
sqlb.and_where("o.draft_only IS NOT TRUE");
}
if let Some(dw) = &lq.dedicated_worker {
sqlb.and_where_eq("dedicated_worker", dw);
}
if lq.with_deployment_msg.unwrap_or(false) {
sqlb.join("deployment_metadata dm")
+3
View File
@@ -371,6 +371,9 @@ async fn list_scripts(
if let Some(it) = &lq.is_template {
sqlb.and_where_eq("is_template", it);
}
if let Some(dw) = &lq.dedicated_worker {
sqlb.and_where_eq("dedicated_worker", dw);
}
if authed.is_operator {
sqlb.and_where_eq("kind", quote("script"));
} else if let Some(lowercased_kinds) = lowercased_kinds {
+1
View File
@@ -1146,6 +1146,7 @@ pub struct ListFlowQuery {
pub starred_only: Option<bool>,
pub include_draft_only: Option<bool>,
pub with_deployment_msg: Option<bool>,
pub dedicated_worker: Option<bool>,
}
pub fn add_virtual_items_if_necessary(modules: &mut Vec<FlowModule>) {
+1
View File
@@ -687,6 +687,7 @@ pub struct ListScriptQuery {
pub with_deployment_msg: Option<bool>,
#[serde(default, deserialize_with = "from_seq")]
pub languages: Option<Vec<ScriptLang>>,
pub dedicated_worker: Option<bool>,
}
fn from_seq<'de, D>(deserializer: D) -> Result<Option<Vec<ScriptLang>>, D::Error>
+49 -5
View File
@@ -219,6 +219,7 @@ lazy_static::lazy_static! {
worker_tags: Default::default(),
priority_tags_sorted: Default::default(),
dedicated_worker: Default::default(),
dedicated_workers: Default::default(),
cache_clear: Default::default(),
init_bash: Default::default(),
periodic_script_bash: Default::default(),
@@ -1243,6 +1244,7 @@ pub struct Ping {
pub ip: Option<String>,
pub tags: Option<Vec<String>>,
pub dw: Option<String>,
pub dws: Option<Vec<String>>,
pub version: Option<String>,
pub vcpus: Option<i64>,
pub memory: Option<i64>,
@@ -1298,6 +1300,7 @@ pub async fn update_ping_http(
&insert_ping.ip.unwrap(),
insert_ping.tags.unwrap_or_default().as_slice(),
insert_ping.dw,
insert_ping.dws.as_deref(),
&insert_ping.version.unwrap(),
insert_ping.vcpus,
insert_ping.memory,
@@ -1428,6 +1431,7 @@ pub async fn insert_ping_query(
ip: &str,
tags: &[String],
dw: Option<String>,
dws: Option<&[String]>,
version: &str,
vcpus: Option<i64>,
memory: Option<i64>,
@@ -1435,14 +1439,15 @@ pub async fn insert_ping_query(
db: &DB,
) -> anyhow::Result<()> {
sqlx::query!(
"INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, wm_version, vcpus, memory, job_isolation) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (worker)
DO UPDATE set ip = EXCLUDED.ip, custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group",
"INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (worker)
DO UPDATE set ip = EXCLUDED.ip, custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_workers = EXCLUDED.dedicated_workers",
worker_instance,
worker_name,
ip,
tags,
worker_group,
dw,
dws,
version,
vcpus,
memory,
@@ -1614,6 +1619,30 @@ pub async fn load_worker_config(
}
})
.transpose()?;
// Parse dedicated_workers (multiple dedicated workers)
let dedicated_workers = config
.dedicated_workers
.map(|workers| {
workers
.into_iter()
.map(|x| {
let splitted = x.split(':').to_owned().collect_vec();
if splitted.len() != 2 {
return Err(anyhow::anyhow!(
"Invalid dedicated_workers format. Got {x}, expects <workspace_id>:<path>"
));
}
let workspace = splitted[0];
let script_path = splitted[1];
Ok(WorkspacedPath {
workspace_id: workspace.to_string(),
path: script_path.to_string(),
})
})
.collect::<Result<Vec<_>, _>>()
})
.transpose()?;
if *WORKER_GROUP == "default" && dedicated_worker.is_none() {
let mut all_tags = config
.worker_tags
@@ -1647,7 +1676,18 @@ pub async fn load_worker_config(
let worker_tags = config
.worker_tags
.or_else(|| {
if let Some(ref dedicated_worker) = dedicated_worker.as_ref() {
// Check for multiple dedicated workers first
if let Some(ref dws) = dedicated_workers.as_ref() {
let mut dedi_tags: Vec<String> = dws
.iter()
.map(|dw| format!("{}:{}", dw.workspace_id, dw.path))
.collect();
if std::env::var("ADD_FLOW_TAG").is_ok() {
dedi_tags.push("flow".to_string());
}
Some(dedi_tags)
} else if let Some(ref dedicated_worker) = dedicated_worker.as_ref() {
// Fallback to single dedicated worker for backward compatibility
let mut dedi_tags = vec![format!(
"{}:{}",
dedicated_worker.workspace_id, dedicated_worker.path
@@ -1743,6 +1783,7 @@ pub async fn load_worker_config(
worker_tags,
priority_tags_sorted,
dedicated_worker,
dedicated_workers,
init_bash: config
.init_bash
.or_else(|| load_init_bash_from_env())
@@ -1836,6 +1877,7 @@ pub struct WorkerConfigOpt {
pub worker_tags: Option<Vec<String>>,
pub priority_tags: Option<HashMap<String, u8>>,
pub dedicated_worker: Option<String>,
pub dedicated_workers: Option<Vec<String>>,
pub init_bash: Option<String>,
pub periodic_script_bash: Option<String>,
pub periodic_script_interval_seconds: Option<u64>,
@@ -1852,6 +1894,7 @@ impl Default for WorkerConfigOpt {
worker_tags: Default::default(),
priority_tags: Default::default(),
dedicated_worker: Default::default(),
dedicated_workers: Default::default(),
init_bash: Default::default(),
periodic_script_bash: Default::default(),
periodic_script_interval_seconds: Default::default(),
@@ -1869,6 +1912,7 @@ pub struct WorkerConfig {
pub worker_tags: Vec<String>,
pub priority_tags_sorted: Vec<PriorityTags>,
pub dedicated_worker: Option<WorkspacedPath>,
pub dedicated_workers: Option<Vec<WorkspacedPath>>,
pub init_bash: Option<String>,
pub periodic_script_bash: Option<String>,
pub periodic_script_interval_seconds: Option<u64>,
@@ -1880,8 +1924,8 @@ pub struct WorkerConfig {
impl std::fmt::Debug for WorkerConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "WorkerConfig {{ worker_tags: {:?}, priority_tags_sorted: {:?}, dedicated_worker: {:?}, init_bash: {:?}, periodic_script_bash: {:?}, periodic_script_interval_seconds: {:?}, cache_clear: {:?}, additional_python_paths: {:?}, pip_local_dependencies: {:?}, env_vars: {:?} }}",
self.worker_tags, self.priority_tags_sorted, self.dedicated_worker, self.init_bash, self.periodic_script_bash, self.periodic_script_interval_seconds, self.cache_clear, self.additional_python_paths, self.pip_local_dependencies, self.env_vars.iter().map(|(k, v)| format!("{}: {}{} ({} chars)", k, &v[..3.min(v.len())], "***", v.len())).collect::<Vec<String>>().join(", "))
write!(f, "WorkerConfig {{ worker_tags: {:?}, priority_tags_sorted: {:?}, dedicated_worker: {:?}, dedicated_workers: {:?}, init_bash: {:?}, periodic_script_bash: {:?}, periodic_script_interval_seconds: {:?}, cache_clear: {:?}, additional_python_paths: {:?}, pip_local_dependencies: {:?}, env_vars: {:?} }}",
self.worker_tags, self.priority_tags_sorted, self.dedicated_worker, self.dedicated_workers, self.init_bash, self.periodic_script_bash, self.periodic_script_interval_seconds, self.cache_clear, self.additional_python_paths, self.pip_local_dependencies, self.env_vars.iter().map(|(k, v)| format!("{}: {}{} ({} chars)", k, &v[..3.min(v.len())], "***", v.len())).collect::<Vec<String>>().join(", "))
}
}
+1
View File
@@ -533,6 +533,7 @@ pub async fn update_worker_ping_for_failed_init_script(
ip: None,
tags: None,
dw: None,
dws: None,
jobs_executed: None,
occupancy_rate: None,
occupancy_rate_15s: None,
+33 -28
View File
@@ -1529,7 +1529,10 @@ pub async fn run_worker(
let mut occupancy_metrics = OccupancyMetrics::new(start_time);
let mut jobs_executed = 0;
let is_dedicated_worker: bool = WORKER_CONFIG.read().await.dedicated_worker.is_some();
let is_dedicated_worker: bool = {
let config = WORKER_CONFIG.read().await;
config.dedicated_worker.is_some() || config.dedicated_workers.as_ref().is_some_and(|dws| !dws.is_empty())
};
#[cfg(feature = "benchmark")]
let benchmark_jobs: i32 = std::env::var("BENCHMARK_JOBS")
@@ -1631,9 +1634,9 @@ pub async fn run_worker(
// Option<JoinHandle<()>>,
#[cfg(all(feature = "private", feature = "enterprise"))]
let (dedicated_workers, is_flow_worker, dedicated_handles): (
let (dedicated_workers, dedicated_flow_paths, dedicated_handles): (
HashMap<String, Sender<DedicatedWorkerJob>>,
bool,
HashSet<String>,
Vec<JoinHandle<()>>,
) = match conn {
Connection::Sql(pool) => {
@@ -1648,15 +1651,15 @@ pub async fn run_worker(
)
.await
}
Connection::Http(_) => (HashMap::new(), false, vec![]),
Connection::Http(_) => (HashMap::new(), HashSet::new(), vec![]),
};
#[cfg(any(not(feature = "private"), not(feature = "enterprise")))]
let (dedicated_workers, is_flow_worker, dedicated_handles): (
let (dedicated_workers, dedicated_flow_paths, dedicated_handles): (
HashMap<String, Sender<DedicatedWorkerJob>>,
bool,
HashSet<String>,
Vec<JoinHandle<()>>,
) = (HashMap::new(), false, vec![]);
) = (HashMap::new(), HashSet::new(), vec![]);
if i_worker == 1 {
if let Err(e) = queue_init_bash_maybe(conn, same_worker_tx.clone(), &worker_name).await {
@@ -2030,30 +2033,32 @@ pub async fn run_worker(
JobKind::Script | JobKind::Preview | JobKind::FlowScript
) {
if !dedicated_workers.is_empty() {
let key_o = if is_flow_worker {
job.flow_step_id.as_ref().map(|x| x.to_string())
// Try flow path + step_id combinations for flow jobs, otherwise use runnable_path
let dedicated_worker_tx = if let Some(step_id) = job.flow_step_id.as_ref() {
dedicated_flow_paths.iter().find_map(|flow_path| {
let key = format!("{}:{}", flow_path, step_id);
dedicated_workers.get(&key)
})
} else {
job.runnable_path.as_ref().map(|x| x.to_string())
job.runnable_path.as_ref().and_then(|path| dedicated_workers.get(path))
};
if let Some(key) = key_o {
if let Some(dedicated_worker_tx) = dedicated_workers.get(&key) {
let dedicated_job = DedicatedWorkerJob {
job: Arc::new(job.job()),
flow_runners: None,
done_tx: None,
};
if let Err(e) = dedicated_worker_tx.send(dedicated_job).await {
tracing::info!("failed to send jobs to dedicated workers. Likely dedicated worker has been shut down. This is normal: {e:?}");
}
#[cfg(feature = "benchmark")]
{
add_time!(bench, "sent to dedicated worker");
infos.add_iter(bench, true);
}
continue;
if let Some(dedicated_worker_tx) = dedicated_worker_tx {
let dedicated_job = DedicatedWorkerJob {
job: Arc::new(job.job()),
flow_runners: None,
done_tx: None,
};
if let Err(e) = dedicated_worker_tx.send(dedicated_job).await {
tracing::info!("failed to send jobs to dedicated workers. Likely dedicated worker has been shut down. This is normal: {e:?}");
}
#[cfg(feature = "benchmark")]
{
add_time!(bench, "sent to dedicated worker");
infos.add_iter(bench, true);
}
continue;
}
}
+11 -1
View File
@@ -133,6 +133,7 @@ async fn update_worker_ping_full_inner(
ip: None,
tags: Some(tags.to_vec()),
dw: None,
dws: None,
jobs_executed: Some(jobs_executed),
occupancy_rate: Some(occupancy_rate),
occupancy_rate_15s: Some(occupancy_rate_15s.unwrap_or(0.0)),
@@ -159,13 +160,19 @@ pub async fn insert_ping(
ip: &str,
db: &Connection,
) -> anyhow::Result<()> {
let (tags, dw) = {
let (tags, dw, dws) = {
let wc = WORKER_CONFIG.read().await.clone();
(
wc.worker_tags,
wc.dedicated_worker
.as_ref()
.map(|x| format!("{}:{}", x.workspace_id, x.path)),
wc.dedicated_workers.as_ref().map(|workers| {
workers
.iter()
.map(|x| format!("{}:{}", x.workspace_id, x.path))
.collect::<Vec<_>>()
}),
)
};
@@ -190,6 +197,7 @@ pub async fn insert_ping(
ip,
tags.as_slice(),
dw,
dws.as_deref(),
windmill_common::utils::GIT_VERSION,
vcpus,
memory,
@@ -210,6 +218,7 @@ pub async fn insert_ping(
ip: Some(ip.to_string()),
tags: Some(tags.to_vec()),
dw: dw,
dws: dws,
jobs_executed: None,
occupancy_rate: None,
occupancy_rate_15s: None,
@@ -282,6 +291,7 @@ pub async fn update_worker_ping_from_job(
ip: None,
tags: None,
dw: None,
dws: None,
version: None,
vcpus: None,
memory: None,
@@ -0,0 +1,645 @@
<script lang="ts">
import { ScriptService, FlowService, WorkspaceService, type FlowModule } from '$lib/gen'
import { Check, X, RefreshCcw, ChevronDown, ChevronRight, CodeXml } from 'lucide-svelte'
import { Button } from './common'
import Select from './select/Select.svelte'
import { sendUserToast } from '$lib/toast'
import Badge from './common/badge/Badge.svelte'
import { SvelteMap } from 'svelte/reactivity'
import { untrack } from 'svelte'
import BarsStaggered from './icons/BarsStaggered.svelte'
// A "Runnable" is a script or flow with dedicated_worker=true
interface Runnable {
tag: string // workspace:path or workspace:flow/path
displayName: string
language: string
type: 'script' | 'flow'
path: string
selected: boolean
// For flows, the actual runners (steps) that will be spawned
runners?: FlowRunner[]
loadingRunners?: boolean
expanded?: boolean
}
// A "FlowRunner" is an individual step within a flow that will get a dedicated worker
interface FlowRunner {
stepId: string
stepSummary?: string
language?: string
scriptPath?: string
isInline: boolean
}
interface Props {
selectedTags: string[]
disabled?: boolean
onchange?: (tags: string[]) => void
}
let { selectedTags = $bindable([]), disabled = false, onchange }: Props = $props()
let selectedWorkspace: string | undefined = $state(undefined)
let runnables: Runnable[] = $state([])
let loading = $state(false)
let workspaces: { id: string; name: string }[] = $state([])
let workspacesLoading = $state(true)
let selectorExpanded = $state(false)
// Track detailed info for each selected tag (for displaying in summary)
interface SelectedTagInfo {
tag: string
workspace: string
type: 'script' | 'flow'
path: string
runners?: FlowRunner[]
expanded?: boolean
loading?: boolean
}
let selectedTagsInfo: SvelteMap<string, SelectedTagInfo> = $state(new SvelteMap())
// Languages that support dedicated workers
const DEDICATED_WORKER_LANGUAGES = ['python3', 'bun', 'deno']
// Parse a tag to extract workspace, type (script/flow), and path
function parseTag(tag: string): { workspace: string; type: 'script' | 'flow'; path: string } | null {
const colonIndex = tag.indexOf(':')
if (colonIndex === -1) return null
const workspace = tag.substring(0, colonIndex)
const rest = tag.substring(colonIndex + 1)
if (rest.startsWith('flow/')) {
return { workspace, type: 'flow', path: rest.substring(5) }
} else {
return { workspace, type: 'script', path: rest }
}
}
// Resolve workspace script languages and filter to supported languages
async function resolveAndFilterRunners(
workspace: string,
preliminaryRunners: FlowRunner[]
): Promise<FlowRunner[]> {
const runnersWithLanguage = await Promise.all(
preliminaryRunners.map(async (runner) => {
if (!runner.isInline && runner.scriptPath) {
try {
const script = await ScriptService.getScriptByPath({
workspace,
path: runner.scriptPath
})
return { ...runner, language: script.language }
} catch (e) {
console.error(`Failed to fetch script ${runner.scriptPath}`, e)
return { ...runner, language: undefined }
}
}
return runner
})
)
// Filter to only supported languages
return runnersWithLanguage.filter(
(runner) => runner.language && DEDICATED_WORKER_LANGUAGES.includes(runner.language)
)
}
// Load detailed info for all selected tags
async function loadSelectedTagsInfo(tags: string[]) {
if (tags.length === 0) {
selectedTagsInfo = new SvelteMap()
return
}
// Capture current state without tracking to avoid infinite loops
const currentInfo = untrack(() => selectedTagsInfo)
const currentRunnables = untrack(() => runnables)
const newInfo = new SvelteMap<string, SelectedTagInfo>()
try {
await Promise.all(
tags.map(async (tag) => {
// Check if we already have this info cached
const existing = currentInfo.get(tag)
if (existing && (existing.type === 'script' || existing.runners !== undefined)) {
newInfo.set(tag, existing)
return
}
// Check if we have it loaded in runnables
const existingRunnable = currentRunnables.find((r) => r.tag === tag)
if (existingRunnable) {
newInfo.set(tag, {
tag,
workspace: tag.substring(0, tag.indexOf(':')),
type: existingRunnable.type,
path: existingRunnable.path,
runners: existingRunnable.runners,
expanded: existing?.expanded ?? false
})
return
}
// Parse and fetch
const parsed = parseTag(tag)
if (!parsed) return
if (parsed.type === 'script') {
newInfo.set(tag, {
tag,
workspace: parsed.workspace,
type: 'script',
path: parsed.path
})
} else {
// Flows need to fetch to get runners
try {
const flow = await FlowService.getFlowByPath({
workspace: parsed.workspace,
path: parsed.path
})
const preliminaryRunners = flow.value?.modules
? extractRunnersFromModules(flow.value.modules)
: []
const runners = await resolveAndFilterRunners(parsed.workspace, preliminaryRunners)
newInfo.set(tag, {
tag,
workspace: parsed.workspace,
type: 'flow',
path: parsed.path,
runners,
expanded: existing?.expanded ?? false
})
} catch (e) {
console.error(`Failed to load flow ${parsed.path}`, e)
newInfo.set(tag, {
tag,
workspace: parsed.workspace,
type: 'flow',
path: parsed.path,
runners: []
})
}
}
})
)
} finally {
selectedTagsInfo = newInfo
}
}
function toggleSelectedTagExpanded(tag: string) {
const info = selectedTagsInfo.get(tag)
if (info) {
// Need to set the whole object to trigger reactivity
selectedTagsInfo.set(tag, { ...info, expanded: !info.expanded })
}
}
// Auto-expand selector if no tags selected
$effect(() => {
if (selectedTags.length === 0) {
selectorExpanded = true
}
})
// Load selected tags info when selectedTags change
$effect(() => {
loadSelectedTagsInfo(selectedTags)
})
$effect(() => {
loadWorkspaces()
})
async function loadWorkspaces() {
try {
workspacesLoading = true
const ws = await WorkspaceService.listWorkspaces()
workspaces = ws.map((w) => ({ id: w.id, name: w.name }))
} catch (e) {
console.error('Failed to load workspaces', e)
sendUserToast('Failed to load workspaces', true)
} finally {
workspacesLoading = false
}
}
// Extract runners from flow modules recursively
// Returns runners with language info for inline scripts, and scriptPath for workspace scripts
function extractRunnersFromModules(modules: FlowModule[]): FlowRunner[] {
const runners: FlowRunner[] = []
for (const module of modules) {
const value = module.value
switch (value.type) {
case 'rawscript':
if (DEDICATED_WORKER_LANGUAGES.includes(value.language)) {
runners.push({
stepId: module.id,
stepSummary: module.summary,
language: value.language,
scriptPath: value.path,
isInline: true
})
}
break
case 'script':
// For workspace script references, we'll resolve the language later
runners.push({
stepId: module.id,
stepSummary: module.summary,
language: undefined, // Will be resolved by fetching the script
scriptPath: value.path,
isInline: false
})
break
case 'forloopflow':
runners.push(...extractRunnersFromModules(value.modules))
break
case 'whileloopflow':
runners.push(...extractRunnersFromModules(value.modules))
break
case 'branchone':
for (const branch of value.branches) {
runners.push(...extractRunnersFromModules(branch.modules))
}
runners.push(...extractRunnersFromModules(value.default))
break
case 'branchall':
for (const branch of value.branches) {
runners.push(...extractRunnersFromModules(branch.modules))
}
break
}
}
return runners
}
async function loadFlowRunners(runnable: Runnable) {
if (!selectedWorkspace || runnable.type !== 'flow') return
try {
runnable.loadingRunners = true
const flow = await FlowService.getFlowByPath({
workspace: selectedWorkspace,
path: runnable.path
})
if (flow.value?.modules) {
const preliminaryRunners = extractRunnersFromModules(flow.value.modules)
runnable.runners = await resolveAndFilterRunners(selectedWorkspace, preliminaryRunners)
} else {
runnable.runners = []
}
} catch (e) {
console.error('Failed to load flow runners', e)
runnable.runners = []
} finally {
runnable.loadingRunners = false
}
}
async function loadRunnables(workspaceId: string) {
try {
loading = true
runnables = []
const [scripts, flows] = await Promise.all([
ScriptService.listScripts({
workspace: workspaceId,
dedicatedWorker: true
}),
FlowService.listFlows({
workspace: workspaceId,
dedicatedWorker: true
})
])
const newRunnables: Runnable[] = []
// Add scripts with supported languages
for (const script of scripts) {
if (DEDICATED_WORKER_LANGUAGES.includes(script.language ?? '')) {
const tag = `${workspaceId}:${script.path}`
newRunnables.push({
tag,
displayName: script.path,
language: script.language ?? 'unknown',
type: 'script',
path: script.path,
selected: selectedTags.includes(tag)
})
}
}
// Add flows
for (const flow of flows) {
const tag = `${workspaceId}:flow/${flow.path}`
newRunnables.push({
tag,
displayName: flow.path,
language: 'flow',
type: 'flow',
path: flow.path,
selected: selectedTags.includes(tag),
runners: undefined,
loadingRunners: false,
expanded: false
})
}
runnables = newRunnables
// Load runners for all flows in parallel
await Promise.all(runnables.filter((r) => r.type === 'flow').map((r) => loadFlowRunners(r)))
} catch (e) {
console.error('Failed to load runnables', e)
sendUserToast('Failed to load scripts/flows', true)
} finally {
loading = false
}
}
function toggleRunnable(runnable: Runnable) {
runnable.selected = !runnable.selected
updateSelectedTags()
}
function toggleExpanded(runnable: Runnable) {
runnable.expanded = !runnable.expanded
}
function selectAll() {
for (const runnable of runnables) {
runnable.selected = true
}
updateSelectedTags()
}
function deselectAll() {
for (const runnable of runnables) {
runnable.selected = false
}
updateSelectedTags()
}
function updateSelectedTags() {
selectedTags = runnables.filter((r) => r.selected).map((r) => r.tag)
onchange?.(selectedTags)
}
function removeTag(tag: string) {
selectedTags = selectedTags.filter((t) => t !== tag)
// Also update runnable state if visible
const runnable = runnables.find((r) => r.tag === tag)
if (runnable) {
runnable.selected = false
}
onchange?.(selectedTags)
}
$effect(() => {
if (selectedWorkspace) {
loadRunnables(selectedWorkspace)
}
})
let selectedCount = $derived(runnables.filter((r) => r.selected).length)
</script>
<div class="flex flex-col gap-3">
<!-- Selected tags summary -->
{#if selectedTags.length > 0}
<div class="flex flex-col gap-2">
<div class="border rounded-md divide-y bg-surface max-h-48 overflow-y-auto">
{#each selectedTags as tag (tag)}
{@const info = selectedTagsInfo.get(tag)}
<div>
<div class="flex items-center">
{#if info?.type === 'flow' && info.runners && info.runners.length > 0}
<button
class="p-2 hover:bg-surface-hover transition-colors"
onclick={(e) => {
e.stopPropagation()
toggleSelectedTagExpanded(tag)
}}
>
{#if info.expanded}
<ChevronDown class="h-3 w-3 text-tertiary" />
{:else}
<ChevronRight class="h-3 w-3 text-tertiary" />
{/if}
</button>
{:else}
<div class="w-7"></div>
{/if}
<div class="flex-1 flex items-center gap-2 px-2 py-1.5 min-w-0">
{#if info}
{#if info.type === 'flow'}
<BarsStaggered size={14} class="flex-shrink-0 text-secondary" />
{:else}
<CodeXml size={14} class="flex-shrink-0 text-secondary" />
{/if}
<span class="text-xs truncate flex-1">{info.path}</span>
<span class="text-xs text-tertiary flex-shrink-0">({info.workspace})</span>
{#if info.type === 'flow' && info.runners}
<Badge color="indigo" small>
{info.runners.length} runner{info.runners.length !== 1 ? 's' : ''}
</Badge>
{:else if info.type === 'script'}
<Badge color="blue" small>1 runner</Badge>
{/if}
{:else}
<span class="text-xs text-tertiary truncate">{tag}</span>
{/if}
</div>
{#if !disabled}
<button
class="p-2 hover:text-red-500 transition-colors"
onclick={(e) => {
e.stopPropagation()
removeTag(tag)
}}
>
<X class="h-3 w-3" />
</button>
{/if}
</div>
{#if info?.type === 'flow' && info.expanded && info.runners}
<div class="bg-surface-secondary border-t">
{#each info.runners as runner (runner.stepId)}
<div class="flex items-center gap-2 px-9 py-1 text-xs border-t first:border-t-0">
<span class="font-mono text-tertiary">{runner.stepId}</span>
{#if runner.stepSummary}
<span class="text-secondary truncate flex-1">{runner.stepSummary}</span>
{/if}
<Badge color="gray" small>
{runner.isInline ? runner.language : runner.scriptPath}
</Badge>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Collapsible selector section -->
<div class="border rounded-md">
<button
class="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-surface-hover transition-colors"
onclick={() => (selectorExpanded = !selectorExpanded)}
{disabled}
>
{#if selectorExpanded}
<ChevronDown class="h-4 w-4 text-secondary" />
{:else}
<ChevronRight class="h-4 w-4 text-secondary" />
{/if}
<span class="text-sm">
{selectedTags.length > 0 ? 'Add more scripts/flows' : 'Select scripts/flows'}
</span>
</button>
{#if selectorExpanded}
<div class="border-t px-3 py-3 flex flex-col gap-3">
<!-- Workspace selector -->
<div class="flex flex-col gap-1">
<span class="text-xs text-secondary">Workspace</span>
<Select
bind:value={selectedWorkspace}
items={workspaces.map((w) => ({ value: w.id, label: `${w.name} (${w.id})` }))}
placeholder="Select workspace..."
disabled={disabled || workspacesLoading}
/>
</div>
<!-- Scripts/flows list -->
{#if selectedWorkspace}
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-xs text-secondary">Scripts/flows with dedicated worker enabled</span
>
{#if !loading && runnables.length > 0}
<div class="flex gap-1">
<Button size="xs2" color="light" on:click={selectAll} {disabled}>All</Button>
<Button size="xs2" color="light" on:click={deselectAll} {disabled}>None</Button>
<Button
size="xs2"
color="light"
iconOnly
startIcon={{ icon: RefreshCcw }}
on:click={() => selectedWorkspace && loadRunnables(selectedWorkspace)}
{disabled}
/>
</div>
{/if}
</div>
{#if loading}
<div class="flex items-center justify-center py-4">
<RefreshCcw class="animate-spin h-4 w-4 text-secondary" />
<span class="ml-2 text-xs text-secondary">Loading...</span>
</div>
{:else if runnables.length === 0}
<div class="text-xs text-tertiary py-3 text-center">
No scripts or flows with dedicated worker enabled found.
</div>
{:else}
<div class="border rounded-md divide-y max-h-64 overflow-y-auto bg-surface">
{#each runnables as runnable (runnable.tag)}
<div>
<div class="flex items-center">
{#if runnable.type === 'flow' && runnable.runners && runnable.runners.length > 0}
<button
class="p-2 hover:bg-surface-hover transition-colors"
onclick={(e) => {
e.stopPropagation()
toggleExpanded(runnable)
}}
{disabled}
>
{#if runnable.expanded}
<ChevronDown class="h-3 w-3 text-tertiary" />
{:else}
<ChevronRight class="h-3 w-3 text-tertiary" />
{/if}
</button>
{:else}
<div class="w-7"></div>
{/if}
<button
class="flex-1 flex items-center gap-2 px-2 py-1.5 hover:bg-surface-hover transition-colors text-left"
onclick={(e) => {
e.stopPropagation()
if (!disabled) toggleRunnable(runnable)
}}
{disabled}
>
<div
class="w-4 h-4 border rounded flex items-center justify-center flex-shrink-0"
class:bg-blue-500={runnable.selected}
class:border-blue-500={runnable.selected}
>
{#if runnable.selected}
<Check class="h-3 w-3 text-white" />
{/if}
</div>
<span class="flex-1 text-xs truncate">{runnable.displayName}</span>
{#if runnable.type === 'flow' && runnable.runners}
<span class="text-xs text-tertiary">
{runnable.runners.length}
</span>
{/if}
<Badge color={runnable.type === 'flow' ? 'indigo' : 'blue'} small>
{runnable.type === 'flow' ? 'flow' : runnable.language}
</Badge>
</button>
</div>
{#if runnable.type === 'flow' && runnable.expanded && runnable.runners}
<div class="bg-surface-secondary border-t">
{#if runnable.runners.length === 0}
<div class="px-9 py-1.5 text-xs text-tertiary italic">
No eligible steps (python3/bun/deno)
</div>
{:else}
{#each runnable.runners as runner (runner.stepId)}
<div
class="flex items-center gap-2 px-9 py-1 text-xs border-t first:border-t-0"
>
<span class="font-mono text-tertiary">{runner.stepId}</span>
{#if runner.stepSummary}
<span class="text-secondary truncate flex-1">
{runner.stepSummary}
</span>
{/if}
<Badge color="gray" small>
{runner.isInline ? runner.language : runner.scriptPath}
</Badge>
</div>
{/each}
{/if}
</div>
{/if}
</div>
{/each}
</div>
<div class="text-xs text-tertiary">
{selectedCount} selected
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
</div>
+40 -20
View File
@@ -42,6 +42,7 @@
import TextInput from './text_input/TextInput.svelte'
import Dropdown from './DropdownV2.svelte'
import TagList from './TagList.svelte'
import DedicatedWorkersSelector from './DedicatedWorkersSelector.svelte'
function computeVCpuAndMemory(workers: [string, WorkerPing[]][]) {
let vcpus = 0
@@ -73,6 +74,7 @@
let nconfig: {
dedicated_worker?: string
dedicated_workers?: string[]
worker_tags?: string[]
priority_tags?: Record<string, number>
cache_clear?: number
@@ -89,7 +91,9 @@
function loadNConfig() {
nconfig = config
? config.worker_tags != undefined || config.dedicated_worker != undefined
? config.worker_tags != undefined ||
config.dedicated_worker != undefined ||
config.dedicated_workers != undefined
? config
: {
worker_tags: []
@@ -101,6 +105,12 @@
nconfig.priority_tags = {}
}
// Convert legacy dedicated_worker to dedicated_workers array
if (nconfig.dedicated_worker && !nconfig.dedicated_workers?.length) {
nconfig.dedicated_workers = [nconfig.dedicated_worker]
nconfig.dedicated_worker = undefined
}
customEnvVars = []
if (nconfig.env_vars_allowlist === undefined) {
nconfig.env_vars_allowlist = []
@@ -170,6 +180,7 @@
| undefined
| {
dedicated_worker?: string
dedicated_workers?: string[]
worker_tags?: string[]
priority_tags?: Record<string, number>
cache_clear?: number
@@ -243,7 +254,11 @@
let drawer: Drawer | undefined = $state()
let vcpus_memory = $derived(computeVCpuAndMemory(workers))
let selected = $derived(nconfig?.dedicated_worker != undefined ? 'dedicated' : 'normal')
let selected = $derived(
nconfig?.dedicated_worker != undefined || (nconfig?.dedicated_workers?.length ?? 0) > 0
? 'dedicated'
: 'normal'
)
$effect(() => {
;($superadmin || $devopsRole) && listWorkspaces()
})
@@ -330,17 +345,19 @@
nconfig = {}
}
if (e.detail == 'dedicated') {
nconfig.dedicated_worker = ''
nconfig.dedicated_workers = nconfig.dedicated_workers ?? []
nconfig.dedicated_worker = undefined
nconfig.worker_tags = undefined
} else {
nconfig.dedicated_worker = undefined
nconfig.dedicated_workers = undefined
nconfig.worker_tags = []
}
}}
>
{#snippet children({ item })}
<ToggleButton value="normal" label="Any jobs within worker tags" {item} />
<ToggleButton value="dedicated" label="Dedicated to a script/flow" {item} />
<ToggleButton value="dedicated" label="Dedicated to scripts/flows" {item} />
{/snippet}
</ToggleButtonGroup>
</Label>
@@ -493,30 +510,33 @@
</Label>
{/if}
{:else if selected == 'dedicated'}
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-4">
{#if $superadmin || $devopsRole}
<div class="py-2">
<Alert
size="xs"
type="info"
title="Script's runtime setting 'dedicated worker' must be toggled on as well"
title="The 'dedicated worker' runtime setting of the runnables must be enabled to be selected here"
/>
</div>
{/if}
{#if nconfig?.dedicated_worker != undefined}
<div
><p class="text-xs text-secondary mb-2"
>Workers will get killed upon detecting changes. It is assumed they are in an
environment where the supervisor will restart them.</p
>
<input
disabled={!canEditConfig}
placeholder="<workspace>:<script path>"
type="text"
onchange={() => {}}
bind:value={nconfig.dedicated_worker}
/></div
>
<p class="text-xs text-secondary"
>Workers will get killed upon detecting changes. It is assumed they are in an environment
where the supervisor will restart them.</p
>
{#if nconfig !== undefined}
<DedicatedWorkersSelector
selectedTags={nconfig.dedicated_workers ?? []}
disabled={!canEditConfig}
onchange={(tags) => {
if (nconfig) {
nconfig.dedicated_workers = tags
nconfig.dedicated_worker = undefined
}
}}
/>
{/if}
</div>
{/if}