Merge branch 'main' into change-d274fbc6

This commit is contained in:
Diego Imbert
2026-07-28 12:42:09 +02:00
committed by GitHub
28 changed files with 741 additions and 112 deletions
+17 -19
View File
@@ -1506,6 +1506,10 @@ struct CustomInstanceDbLogs {
db_connect: String,
#[serde(skip_serializing_if = "String::is_empty")]
grant_permissions: String,
#[serde(skip_serializing_if = "String::is_empty")]
replication_user: String,
#[serde(skip_serializing_if = "Option::is_none")]
replication_user_error: Option<String>,
}
async fn list_custom_instance_pg_databases(
@@ -1705,30 +1709,24 @@ async fn setup_custom_instance_pg_database_inner(
))
})?;
// The replication attribute lives on a dedicated role used by postgres trigger
// connections. The getter creates the role (with its stored password) when the
// migration couldn't.
if let Err(e) = windmill_common::utils::get_custom_pg_instance_replication_password(db).await {
tracing::error!("Failed to ensure custom_instance_replication_user exists: {e:#}");
}
if let Err(e) = client
.batch_execute(
"ALTER ROLE custom_instance_replication_user REPLICATION;
GRANT custom_instance_user TO custom_instance_replication_user;
ALTER ROLE custom_instance_user NOREPLICATION;",
)
.await
{
tracing::error!(
"Failed to grant replication permission to custom_instance_replication_user: {e:#}"
);
}
logs.grant_permissions = "OK".to_string();
drop(client); // /!\ Drop before joining to avoid deadlock
windmill_common::shutdown_pg_connection(join_handle).await?;
// Roles are cluster-wide, so the dedicated role used by postgres trigger connections is
// provisioned on the main pool rather than on the new database. Reported as its own step
// rather than failing the setup: without the role the database still serves datatables, only
// postgres triggers on them break.
match windmill_common::utils::ensure_custom_instance_replication_user(db).await {
Ok(()) => logs.replication_user = "OK".to_string(),
Err(e) => {
tracing::error!("Failed to provision custom_instance_replication_user: {e:#}");
logs.replication_user = "FAIL".to_string();
logs.replication_user_error = Some(e.to_string());
}
}
Ok(())
}
+4
View File
@@ -29200,6 +29200,10 @@ components:
$ref: "#/components/schemas/LoggedWizardStatus"
grant_permissions:
$ref: "#/components/schemas/LoggedWizardStatus"
replication_user:
$ref: "#/components/schemas/LoggedWizardStatus"
replication_user_error:
type: string
CustomInstanceDbTag:
type: string
@@ -23,9 +23,39 @@ pub async fn custom_migrations(migrator: &mut CustomMigrator) -> Result<(), Erro
tracing::error!("Could not normalize custom_instance_user attributes: {err:#}");
}
if let Err(err) = ensure_custom_instance_replication_user(migrator).await {
tracing::error!(
"Could not provision custom_instance_replication_user: {err:#}. Postgres triggers on \
custom-instance datatables will not work until the role can open replication \
connections: grant the role owning DATABASE_URL either SUPERUSER or (PG 16+) the \
REPLICATION attribute. On AWS RDS this is handled by granting rds_replication, which \
requires no change; other managed providers are not covered"
);
}
Ok(())
}
// Converged on every boot, not once: the one-shot migration creates the role with the
// REPLICATION attribute, which managed postgres rejects outright, so instances set up before
// the provider-role fallback existed have no role at all. Scoped to instances that actually
// have a custom-instance database, so the rest never see the error.
async fn ensure_custom_instance_replication_user(
migrator: &mut CustomMigrator,
) -> Result<(), Error> {
let has_custom_instance_db = sqlx::query_scalar::<_, bool>(
"SELECT COALESCE(value->'databases', '{}'::jsonb) <> '{}'::jsonb
FROM global_settings WHERE name = 'custom_instance_pg_databases'",
)
.fetch_optional(migrator.connection())
.await?
.unwrap_or(false);
if !has_custom_instance_db {
return Ok(());
}
windmill_common::utils::ensure_custom_instance_replication_user(migrator.connection()).await
}
// Converged on every boot, not once: the one-shot migration swallows errors (it must not
// abort startup without superuser), and an older instance sharing the cluster can re-add
// the attribute. REPLICATION belongs only on custom_instance_replication_user.
+19 -3
View File
@@ -50,8 +50,15 @@ const TWO_HUNDRED: &str = "200.html";
/// Check if the original path requires cross-origin isolation headers.
///
/// These headers are needed for SharedArrayBuffer and TypeScript workers
/// (raw app editor at `/apps_raw/`, in-browser bundler at `/ui_builder/`).
/// CANONICAL COEP RATIONALE (the dev-server mirror in `frontend/vite.config.js`
/// and the navigation guards in `frontend/src/routes/(root)/(logged)/+layout.svelte`
/// point here): the headers are needed for SharedArrayBuffer and TypeScript
/// workers (raw app editor at `/apps_raw/edit|add`, in-browser bundler at
/// `/ui_builder/`). The raw app *viewer* (`/apps_raw/get/`) must NOT get them:
/// COEP `require-corp` blocks the viewed app's cross-origin subresources
/// (external images, embeds) that lack CORP — and since headers stick to the
/// document, apps would break on a page reload while working when reached via
/// client-side navigation.
///
/// Public apps (`/public/` and custom paths `/a/`) opt in via the `wm_coep`
/// query param: a public (raw) app must set COEP to be embeddable as an iframe
@@ -62,7 +69,9 @@ const TWO_HUNDRED: &str = "200.html";
/// explicitly requests it.
#[cfg(feature = "static_frontend")]
fn needs_cross_origin_isolation(original_path: &str, query: Option<&str>) -> bool {
original_path.starts_with("/apps_raw/")
// no trailing slash on edit/add: matches the +layout.svelte guards
original_path.starts_with("/apps_raw/edit")
|| original_path.starts_with("/apps_raw/add")
|| original_path.starts_with("/ui_builder/")
|| ((original_path.starts_with("/public/") || original_path.starts_with("/a/"))
&& query_has_flag(query, "wm_coep"))
@@ -152,8 +161,15 @@ mod tests {
fn test_needs_cross_origin_isolation() {
// editor + bundler are always isolated, regardless of query
assert!(needs_cross_origin_isolation("/apps_raw/edit/foo", None));
assert!(needs_cross_origin_isolation("/apps_raw/add", None));
assert!(needs_cross_origin_isolation("/ui_builder/index.html", None));
// the raw app viewer must NOT be isolated
assert!(!needs_cross_origin_isolation(
"/apps_raw/get/u/foo/bar",
None
));
// public apps (and custom paths) are isolated only when they opt in via wm_coep
assert!(needs_cross_origin_isolation(
"/public/ws/secret",
@@ -819,6 +819,10 @@ pub struct CustomInstanceDbLogs {
pub db_connect: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub grant_permissions: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub replication_user: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub replication_user_error: Option<String>,
}
// ---------------------------------------------------------------------------
+92 -18
View File
@@ -1058,30 +1058,90 @@ pub async fn get_custom_pg_instance_password(db: &DB) -> Result<String> {
)
}
const REFRESH_CUSTOM_INSTANCE_REPLICATION_USER_SQL: &str = r#"
/// PL/pgSQL granting `custom_instance_replication_user` the ability to open replication
/// connections, inlined into the `DO` blocks below.
///
/// The REPLICATION attribute requires a real superuser on PG <= 15 (PG 16+ accepts
/// CREATEROLE + REPLICATION), and managed postgres never hands one out: on RDS the
/// capability is carried by the `rds_replication` role instead. Both privilege failures
/// raise `insufficient_privilege`, so the attribute is attempted first and the provider
/// role is the fallback.
const GRANT_REPLICATION_CAPABILITY_PLPGSQL: &str = r#"
BEGIN
EXECUTE 'ALTER ROLE custom_instance_replication_user REPLICATION';
EXCEPTION WHEN insufficient_privilege THEN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'rds_replication') THEN
EXECUTE 'GRANT rds_replication TO custom_instance_replication_user';
ELSE
RAISE;
END IF;
END;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custom_instance_user') THEN
EXECUTE 'GRANT custom_instance_user TO custom_instance_replication_user';
-- Stripping REPLICATION off custom_instance_user is a cleanup, so it is both
-- guarded and best-effort: the clause is superuser-only on PG <= 15 even when the
-- attribute is already unset, and failing it must not roll back the role above.
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custom_instance_user' AND rolreplication) THEN
BEGIN
EXECUTE 'ALTER ROLE custom_instance_user NOREPLICATION';
EXCEPTION WHEN insufficient_privilege THEN
NULL;
END;
END IF;
END IF;
"#;
/// `rotate_condition` decides when a new password is generated: always for the refresh
/// endpoint, only when the role or its stored password is missing for the boot converge.
/// Both variants are a single statement so they can run on any executor, and both are
/// atomic: a role that cannot be granted replication is rolled back rather than left
/// half-provisioned.
fn provision_replication_user_sql(rotate_condition: &str) -> String {
format!(
r#"
DO $$
DECLARE
pwd text;
BEGIN
SELECT gen_random_uuid()::text INTO pwd;
-- Same lock as get_custom_pg_instance_replication_password: without it, every API
-- replica booting onto a version that provisions the role races into CREATE USER,
-- and the losers abort on duplicate_object.
PERFORM pg_advisory_xact_lock(hashtext('custom_instance_replication_pwd'));
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custom_instance_replication_user') THEN
EXECUTE format('ALTER USER custom_instance_replication_user WITH PASSWORD %L REPLICATION', pwd);
ELSE
EXECUTE format('CREATE USER custom_instance_replication_user WITH PASSWORD %L REPLICATION', pwd);
IF {rotate_condition} THEN
SELECT gen_random_uuid()::text INTO pwd;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custom_instance_replication_user') THEN
EXECUTE format('ALTER USER custom_instance_replication_user WITH PASSWORD %L', pwd);
ELSE
EXECUTE format('CREATE USER custom_instance_replication_user WITH PASSWORD %L', pwd);
END IF;
INSERT INTO global_settings (name, value)
VALUES ('custom_instance_replication_pwd', to_jsonb(pwd::text))
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custom_instance_user') THEN
GRANT custom_instance_user TO custom_instance_replication_user;
ALTER ROLE custom_instance_user NOREPLICATION;
END IF;
INSERT INTO global_settings (name, value)
VALUES ('custom_instance_replication_pwd', to_jsonb(pwd::text))
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value;
{GRANT_REPLICATION_CAPABILITY_PLPGSQL}
END
$$;
"#;
"#
)
}
lazy_static::lazy_static! {
static ref REFRESH_CUSTOM_INSTANCE_REPLICATION_USER_SQL: String =
provision_replication_user_sql("TRUE");
// The password test mirrors REPLICATION_PWD_READ_SQL, whose readers flatten a NULL away: a row
// holding a JSON null reads back as no password, so it must rotate rather than count as
// provisioned and strand the getter.
static ref ENSURE_CUSTOM_INSTANCE_REPLICATION_USER_SQL: String = provision_replication_user_sql(
"NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'custom_instance_replication_user')
OR NOT EXISTS (SELECT 1 FROM global_settings
WHERE name = 'custom_instance_replication_pwd' AND value #>> '{}' IS NOT NULL)"
);
}
const REPLICATION_PWD_READ_SQL: &str =
"SELECT value #>> '{}' FROM global_settings WHERE name = 'custom_instance_replication_pwd'";
@@ -1093,12 +1153,26 @@ const REPLICATION_PWD_READ_SQL: &str =
/// Authorization: rotates a stored database credential and performs no authorization
/// itself — callers MUST restrict this to superadmin or internal server paths.
pub async fn refresh_custom_instance_replication_user_pwd(db: &DB) -> Result<()> {
sqlx::query(REFRESH_CUSTOM_INSTANCE_REPLICATION_USER_SQL)
sqlx::query(REFRESH_CUSTOM_INSTANCE_REPLICATION_USER_SQL.as_str())
.execute(db)
.await?;
Ok(())
}
/// Create `custom_instance_replication_user` if it is missing and (re)grant it replication,
/// leaving an existing password in place. Idempotent, so it can be converged on every boot.
///
/// Authorization: same contract as [`refresh_custom_instance_replication_user_pwd`] —
/// callers MUST restrict this to superadmin or internal server paths.
pub async fn ensure_custom_instance_replication_user<'c>(
executor: impl sqlx::PgExecutor<'c>,
) -> Result<()> {
sqlx::query(ENSURE_CUSTOM_INSTANCE_REPLICATION_USER_SQL.as_str())
.execute(executor)
.await?;
Ok(())
}
/// Authorization: returns a stored database credential and performs no authorization
/// itself — callers MUST restrict this to superadmin or internal server paths (mirrors
/// [`get_custom_pg_instance_password`]).
@@ -1127,7 +1201,7 @@ pub async fn get_custom_pg_instance_replication_password(db: &DB) -> Result<Stri
tx.commit().await?;
return Ok(pwd);
}
sqlx::query(REFRESH_CUSTOM_INSTANCE_REPLICATION_USER_SQL)
sqlx::query(ENSURE_CUSTOM_INSTANCE_REPLICATION_USER_SQL.as_str())
.execute(&mut *tx)
.await?;
let pwd = sqlx::query_scalar::<_, Option<String>>(REPLICATION_PWD_READ_SQL)
@@ -208,8 +208,7 @@ impl TriggerCrud for PostgresTrigger {
publication,
publication_data.map(|publication| publication.0),
)
.await
.map_err(to_anyhow)?;
.await?;
}
remote_db_tx.commit().await.map_err(to_anyhow)?;
@@ -714,15 +713,13 @@ pub async fn update_pg_publication(
table_to_track.as_deref(),
&transaction_to_track,
)
.await
.map_err(to_anyhow)?;
.await?;
} else {
let pg_14 = check_if_valid_publication_for_postgres_version(
pg_connection,
table_to_track.as_deref(),
)
.await
.map_err(to_anyhow)?;
.await?;
let mut query = format!("ALTER PUBLICATION {} SET ", quoted_publication_name);
let mut first = true;
@@ -832,8 +829,7 @@ pub async fn alter_publication(
publication_data,
publication.map(|publication| publication.0),
)
.await
.map_err(to_anyhow)?;
.await?;
tx.commit().await.map_err(to_anyhow)?;
@@ -508,6 +508,10 @@ pub async fn create_pg_publication(
query.push_str(")");
}
// A row filter has no bind parameter, so it goes in raw. Publication
// DDL must therefore keep running through `Client::execute`, whose
// `Parse` refuses more than one command: under `simple_query` or
// `batch_execute` a filter could stack statements.
if let Some(where_clause) = &table.where_clause {
query.push_str(" WHERE (");
query.push_str(where_clause);
@@ -197,7 +197,15 @@
return { errorCount: 0, warningCount: 0, errors: [], warnings: [] }
},
setFlowJson: async ({ modules, schema, preprocessorModule, failureModule, groups, notes }) => {
setFlowJson: async ({
modules,
schema,
preprocessorModule,
failureModule,
groups,
notes,
settings
}) => {
try {
if (
modules !== undefined ||
@@ -205,7 +213,8 @@
preprocessorModule !== undefined ||
failureModule !== undefined ||
groups !== undefined ||
notes !== undefined
notes !== undefined ||
settings !== undefined
) {
// Take snapshot of current flowStore and set as beforeFlow
if (!diffManager?.hasPendingChanges) {
@@ -221,7 +230,8 @@
preprocessorModule,
failureModule,
groups,
notes
notes,
settings
})
// Refresh the state store to update UI
@@ -43,10 +43,13 @@ import { flowModuleSchema } from './openFlowZod.gen'
import { collectAllFlowModuleIdsFromModules } from '$lib/components/flows/flowTree'
import {
buildEditableFlowJson as buildEditableFlowJsonBase,
FLOW_VALUE_SETTINGS_KEYS,
pickFlowValueSettings,
validateEditableFlowJson,
validateFlowModules,
validateFlowSchema,
type EditableFlowJson
type EditableFlowJson,
type FlowValueSettings
} from './editableFlowJson'
import { FLOW_CHAT_SPECIAL_MODULES, getFlowPrompt } from '$system_prompts'
@@ -57,6 +60,9 @@ type FlowJsonUpdate = {
failureModule?: FlowModule | null
groups?: FlowGroup[] | null
notes?: FlowNote[] | null
/** Full state of the top-level FlowValue settings: when provided, keys
* absent from it are removed from the flow value. */
settings?: FlowValueSettings
}
function formatEmptyInlineScriptWarning({
@@ -563,7 +569,8 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [
preprocessorModule: parsedFlow.preprocessor_module,
failureModule: parsedFlow.failure_module,
groups: parsedFlow.groups,
notes: parsedFlow.notes
notes: parsedFlow.notes,
settings: pickFlowValueSettings(parsedFlow)
})
const warning = formatEmptyInlineScriptWarning(updateResult)
@@ -854,7 +861,7 @@ export function prepareFlowSystemMessage(customPrompt?: string): ChatCompletionS
Use \`patch_flow_json\` for small, localized changes when you can target an exact snippet from the \`CURRENT FLOW JSON COMPACT\` block below.
Always copy the exact search text from the \`CURRENT FLOW JSON COMPACT\` block below.
The compact JSON is a single object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, \`groups\`, and \`notes\` keys.
The compact JSON is a single object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, \`groups\`, and \`notes\` keys, plus any top-level flow settings that are set (${FLOW_VALUE_SETTINGS_KEYS.join(', ')}). Settings can be added, edited, or removed with \`patch_flow_json\` as top-level keys — e.g. \`chat_input_enabled: true\` marks the flow as chat-style (flow-as-chat); keep it intact when restructuring such a flow. \`set_flow_json\` never changes these settings.
**Parameters:**
- \`old_string\`: Exact JSON text to find
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest'
import type { FlowModule, FlowValue } from '$lib/gen'
import {
applyEditableFlowJsonToFlow,
buildEditableFlowJson,
validateEditableFlowJson
} from './editableFlowJson'
import { createInlineScriptSession } from './inlineScriptsUtils'
vi.mock('../shared', () => ({
SPECIAL_MODULE_IDS: {
PREPROCESSOR: 'preprocessor',
FAILURE: 'failure'
}
}))
function makeRawScriptModule(id: string, content: string): FlowModule {
return {
id,
summary: id,
value: {
type: 'rawscript',
language: 'bun',
content,
input_transforms: {}
}
} as FlowModule
}
function makeFlowValue(extra: Record<string, unknown> = {}): FlowValue {
return {
modules: [makeRawScriptModule('step_a', 'code a')],
...extra
} as FlowValue
}
describe('flow settings in the compact editable view', () => {
it('round-trips top-level flow settings through build → validate → apply', () => {
const value = makeFlowValue({ chat_input_enabled: true, same_worker: true })
const session = createInlineScriptSession()
const editable = buildEditableFlowJson({ value }, session)
expect(editable.chat_input_enabled).toBe(true)
expect(editable.same_worker).toBe(true)
const revalidated = validateEditableFlowJson(JSON.parse(JSON.stringify(editable)))
const result = applyEditableFlowJsonToFlow(value, revalidated, session)
expect(result.chat_input_enabled).toBe(true)
expect(result.same_worker).toBe(true)
expect(result.modules[0]?.value).toMatchObject({ type: 'rawscript', content: 'code a' })
})
it('applies a patched-in setting and deletes a removed one', () => {
const value = makeFlowValue({ cache_ttl: 60 })
const session = createInlineScriptSession()
const editable = buildEditableFlowJson({ value }, session)
const patched = JSON.parse(JSON.stringify(editable))
patched.chat_input_enabled = true
delete patched.cache_ttl
const result = applyEditableFlowJsonToFlow(value, validateEditableFlowJson(patched), session)
expect(result.chat_input_enabled).toBe(true)
expect('cache_ttl' in result).toBe(false)
})
it('preserves original FlowValue fields outside the compact view', () => {
const value = makeFlowValue({ some_future_field: 'kept' })
const session = createInlineScriptSession()
const editable = buildEditableFlowJson({ value }, session)
expect('some_future_field' in editable).toBe(false)
const result = applyEditableFlowJsonToFlow(
value,
validateEditableFlowJson(JSON.parse(JSON.stringify(editable))),
session
)
expect((result as Record<string, unknown>).some_future_field).toBe('kept')
})
it('rejects unknown top-level keys instead of silently dropping them', () => {
expect(() =>
validateEditableFlowJson({
modules: [makeRawScriptModule('step_a', 'code a')],
chat_enabled: true
})
).toThrow(/Unknown top-level flow key\(s\): chat_enabled/)
})
it('rejects settings with the wrong type', () => {
expect(() =>
validateEditableFlowJson({
modules: [makeRawScriptModule('step_a', 'code a')],
chat_input_enabled: 'yes'
})
).toThrow(/chat_input_enabled/)
})
})
@@ -12,6 +12,18 @@ import {
type FlowNote
} from './helperUtils'
import { flowModuleSchema, flowModulesSchema } from './openFlowZod.gen'
import {
FLOW_VALUE_SETTINGS_KEYS,
flowValueSettingsSchema,
pickFlowValueSettings,
type FlowValueSettings
} from './flowValueSettings'
export {
FLOW_VALUE_SETTINGS_KEYS,
pickFlowValueSettings,
type FlowValueSettings
} from './flowValueSettings'
/**
* Compact, agent-friendly representation of a flow.
@@ -27,7 +39,7 @@ export type EditableFlowJson = {
failure_module: FlowModule | null
groups: FlowGroup[] | null
notes: FlowNote[] | null
}
} & FlowValueSettings
/** Optional input to the rich-error path of `validateEditableFlowJson`. */
type SchemaErrorContext = {
@@ -279,10 +291,19 @@ function validateOptionalFlowModule(rawModule: unknown, fieldName: string): Flow
return result.data
}
export const EDITABLE_FLOW_STRUCTURAL_KEYS = [
'modules',
'schema',
'preprocessor_module',
'failure_module',
'groups',
'notes'
] as const
/**
* Parse and validate a raw object as an `EditableFlowJson`. Validates module
* shape, schema shape, optional special modules (with their reserved ids),
* groups, and that no module ids collide.
* groups, top-level flow settings, and that no module ids collide.
*/
export function validateEditableFlowJson(
rawFlow: unknown,
@@ -293,6 +314,28 @@ export function validateEditableFlowJson(
}
const flow = rawFlow as Record<string, unknown>
// Reject unknown top-level keys: silently dropping them would make patch
// tools report success for edits that never land on the flow.
const allowedKeys = new Set<string>([
...EDITABLE_FLOW_STRUCTURAL_KEYS,
...FLOW_VALUE_SETTINGS_KEYS
])
const unknownKeys = Object.keys(flow).filter((key) => !allowedKeys.has(key))
if (unknownKeys.length > 0) {
throw new Error(
`Unknown top-level flow key(s): ${unknownKeys.join(', ')}. Allowed keys: ${[...allowedKeys].join(', ')}`
)
}
const settingsResult = flowValueSettingsSchema.safeParse(flow)
if (!settingsResult.success) {
const issue = settingsResult.error.issues[0]
const path = issue?.path?.join('.') ?? 'settings'
throw new Error(`Invalid flow setting ${path}: ${issue?.message ?? 'unknown error'}`)
}
const settings = pickFlowValueSettings(settingsResult.data)
const modules = validateFlowModules(flow.modules, ctx)
const schema = validateFlowSchema(flow.schema)
const preprocessorModule = validateOptionalFlowModule(
@@ -345,7 +388,8 @@ export function validateEditableFlowJson(
preprocessor_module: preprocessorModule,
failure_module: failureModule,
groups,
notes
notes,
...settings
}
}
@@ -401,7 +445,8 @@ export function buildEditableFlowJson(
preprocessor_module: preprocessorModule ?? null,
failure_module: failureModule ?? null,
groups: flow.value.groups ?? null,
notes: flow.value.notes ?? null
notes: flow.value.notes ?? null,
...pickFlowValueSettings(flow.value)
}
}
@@ -420,8 +465,12 @@ export function restoreSpecialRawscriptModule(
/**
* Inverse of `buildEditableFlowJson`. Replaces `inline_script.<moduleId>`
* placeholders in `editable.modules` and the special modules with the content
* stored in `session`. Other fields on the original FlowValue (`same_worker`,
* `concurrent_limit`, etc.) are preserved.
* stored in `session`.
*
* The compact view is the full state for the settings in
* `FLOW_VALUE_SETTINGS_KEYS`: a settings key absent from `editable` is removed
* from the result, so patches can unset them. Fields of the original FlowValue
* outside that list are preserved untouched.
*
* Pair with `buildEditableFlowJson` for round-trip patches: extract → patch
* the compact view → restore.
@@ -431,7 +480,7 @@ export function applyEditableFlowJsonToFlow(
editable: EditableFlowJson,
session: InlineScriptSession
): FlowValue {
return {
const result: FlowValue = {
...originalValue,
modules: session.restoreInlineScriptReferences(editable.modules),
preprocessor_module:
@@ -440,6 +489,14 @@ export function applyEditableFlowJsonToFlow(
groups: editable.groups ?? undefined,
notes: editable.notes ?? undefined
}
for (const key of FLOW_VALUE_SETTINGS_KEYS) {
if (editable[key] !== undefined) {
;(result as Record<string, unknown>)[key] = editable[key]
} else {
delete (result as Record<string, unknown>)[key]
}
}
return result
}
/**
@@ -0,0 +1,52 @@
import { z } from 'zod'
import type { FlowValue } from '$lib/gen'
/**
* Non-structural top-level FlowValue settings exposed in the compact view so
* they round-trip through patch/write tools instead of being silently dropped.
* Must stay in sync with the top-level scalar/config fields of `FlowValue`.
*/
export const flowValueSettingsSchema = z
.object({
same_worker: z.boolean(),
preserve_step_tags: z.boolean(),
concurrent_limit: z.number(),
concurrency_key: z.string(),
concurrency_time_window_s: z.number(),
debounce_delay_s: z.number(),
debounce_key: z.string(),
debounce_args_to_accumulate: z.array(z.string()),
max_total_debouncing_time: z.number(),
max_total_debounces_amount: z.number(),
skip_expr: z.string(),
cache_ttl: z.number(),
cache_ignore_s3_path: z.boolean(),
delete_after_secs: z.number(),
flow_env: z.record(z.string(), z.any()),
priority: z.number(),
early_return: z.string(),
chat_input_enabled: z.boolean()
})
.partial()
type FlowValueSettingsKey = keyof z.infer<typeof flowValueSettingsSchema> & keyof FlowValue
export const FLOW_VALUE_SETTINGS_KEYS = Object.keys(
flowValueSettingsSchema.shape
) as FlowValueSettingsKey[]
export type FlowValueSettings = Pick<FlowValue, FlowValueSettingsKey>
/**
* Extract the defined non-structural settings from a FlowValue (or an
* EditableFlowJson, which carries the same keys).
*/
export function pickFlowValueSettings(source: Record<string, unknown>): FlowValueSettings {
const settings: Record<string, unknown> = {}
for (const key of FLOW_VALUE_SETTINGS_KEYS) {
if (source[key] !== undefined) {
settings[key] = source[key]
}
}
return settings as FlowValueSettings
}
@@ -53,6 +53,39 @@ function makeAiAgentWithTool(agentId: string, toolId: string, toolContent: strin
}
describe('applyFlowJsonUpdate', () => {
it('leaves top-level flow settings untouched when settings is not provided', () => {
const flow = {
value: {
modules: [makeRawScriptModule('step_a', 'code a')],
chat_input_enabled: true,
same_worker: true
}
}
applyFlowJsonUpdate(flow as any, createInlineScriptSession(), {
modules: [makeRawScriptModule('step_b', 'code b')]
})
expect((flow.value as any).chat_input_enabled).toBe(true)
expect((flow.value as any).same_worker).toBe(true)
})
it('applies full-state settings: sets provided keys and removes absent ones', () => {
const flow = {
value: {
modules: [makeRawScriptModule('step_a', 'code a')],
chat_input_enabled: true
}
}
applyFlowJsonUpdate(flow as any, createInlineScriptSession(), {
settings: { same_worker: true }
})
expect((flow.value as any).same_worker).toBe(true)
expect('chat_input_enabled' in flow.value).toBe(false)
})
it('accepts new self-referenced inline scripts and initializes them as empty', () => {
const flow = {
value: {
@@ -8,6 +8,7 @@ import {
NoteColor
} from '$lib/components/graph/noteColors'
import type { InlineScriptSession } from './inlineScriptsUtils'
import { FLOW_VALUE_SETTINGS_KEYS, type FlowValueSettings } from './flowValueSettings'
/** Allowed note/group color names — matches the NoteColor palette the note and
* group editors use. The note renderer keys `NOTE_COLORS` by these exact names,
@@ -56,6 +57,9 @@ export interface FlowJsonUpdate {
failureModule?: FlowModule | null
groups?: FlowGroup[] | null
notes?: FlowNote[] | null
/** Full state of the top-level FlowValue settings: when provided, keys
* absent from it are removed from the flow value. */
settings?: FlowValueSettings
}
export interface FlowJsonUpdateResult {
@@ -269,7 +273,7 @@ export function validateFlowNotes(rawNotes: unknown, moduleIds?: Set<string>): F
export function applyFlowJsonUpdate(
flow: FlowLike,
inlineScriptSession: InlineScriptSession,
{ modules, schema, preprocessorModule, failureModule, groups, notes }: FlowJsonUpdate
{ modules, schema, preprocessorModule, failureModule, groups, notes, settings }: FlowJsonUpdate
): FlowJsonUpdateResult {
const emptyInlineScriptModuleIds = new Set<string>()
@@ -307,6 +311,16 @@ export function applyFlowJsonUpdate(
flow.value.notes = notes == null || notes.length === 0 ? undefined : notes
}
if (settings !== undefined) {
for (const key of FLOW_VALUE_SETTINGS_KEYS) {
if (settings[key] !== undefined) {
;(flow.value as Record<string, unknown>)[key] = settings[key]
} else {
delete (flow.value as Record<string, unknown>)[key]
}
}
}
return {
emptyInlineScriptModuleIds: Array.from(emptyInlineScriptModuleIds)
}
@@ -1941,7 +1941,7 @@ describe('global AI tools', () => {
path: 'f/flows/existing',
summary: 'deployed summary',
description: 'deployed description',
value: { modules: [] },
value: { modules: [], chat_input_enabled: true, same_worker: true },
schema: { properties: { deployed: { type: 'boolean' } } },
edited_by: 'admin',
edited_at: '2026-05-22T09:00:00Z',
@@ -1961,7 +1961,11 @@ describe('global AI tools', () => {
path: 'f/flows/existing',
summary: 'new summary',
description: 'deployed description',
value: { modules: [{ id: 'step', value: { type: 'identity' } }] }
value: {
modules: [{ id: 'step', value: { type: 'identity' } }],
chat_input_enabled: true,
same_worker: true
}
})
})
@@ -58,7 +58,10 @@ import { isChromiumBrowser } from '$lib/utils'
import {
applyEditableFlowJsonToFlow,
buildEditableFlowJson,
EDITABLE_FLOW_STRUCTURAL_KEYS,
type EditableFlowJson,
FLOW_VALUE_SETTINGS_KEYS,
pickFlowValueSettings,
finalizeUnresolvedInlineScripts,
restoreSpecialRawscriptModule,
validateEditableFlowJson
@@ -521,6 +524,7 @@ function appendEmptyInlineScriptWarning(result: string, editable: EditableFlowJs
function editableFlowToDraftValue(editable: EditableFlowJson): FlowDraftValue {
const value: FlowValue = {
...pickFlowValueSettings(editable),
modules: editable.modules,
preprocessor_module: editable.preprocessor_module ?? undefined,
failure_module: editable.failure_module ?? undefined,
@@ -1947,8 +1951,9 @@ function getFlowInstructions(): string {
- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.
- Paths follow the conventions in the system prompt: default to \`u/<current-user>/<name>\` when the user gave a bare name; only use \`f/<folder>/<name>\` when the folder is known to exist. Never invent a folder.
- \`write_flow\` mirrors flow mode's \`set_flow_json\`: pass \`path\`, optional \`summary\`, optional \`description\`, required \`modules\`, and optional \`schema\`, \`preprocessor_module\`, \`failure_module\`, \`groups\`, and \`notes\`. \`summary\` and \`description\` are top-level flow metadata (not part of the compact value \`patch_flow_json\` edits); the flow-structure arguments are JSON strings, matching the tool schema descriptions.
- \`read_workspace_item\` returns a compact flow \`value\` object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, \`groups\`, and \`notes\`.
- \`write_flow\` mirrors flow mode's \`set_flow_json\`: pass \`path\`, optional \`summary\`, optional \`description\`, required \`modules\`, and optional \`schema\`, \`preprocessor_module\`, \`failure_module\`, \`groups\`, and \`notes\`. \`summary\` and \`description\` are top-level flow metadata (not part of the compact value \`patch_flow_json\` edits); the flow-structure arguments are JSON strings, matching the tool schema descriptions. When overwriting an existing flow, top-level flow settings (see below) are preserved from the current flow — use \`patch_flow_json\` to change them.
- \`read_workspace_item\` returns a compact flow \`value\` object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, \`groups\`, \`notes\`, and any top-level flow settings that are set.
- Top-level flow settings appear as top-level keys of the compact flow value and can be added, edited, or removed with \`patch_flow_json\`: ${FLOW_VALUE_SETTINGS_KEYS.join(', ')}. For example, \`chat_input_enabled: true\` marks a flow as chat-style (flow-as-chat); keep it intact when restructuring such a flow.
- \`modules\` contains normal sequential modules. Use top-level \`preprocessor_module\` and \`failure_module\` for special modules; do not put \`preprocessor\` or \`failure\` in \`modules\`.
- Every module needs a stable unique \`id\` and a useful \`summary\` when the schema supports it.
- Prefer path/script/flow modules when composing existing workspace logic. Use rawscript modules only when new inline code is needed.
@@ -2878,7 +2883,8 @@ export const globalTools: Tool<{}>[] = [
path: parsed.path,
summary: parsed.summary,
description: parsed.description,
flow: editableFlowToDraftValue(resolved)
flow: editableFlowToDraftValue(resolved),
preserveBaseValueSettings: true
},
ctx
)
@@ -4169,8 +4175,14 @@ type FlowDraftArgs = {
description?: string
flow: FlowDraftValue
override?: boolean
/** Carry over the base value's non-structural fields (chat_input_enabled,
* same_worker, ...) into the new value. Set by write_flow, whose arguments
* cannot express them; patch_flow_json passes the full value state instead. */
preserveBaseValueSettings?: boolean
}
const FLOW_STRUCTURAL_VALUE_KEYS = new Set<string>(EDITABLE_FLOW_STRUCTURAL_KEYS)
const FLOW_SPEC: WriteSpec<Flow, FlowDraftArgs> = {
probe: (workspace, path) => FlowService.existsFlowByPath({ workspace, path }),
fetchDeployed: (workspace, path) => FlowService.getFlowByPath({ workspace, path }),
@@ -4179,6 +4191,16 @@ const FLOW_SPEC: WriteSpec<Flow, FlowDraftArgs> = {
if (args.flow.groups !== undefined && args.flow.groups !== null) {
value.groups = structuredClone(args.flow.groups)
}
if (args.preserveBaseValueSettings && base?.value) {
for (const [key, fieldValue] of Object.entries(base.value)) {
if (
!FLOW_STRUCTURAL_VALUE_KEYS.has(key) &&
(value as Record<string, unknown>)[key] === undefined
) {
;(value as Record<string, unknown>)[key] = structuredClone(fieldValue)
}
}
}
return base
? {
...structuredClone(base),
@@ -44,7 +44,7 @@
import CaptureTable from '$lib/components/triggers/CaptureTable.svelte'
import { isObjectTooBig, readFieldsRecursively } from '$lib/utils'
import { refreshFlowStateStore } from '$lib/components/flows/flowStoreRefresh.svelte'
import type { AiAgent, ScriptLang } from '$lib/gen'
import type { AiAgent, InputTransform, ScriptLang } from '$lib/gen'
import { deepEqual } from 'fast-equals'
import Toggle from '$lib/components/Toggle.svelte'
import { AI_AGENT_SCHEMA } from '../flowInfers'
@@ -599,24 +599,45 @@
false
)
} else if (aiAgentModules.length === 1) {
// Exactly one AI agent exists, configure it
// Exactly one AI agent exists: fill in defaults only for inputs the
// user hasn't configured, so re-enabling chat mode on an already
// configured agent doesn't clobber a custom user_message expression
// or a deliberate memory choice (e.g. off).
const aiAgent = aiAgentModules[0]
const value = aiAgent.value as AiAgent
// Set user_message to flow_input.user_message
value.input_transforms['user_message'] = {
type: 'javascript',
expr: 'flow_input.user_message'
// Degenerate shapes the input form can produce without deliberate
// configuration count as unconfigured: empty static value (undefined
// persists as null through JSON round-trips), blank JS expression
// (the JS toggle seeds a bare backtick pair), or an AI transform
// (meaningless for the chat input).
const isUnconfigured = (transform: InputTransform | undefined) =>
transform === undefined ||
(transform.type === 'static' && (transform.value == null || transform.value === '')) ||
(transform.type === 'javascript' && transform.expr.replaceAll('`', '').trim() === '') ||
transform.type === 'ai'
const applied: string[] = []
if (isUnconfigured(value.input_transforms['user_message'])) {
value.input_transforms['user_message'] = {
type: 'javascript',
expr: 'flow_input.user_message'
}
applied.push('user message input')
}
// Set messages_context_length to 10
value.input_transforms['memory'] = {
type: 'static',
value: { kind: 'auto', context_length: 10 }
if (isUnconfigured(value.input_transforms['memory'])) {
value.input_transforms['memory'] = {
type: 'static',
value: { kind: 'auto', context_length: 10 }
}
applied.push('context memory set to 10')
}
sendUserToast(
'Chat mode enabled. AI agent configured with user message input and context memory set to 10.',
applied.length > 0
? `Chat mode enabled. AI agent configured with ${applied.join(' and ')}.`
: 'Chat mode enabled. Existing AI agent configuration kept unchanged.',
false
)
}
@@ -1380,7 +1380,11 @@
maximum number of attempts as defined below.
</Tooltip>
{/snippet}
<FlowRetries bind:flowModuleRetry={flowModule.retry} bind:flowModule />
<FlowRetries
bind:flowModuleRetry={flowModule.retry}
bind:flowModule
{isAgentTool}
/>
</Section>
{:else if advancedSelected === 'runtime' && advancedRuntimeSelected === 'concurrency'}
<Section label="Concurrency limits" class="flex flex-col gap-4" eeOnly>
@@ -1545,7 +1549,11 @@
</div>
{:else if advancedSelected === 'sleep'}
<div>
<FlowModuleSleep previousModuleId={previousModule?.id} bind:flowModule />
<FlowModuleSleep
previousModuleId={previousModule?.id}
bind:flowModule
{isAgentTool}
/>
</div>
{:else if advancedSelected === 'debounce'}
<div>
@@ -12,13 +12,16 @@
import Section from '$lib/components/Section.svelte'
import Label from '$lib/components/Label.svelte'
import { getStepPropPicker } from '../previousResults'
import { SAME_WORKER_INCOMPATIBLE_MSG } from '../utils.svelte'
import { Alert } from '$lib/components/common'
interface Props {
flowModule: FlowModule
previousModuleId: string | undefined
isAgentTool?: boolean
}
let { flowModule = $bindable(), previousModuleId }: Props = $props()
let { flowModule = $bindable(), previousModuleId, isAgentTool = false }: Props = $props()
const { selectionManager, flowStore, flowStateStore, previewArgs } =
getContext<FlowEditorContext>('FlowEditorContext')
@@ -44,6 +47,8 @@
const result = flowStateStore.val[selectionManager.getSelectedId()]?.previewResult ?? {}
let isSleepEnabled = $derived(Boolean(flowModule.sleep))
// Agent tools never go through the flow scheduler, so `same_worker` doesn't apply to them.
let sameWorker = $derived(Boolean(!isAgentTool && flowStore.val.value.same_worker))
</script>
<Section label="Sleep" class="w-full">
@@ -54,8 +59,15 @@
</Tooltip>
{/snippet}
{#if sameWorker}
<Alert type="warning" size="xs" title="Disabled by the shared directory" class="mb-4">
{SAME_WORKER_INCOMPATIBLE_MSG} Disable `Same Worker` in the flow settings to use a sleep.
</Alert>
{/if}
<Toggle
checked={isSleepEnabled}
disabled={sameWorker}
class="mb-6"
on:change={() => {
if (isSleepEnabled && flowModule.sleep != undefined) {
@@ -72,7 +84,7 @@
}}
/>
<Label label="Sleep for duration">
{#if flowModule.sleep && schema.properties['sleep']}
{#if flowModule.sleep && schema.properties['sleep'] && !sameWorker}
<div class="border rounded-md overflow-auto">
<PropPickerWrapper
noFlowPlugConnect={true}
@@ -15,17 +15,21 @@
import { NEVER_TESTED_THIS_FAR } from '../models'
import { validateRetryConfig } from '$lib/utils'
import EEOnly from '$lib/components/EEOnly.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { SAME_WORKER_INCOMPATIBLE_MSG } from '../utils.svelte'
interface Props {
flowModuleRetry: Retry | undefined
disabled?: boolean
flowModule?: FlowModule
isAgentTool?: boolean
}
let {
flowModule = $bindable(),
flowModuleRetry = $bindable(),
disabled = false
disabled = false,
isAgentTool = false
}: Props = $props()
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
@@ -53,6 +57,14 @@
: null
)
// `flowModule` is only set when editing a flow step: the trigger/schedule usages of this
// component share the flow editor context but are unaffected by `same_worker`, and so are
// agent tools, which never go through the flow scheduler.
let sameWorker = $derived(
Boolean(flowModule && !isAgentTool && flowStore?.val?.value?.same_worker)
)
let isDisabled = $derived(disabled || sameWorker)
let isRetryConditionEnabled = $derived(Boolean(flowModuleRetry?.retry_if))
let result = $derived(
flowModule && flowStateStore?.val
@@ -111,9 +123,15 @@
</script>
<div class="flex flex-col gap-4">
{#if sameWorker}
<Alert type="warning" size="xs" title="Disabled by the shared directory">
{SAME_WORKER_INCOMPATIBLE_MSG} Disable `Same Worker` in the flow settings to use retries.
</Alert>
{/if}
<ToggleButtonGroup
bind:selected={delayType}
class={`${disabled ? 'disabled' : ''}`}
disabled={isDisabled}
on:selected={(e) => {
flowModuleRetry = undefined
if (e.detail === 'constant') {
@@ -132,7 +150,7 @@
{/snippet}
</ToggleButtonGroup>
{#if delayType === 'constant' || delayType === 'exponential'}
{#if (delayType === 'constant' || delayType === 'exponential') && !sameWorker}
<Section label="Retry Condition" class="w-full">
{#snippet header()}
<Tooltip>
@@ -216,7 +234,7 @@
</Section>
{/if}
{#if delayType === 'constant' || delayType === 'exponential'}
{#if (delayType === 'constant' || delayType === 'exponential') && !sameWorker}
<div class="flex h-[calc(100%-22px)]">
<div class="w-1/2 h-full overflow-auto pr-2">
{#if delayType === 'constant'}
@@ -30,6 +30,7 @@
import OnBehalfOfSelector, {
type OnBehalfOfChoice
} from '$lib/components/OnBehalfOfSelector.svelte'
import { modulesWithRetryOrSleep, SAME_WORKER_INCOMPATIBLE_MSG } from '../utils.svelte'
interface Props {
noEditor: boolean
@@ -63,6 +64,11 @@
let displayWorkerTagPicker = $state(false)
// Only block turning it on: a flow deployed with both (CLI, YAML editor) must stay fixable.
let conflictingModuleIds = $derived(
flowStore.val.value.same_worker ? [] : modulesWithRetryOrSleep(flowStore.val.value)
)
run(() => {
if (flowStore.val.tag) {
displayWorkerTagPicker = true
@@ -394,20 +400,31 @@
<!-- Shared Directory Section -->
{#if customUi?.settingsTabs?.sharedDiretory != false}
<Toggle
textClass="font-medium"
size="xs"
bind:checked={flowStore.val.value.same_worker}
options={{
right: 'Same Worker + Shared directory on `./shared`',
rightTooltip:
'Steps will share a folder at `./shared` in which they can store heavier data and ' +
'pass them to the next step. Beware that the `./shared` folder is not ' +
'preserved across suspends and sleeps.',
rightDocumentationLink:
'https://www.windmill.dev/docs/core_concepts/persistent_storage/within_windmill#shared-directory'
}}
/>
<div class="flex flex-col gap-1">
<Toggle
textClass="font-medium"
size="xs"
disabled={conflictingModuleIds.length > 0}
bind:checked={flowStore.val.value.same_worker}
options={{
right: 'Same Worker + Shared directory on `./shared`',
rightTooltip:
'Steps will share a folder at `./shared` in which they can store heavier data and ' +
'pass them to the next step. Beware that the `./shared` folder is not ' +
'preserved across suspends and sleeps.',
rightDocumentationLink:
'https://www.windmill.dev/docs/core_concepts/persistent_storage/within_windmill#shared-directory'
}}
/>
{#if conflictingModuleIds.length > 0}
<span class="text-xs text-secondary">
{SAME_WORKER_INCOMPATIBLE_MSG} Remove them from step{conflictingModuleIds.length > 1
? 's'
: ''}
{conflictingModuleIds.join(', ')} first.
</span>
{/if}
</div>
{/if}
<!-- Visibility Section -->
@@ -6,13 +6,16 @@ import {
type Job,
type RestartedFrom,
type OpenFlow,
type MemoryConfig
type MemoryConfig,
type FlowValue,
type Retry
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { cleanExpr, emptySchema } from '$lib/utils'
import { get } from 'svelte/store'
import type { FlowModuleState } from './flowState'
import { type PickableProperties, dfs } from './previousResults'
import { forEachFlowModule } from './dfs'
import { NEVER_TESTED_THIS_FAR } from './models'
import { sendUserToast } from '$lib/toast'
import type { ExtendedOpenFlow } from './types'
@@ -227,6 +230,37 @@ export function emptyFlowModuleState(): FlowModuleState {
}
}
// `same_worker` hands the next step directly to the worker holding `./shared`, bypassing
// `scheduled_for`: a retry delay is silently ignored, and a sleep breaks the hand-off so the
// next step can land on another worker without `./shared`. Keep the two mutually exclusive.
export const SAME_WORKER_INCOMPATIBLE_MSG =
'Retries and sleeps are not compatible with the shared directory (`Same Worker`): retry delays would be ignored and a sleep would lose the `./shared` folder.'
// Mirrors the backend's `Retry::has_attempts`: a retry with no attempt never runs, and the
// retries tab renders it as "Disabled", so it must not block anything.
function hasRetryAttempts(retry: Retry | undefined): boolean {
return (retry?.constant?.attempts ?? 0) > 0 || (retry?.exponential?.attempts ?? 0) > 0
}
export function modulesWithRetryOrSleep(flow: FlowValue): string[] {
// The failure and preprocessor modules live outside `modules` but run as regular steps on
// the same-worker hand-off. Agent tools don't: they never go through the flow scheduler.
const roots = [flow.modules, flow.failure_module, flow.preprocessor_module]
.flat()
.filter((m) => m != undefined)
const ids: string[] = []
forEachFlowModule(
roots,
(m) => {
if (hasRetryAttempts(m.retry) || m.sleep != undefined) {
ids.push(m.id)
}
},
{ skipToolNodes: true }
)
return ids
}
export function checkIfParentLoop(
flowStore: ExtendedOpenFlow,
modId: string
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import type { FlowValue } from '$lib/gen'
import { modulesWithRetryOrSleep } from './utils.svelte'
const constantRetry = { constant: { attempts: 1, seconds: 5 } }
function step(id: string, extra: Record<string, unknown> = {}) {
return { id, value: { type: 'identity' }, ...extra } as any
}
describe('modulesWithRetryOrSleep', () => {
it('reports retries and sleeps everywhere same_worker applies', () => {
const flow: FlowValue = {
modules: [
step('a', { retry: constantRetry }),
step('b', { sleep: { type: 'static', value: 3 } }),
step('c'),
{
id: 'd',
value: {
type: 'forloopflow',
modules: [step('e', { retry: constantRetry })],
iterator: { type: 'static', value: [] },
skip_failures: false
}
} as any
],
failure_module: step('failure', { retry: constantRetry }),
preprocessor_module: step('preprocessor', { sleep: { type: 'static', value: 1 } })
}
expect(modulesWithRetryOrSleep(flow)).toEqual(['a', 'b', 'e', 'failure', 'preprocessor'])
})
it('ignores what same_worker does not govern: agent tools and attempt-less retries', () => {
const flow: FlowValue = {
modules: [
step('a', { retry: { constant: { attempts: 0, seconds: 5 } } }),
{
id: 'b',
value: { type: 'aiagent', tools: [step('tool', { retry: constantRetry })] }
} as any
]
}
expect(modulesWithRetryOrSleep(flow)).toEqual([])
})
})
@@ -81,8 +81,13 @@
// `wm_coep` (embed-in-cross-origin-isolated-page opt-in) must be propagated
// to the wrapper document: under a COEP `require-corp` embedder, a nested
// document is only allowed to load if it asserts COEP itself, so the
// backend adds the header when the flag is present.
const coep = new URLSearchParams(window.location.search).has('wm_coep') ? '?wm_coep=1' : ''
// backend adds the header when the flag is present. Also request it when
// this document is itself cross-origin isolated (e.g. the raw app editor)
// — the wrapper would otherwise be blocked outright, URL flag or not.
const coep =
new URLSearchParams(window.location.search).has('wm_coep') || window.crossOriginIsolated
? '?wm_coep=1'
: ''
return `/api/w/${workspace}/apps_u/get_data/v/${secret}.html${coep}`
})
@@ -165,17 +165,26 @@
title: 'Grant permissions to custom_instance_user',
status: status?.logs.grant_permissions,
description:
'Gives custom_instance_user the required permissions to use the database. custom_instance_user is already created during a migration and has an auto-generated password stored in global_settings.custom_instance_pg_databases.user_pwd. Postgres triggers use custom_instance_replication_user (password in global_settings.custom_instance_replication_pwd). These are the commands : \n\n' +
'Gives custom_instance_user the required permissions to use the database. custom_instance_user is already created during a migration and has an auto-generated password stored in global_settings.custom_instance_pg_databases.user_pwd. These are the commands : \n\n' +
`GRANT CONNECT ON DATABASE "${dbname}" TO custom_instance_user;\n` +
'GRANT USAGE ON SCHEMA public TO custom_instance_user;\n' +
'GRANT CREATE ON SCHEMA public TO custom_instance_user;\n' +
`GRANT CREATE ON DATABASE "${dbname}" TO custom_instance_user;\n` +
'ALTER DEFAULT PRIVILEGES IN SCHEMA public \n' +
' GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES\n TO custom_instance_user;\n' +
'ALTER ROLE custom_instance_user CREATEROLE;\n' +
'ALTER ROLE custom_instance_user CREATEROLE;'
},
{
title: 'Grant replication to custom_instance_replication_user',
status: status?.logs.replication_user,
description:
'Postgres triggers on custom-instance datatables connect as custom_instance_replication_user, whose password is stored in global_settings.custom_instance_replication_pwd. The role is cluster-wide, so it is created on the Windmill PostgreSQL instance rather than on this database : \n\n' +
'ALTER ROLE custom_instance_replication_user REPLICATION;\n' +
'GRANT custom_instance_user TO custom_instance_replication_user;\n' +
'ALTER ROLE custom_instance_user NOREPLICATION;'
'GRANT custom_instance_user TO custom_instance_replication_user;\n\n' +
'Setting REPLICATION requires a superuser on PostgreSQL 15 and older. Managed instances never grant one, so on AWS RDS Windmill falls back to GRANT rds_replication TO custom_instance_replication_user. The database stays usable for datatables if this step fails, but postgres triggers on them do not.' +
(status?.logs.replication_user_error
? `\n\nError: ${status.logs.replication_user_error}`
: '')
}
],
status?.error ?? undefined
@@ -415,8 +415,10 @@
// This ensures the cross-origin isolation headers are fetched from the server
// which are required for SharedArrayBuffer and TypeScript workers to work correctly
const toPath = navigation.to?.url.pathname
if (toPath && (toPath.startsWith('/apps_raw/add') || toPath.startsWith('/apps_raw/edit'))) {
const currentPath = navigation.from?.url.pathname
const currentPath = navigation.from?.url.pathname
const isEditorPath = (p: string | undefined) =>
!!p && (p.startsWith('/apps_raw/add') || p.startsWith('/apps_raw/edit'))
if (isEditorPath(toPath)) {
// Reload if we're not on an apps_raw path, or if we're on the raw app viewer
// (/apps_raw/get/): the viewer doesn't have cross-origin isolation headers, so
// we need a full reload to fetch them for the editor.
@@ -424,6 +426,15 @@
navigation.cancel()
window.location.href = navigation.to!.url.href
}
} else if (toPath && (isEditorPath(currentPath) || window.crossOriginIsolated)) {
// Reverse of the guard above: leaving the isolated editor document must
// also fully reload, or its COEP header sticks for the rest of the SPA
// session and blocks CORP-less cross-origin subresources (e.g. images in
// a viewed app — see needs_cross_origin_isolation in static_assets.rs).
// The path check covers plain-HTTP origins, where `crossOriginIsolated`
// stays false even with the headers applied.
navigation.cancel()
window.location.href = navigation.to!.url.href
}
})
+21 -3
View File
@@ -24,15 +24,33 @@ const remoteUrl =
const cookieDomain = process.env.ISOLATE_DEV_AUTH === '1' ? '' : 'localhost'
// Cross-origin isolation headers, scoped to mirror the production predicate —
// see `needs_cross_origin_isolation` in backend/windmill-api/src/static_assets.rs
// for which paths need them and why the raw app viewer must be excluded.
// `enforce: 'pre'` so these headers are set before SvelteKit's sirv static
// handler serves `static/` files and ends the response without calling next().
function needsCrossOriginIsolation(url) {
const [path, query = ''] = url.split('?')
return (
path.startsWith('/apps_raw/edit') ||
path.startsWith('/apps_raw/add') ||
path.startsWith('/ui_builder/') ||
((path.startsWith('/public/') || path.startsWith('/a/')) &&
new URLSearchParams(query).has('wm_coep'))
)
}
let plugin = {
name: 'configure-response-headers',
enforce: 'pre',
configureServer: (server) => {
server.middlewares.use((_req, res, next) => {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp')
server.middlewares.use((req, res, next) => {
if (needsCrossOriginIsolation(req.url ?? '')) {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp')
}
// CORP on everything so dev assets stay loadable as subresources of
// isolated documents on other dev origins (e.g. 127.0.0.1 vs localhost).
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin')
next()
})