mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 08:02:18 +00:00
Merge branch 'main' into raw-apps-wmill-client-frontend
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -90,8 +90,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}`
|
||||
})
|
||||
|
||||
|
||||
@@ -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