mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 16:02:11 +00:00
Merge branch 'main' into change-d274fbc6
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user