diff --git a/.agents/skills/commit/SKILL.md b/.agents/skills/commit/SKILL.md new file mode 100644 index 0000000000..d610fa3f65 --- /dev/null +++ b/.agents/skills/commit/SKILL.md @@ -0,0 +1,59 @@ +--- +name: commit +description: Create a git commit with conventional commit format. MUST use anytime you want to commit changes. +--- + +# Git Commit Skill + +Create a focused, single-line commit following conventional commit conventions. + +## Instructions + +1. **Analyze changes**: Run `git status` and `git diff` to understand what was modified +2. **Stage only modified files**: Add files individually by name. NEVER use `git add -A` or `git add .` +3. **Write commit message**: Follow the conventional commit format as a single line + +## Conventional Commit Format + +``` +: +``` + +### Types +- `feat`: New feature or capability +- `fix`: Bug fix +- `refactor`: Code change that neither fixes a bug nor adds a feature +- `docs`: Documentation only changes +- `style`: Formatting, missing semicolons, etc (no code change) +- `test`: Adding or correcting tests +- `chore`: Maintenance tasks, dependency updates, etc +- `perf`: Performance improvement + +### Rules +- Message MUST be a single line (no multi-line messages) +- Description should be lowercase, imperative mood ("add" not "added") +- No period at the end +- Keep under 72 characters total + +### Examples +``` +feat: add token usage tracking for AI providers +fix: resolve null pointer in job executor +refactor: extract common validation logic +docs: update API endpoint documentation +chore: upgrade sqlx to 0.7 +``` + +## Execution Steps + +1. Run `git status` to see all changes +2. Run `git diff` to understand the changes in detail +3. Run `git log --oneline -5` to see recent commit style +4. Stage ONLY the modified/relevant files: `git add ...` +5. Create the commit with conventional format: + ```bash + git commit -m ": + + Co-Authored-By: Claude Opus 4.5 " + ``` +6. Run `git status` to verify the commit succeeded diff --git a/.agents/skills/local-review/SKILL.md b/.agents/skills/local-review/SKILL.md new file mode 100644 index 0000000000..ad701ac367 --- /dev/null +++ b/.agents/skills/local-review/SKILL.md @@ -0,0 +1,97 @@ +--- +name: local-review +description: Code review a pull request for bugs and CLAUDE.md compliance. MUST use when asked to review code. +--- + +# Local Code Review Skill + +Review a pull request for real bugs and CLAUDE.md compliance violations. This review targets HIGH SIGNAL issues only. + +## Review Philosophy + +- **Only flag issues you are certain about.** If you are not sure an issue is real, do not flag it. False positives erode trust and waste reviewer time. +- Think like a senior engineer doing a final review — flag things that would cause incidents, not things that are merely imperfect. + +## What to Flag + +- Code that won't compile or parse (syntax errors, type errors, missing imports) +- Code that will definitely produce wrong results regardless of inputs +- Clear, unambiguous CLAUDE.md violations (quote the exact rule being violated) +- Security issues in introduced code (injection, auth bypass, data exposure) +- Incorrect logic that will fail in production + +## What NOT to Flag + +- Code style or quality concerns +- Potential issues that depend on specific inputs or runtime state +- Subjective suggestions or improvements +- Pre-existing issues not introduced by this PR +- Pedantic nitpicks a senior engineer wouldn't flag +- Issues a linter or type checker will catch +- General quality concerns unless explicitly prohibited in CLAUDE.md +- Issues silenced via lint ignore comments + +## Execution Steps + +1. **Determine the PR scope**: + - If an argument is provided, use it as the PR number or branch + - Otherwise, detect from the current branch vs main + - Run `gh pr view` if a PR exists, or use `git diff main...HEAD` + +2. **Find relevant CLAUDE.md files**: + - Read the root `CLAUDE.md` + - Check for CLAUDE.md files in directories containing changed files + +3. **Get the diff and metadata**: + - `gh pr diff` or `git diff main...HEAD` for the full diff + - `gh pr view` or `git log main..HEAD --oneline` for context + +4. **Read changed files** where the diff alone is insufficient to understand context + +5. **Review for**: + - CLAUDE.md compliance — check each rule against the changed code + - Bugs and logic errors — will this code work correctly? + - Security issues — injection, auth, data exposure in new code + +6. **Self-validate each finding**: Before reporting, ask yourself: + - "Is this definitely a real issue, not a false positive?" + - "Would a senior engineer flag this in review?" + - If the answer to either is no, discard the finding + +7. **Output findings** to the terminal (default) or post as PR comments (with `--comment` flag) + +## Output Format + +``` +## Code review + +Found N issues: + +1. () + + +2. () + +``` + +If no issues are found: + +``` +## Code review + +No issues found. Checked for bugs and CLAUDE.md compliance. +``` + +## Posting Comments (--comment flag) + +If the user passes `--comment`, post findings as inline PR comments using: + +```bash +gh pr review --comment --body "" +``` + +Or for inline comments on specific lines: + +```bash +gh api repos/{owner}/{repo}/pulls/{pr}/reviews -f body="" -f event="COMMENT" -f comments="[...]" +``` diff --git a/.agents/skills/native-trigger/SKILL.md b/.agents/skills/native-trigger/SKILL.md new file mode 100644 index 0000000000..026c1900bf --- /dev/null +++ b/.agents/skills/native-trigger/SKILL.md @@ -0,0 +1,777 @@ +# Skill: Adding Native Trigger Services + +This skill provides comprehensive guidance for adding new native trigger services to Windmill. Native triggers allow external services (like Nextcloud, Google Drive, etc.) to trigger Windmill scripts/flows via webhooks or push notifications. + +## Architecture Overview + +The native trigger system consists of: + +1. **Database Layer** - PostgreSQL tables and enum types +2. **Backend Rust Implementation** - Core trait, handlers, and service modules in the `windmill-native-triggers` crate +3. **Frontend Svelte Components** - Configuration forms and UI components + +### Key Files + +| Component | Path | +|-----------|------| +| Core module with `External` trait | `backend/windmill-native-triggers/src/lib.rs` | +| Generic CRUD handlers | `backend/windmill-native-triggers/src/handler.rs` | +| Background sync logic | `backend/windmill-native-triggers/src/sync.rs` | +| OAuth/workspace integration | `backend/windmill-native-triggers/src/workspace_integrations.rs` | +| Re-export shim (windmill-api) | `backend/windmill-api/src/native_triggers/mod.rs` | +| TriggerKind enum | `backend/windmill-common/src/triggers.rs` | +| JobTriggerKind enum | `backend/windmill-common/src/jobs.rs` | +| Frontend service registry | `frontend/src/lib/components/triggers/native/utils.ts` | +| Frontend trigger utilities | `frontend/src/lib/components/triggers/utils.ts` | +| Trigger badges (icons + counts) | `frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte` | +| Workspace integrations UI | `frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte` | +| OAuth config form component | `frontend/src/lib/components/workspaceSettings/OAuthClientConfig.svelte` | +| OpenAPI spec | `backend/windmill-api/openapi.yaml` | +| Reference: Nextcloud module | `backend/windmill-native-triggers/src/nextcloud/` | +| Reference: Google module | `backend/windmill-native-triggers/src/google/` | + +### Crate Structure + +The native trigger code lives in the `windmill-native-triggers` crate (`backend/windmill-native-triggers/`). The `windmill-api` crate re-exports everything via a shim: + +```rust +// backend/windmill-api/src/native_triggers/mod.rs +pub use windmill_native_triggers::*; +``` + +All new service modules go in `backend/windmill-native-triggers/src/`. + +--- + +## Core Concepts + +### The `External` Trait + +Every native trigger service implements the `External` trait defined in `lib.rs`: + +```rust +#[async_trait] +pub trait External: Send + Sync + 'static { + // Associated types: + type ServiceConfig: Debug + DeserializeOwned + Serialize + Send + Sync; + type TriggerData: Debug + Serialize + Send + Sync; + type OAuthData: DeserializeOwned + Serialize + Clone + Send + Sync; + type CreateResponse: DeserializeOwned + Send + Sync; + + // Constants: + const SUPPORT_WEBHOOK: bool; + const SERVICE_NAME: ServiceName; + const DISPLAY_NAME: &'static str; + const TOKEN_ENDPOINT: &'static str; + const REFRESH_ENDPOINT: &'static str; + const AUTH_ENDPOINT: &'static str; + + // Required methods: + async fn create(&self, w_id, oauth_data, webhook_token, data, db, tx) -> Result; + async fn update(&self, w_id, oauth_data, external_id, webhook_token, data, db, tx) -> Result; + async fn get(&self, w_id, oauth_data, external_id, db, tx) -> Result; + async fn delete(&self, w_id, oauth_data, external_id, db, tx) -> Result<()>; + async fn exists(&self, w_id, oauth_data, external_id, db, tx) -> Result; + async fn maintain_triggers(&self, db, workspace_id, triggers, oauth_data, synced, errors); + fn external_id_and_metadata_from_response(&self, resp) -> (String, Option); + + // Methods with defaults: + async fn prepare_webhook(&self, db, w_id, headers, body, script_path, is_flow) -> Result; + fn service_config_from_create_response(&self, data, resp) -> Option; + fn additional_routes(&self) -> axum::Router; + async fn http_client_request(&self, url, method, workspace_id, tx, db, headers, body) -> Result; +} +``` + +Key design points: +- **`update()` returns `serde_json::Value`** - the resolved service_config to store. Each service is responsible for building the final config. +- **`maintain_triggers()`** - periodic background maintenance. Each service implements its own strategy (Nextcloud: reconcile with external state; Google: renew expiring channels). +- **No `list_all()` in the trait** - services that need it (Nextcloud) implement it privately; services that don't (Google) use different maintenance strategies. +- **No `get_external_id_from_trigger_data()` or `extract_service_config_from_trigger_data()`** - removed in favor of the `maintain_triggers` pattern. + +### Create Lifecycle: Two Paths + +The `create_native_trigger` handler in `handler.rs` supports two creation flows, controlled by `service_config_from_create_response()`: + +**Path A: Short (Google pattern)** - `service_config_from_create_response()` returns `Some(config)`: +1. `create()` registers on external service +2. `external_id_and_metadata_from_response()` extracts the ID +3. `service_config_from_create_response()` builds the config directly from input data + response metadata +4. Stores trigger in DB -- done, no extra round-trip + +Use this when the external_id is known before the create call (e.g., Google generates the channel_id as a UUID upfront and includes it in the webhook URL). + +**Path B: Long (Nextcloud pattern)** - `service_config_from_create_response()` returns `None` (default): +1. `create()` registers on external service (webhook URL has no external_id yet) +2. `external_id_and_metadata_from_response()` extracts the ID +3. `update()` is called to fix the webhook URL with the now-known external_id +4. `update()` returns the resolved service_config +5. Stores trigger in DB + +Use this when the external_id is assigned by the remote service and the webhook URL needs to be corrected after creation. + +### OAuth Token Storage (Three-Table Pattern) + +OAuth tokens are stored across three tables, NOT in `workspace_integrations.oauth_data` directly: + +| Table | What's Stored | +|-------|---------------| +| `workspace_integrations` | `oauth_data` JSON with `base_url`, `client_id`, `client_secret`, `instance_shared` flag; `resource_path` pointing to the variable | +| `variable` | Encrypted `access_token` (at the path stored in `resource_path`), linked to `account` via `account` column | +| `account` | `refresh_token`, keyed by `workspace_id` + `client` (service name) + `is_workspace_integration = true` | + +The `decrypt_oauth_data()` function in `lib.rs` assembles these into a unified struct: +```rust +pub struct OAuthConfig { + pub base_url: String, + pub access_token: String, // decrypted from variable + pub refresh_token: Option, // from account table + pub client_id: String, // from oauth_data or instance settings + pub client_secret: String, // from oauth_data or instance settings +} +``` + +Instance-level sharing: when `oauth_data.instance_shared == true`, `client_id` and `client_secret` are read from global settings instead of workspace_integrations. + +### URL Resolution + +The `resolve_endpoint()` helper handles both absolute and relative OAuth URLs: + +```rust +pub fn resolve_endpoint(base_url: &str, endpoint: &str) -> String { + if endpoint.starts_with("http://") || endpoint.starts_with("https://") { + endpoint.to_string() // Google: absolute URLs + } else { + format!("{}{}", base_url, endpoint) // Nextcloud: relative paths + } +} +``` + +### ServiceName Methods + +`ServiceName` is the central registry enum. Each variant must implement these match arms: + +| Method | Purpose | +|--------|---------| +| `as_str()` | Lowercase identifier (e.g., `"google"`) | +| `as_trigger_kind()` | Maps to `TriggerKind` enum | +| `as_job_trigger_kind()` | Maps to `JobTriggerKind` enum | +| `token_endpoint()` | OAuth token endpoint (relative or absolute) | +| `auth_endpoint()` | OAuth authorization endpoint | +| `oauth_scopes()` | Space-separated OAuth scopes | +| `resource_type()` | Resource type for token storage (e.g., `"gworkspace"`) | +| `extra_auth_params()` | Extra OAuth params (e.g., Google needs `access_type=offline`, `prompt=consent`) | +| `integration_service()` | Maps to the workspace integration service (usually `*self`) | +| `TryFrom` | Parse from string | +| `Display` | Delegates to `as_str()` | + +--- + +## Step-by-Step Implementation Guide + +### Step 1: Database Migration + +Create a new migration file: `backend/migrations/YYYYMMDDHHMMSS_newservice_trigger.up.sql` + +```sql +-- Add the service to the native_trigger_service enum +ALTER TYPE native_trigger_service ADD VALUE IF NOT EXISTS 'newservice'; + +-- Add to TRIGGER_KIND enum (used for trigger tracking) +ALTER TYPE TRIGGER_KIND ADD VALUE IF NOT EXISTS 'newservice'; + +-- Add to job_trigger_kind enum (used for job tracking) +ALTER TYPE job_trigger_kind ADD VALUE IF NOT EXISTS 'newservice'; +``` + +Also create the corresponding down migration. + +### Step 2: Update windmill-common Enums + +#### `backend/windmill-common/src/triggers.rs` + +Add variant to `TriggerKind` enum, and update `to_key()` and `fmt()` implementations. + +#### `backend/windmill-common/src/jobs.rs` + +Add variant to `JobTriggerKind` enum and update the `Display` implementation. + +### Step 3: Backend Service Module + +Create a new directory: `backend/windmill-native-triggers/src/newservice/` + +#### `mod.rs` - Type Definitions + +```rust +use serde::{Deserialize, Serialize}; + +pub mod external; +// pub mod routes; // Only if you need additional service-specific routes + +/// OAuth data deserialized from the three-table pattern. +/// The actual structure is built by decrypt_oauth_data() from variable + account + workspace_integrations. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct NewServiceOAuthData { + pub base_url: String, // from workspace_integrations.oauth_data + pub access_token: String, // decrypted from variable table + pub refresh_token: Option, // from account table + // Note: client_id and client_secret are in OAuthConfig, not here + // unless the service needs them at runtime for API calls +} + +/// Configuration provided by user when creating/updating a trigger. +/// Stored as JSON in native_trigger.service_config. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NewServiceConfig { + // Service-specific configuration fields + pub folder_path: String, + pub file_filter: Option, +} + +/// Data retrieved from the external service about a trigger. +/// Returned by the get() method and shown in the UI. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NewServiceTriggerData { + pub folder_path: String, + pub file_filter: Option, + // Fields that shouldn't affect service_config comparison should use #[serde(skip_serializing)] +} + +/// Response from external service when creating a trigger/webhook. +#[derive(Debug, Deserialize)] +pub struct CreateTriggerResponse { + pub id: String, +} + +/// Handler struct (stateless, used for routing) +#[derive(Copy, Clone)] +pub struct NewService; +``` + +#### `external.rs` - External Trait Implementation + +```rust +use async_trait::async_trait; +use reqwest::Method; +use sqlx::PgConnection; +use std::collections::HashMap; +use windmill_common::{ + error::{Error, Result}, + BASE_URL, DB, +}; + +use crate::{ + generate_webhook_service_url, External, NativeTrigger, NativeTriggerData, ServiceName, + sync::{SyncError, TriggerSyncInfo}, +}; +use super::{NewService, NewServiceConfig, NewServiceOAuthData, NewServiceTriggerData, CreateTriggerResponse}; + +#[async_trait] +impl External for NewService { + type ServiceConfig = NewServiceConfig; + type TriggerData = NewServiceTriggerData; + type OAuthData = NewServiceOAuthData; + type CreateResponse = CreateTriggerResponse; + + const SERVICE_NAME: ServiceName = ServiceName::NewService; + const DISPLAY_NAME: &'static str = "New Service"; + const SUPPORT_WEBHOOK: bool = true; + const TOKEN_ENDPOINT: &'static str = "/oauth/token"; + const REFRESH_ENDPOINT: &'static str = "/oauth/token"; + const AUTH_ENDPOINT: &'static str = "/oauth/authorize"; + + async fn create( + &self, + w_id: &str, + oauth_data: &Self::OAuthData, + webhook_token: &str, + data: &NativeTriggerData, + db: &DB, + tx: &mut PgConnection, + ) -> Result { + let base_url = &*BASE_URL.read().await; + + // external_id is None during create (we get it from the response) + let webhook_url = generate_webhook_service_url( + base_url, w_id, &data.script_path, data.is_flow, + None, Self::SERVICE_NAME, webhook_token, + ); + + let url = format!("{}/api/webhooks/create", oauth_data.base_url); + let payload = serde_json::json!({ + "callback_url": webhook_url, + "folder_path": data.service_config.folder_path, + }); + + let response: CreateTriggerResponse = self + .http_client_request(&url, Method::POST, w_id, tx, db, None, Some(&payload)) + .await?; + + Ok(response) + } + + /// Update returns the resolved service_config as JSON. + /// For services using the update+get pattern, call self.get() and serialize. + async fn update( + &self, + w_id: &str, + oauth_data: &Self::OAuthData, + external_id: &str, + webhook_token: &str, + data: &NativeTriggerData, + db: &DB, + tx: &mut PgConnection, + ) -> Result { + let base_url = &*BASE_URL.read().await; + + let webhook_url = generate_webhook_service_url( + base_url, w_id, &data.script_path, data.is_flow, + Some(external_id), Self::SERVICE_NAME, webhook_token, + ); + + let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id); + let payload = serde_json::json!({ + "callback_url": webhook_url, + "folder_path": data.service_config.folder_path, + }); + + let _: serde_json::Value = self + .http_client_request(&url, Method::PUT, w_id, tx, db, None, Some(&payload)) + .await?; + + // Fetch back the updated state to get the resolved config + let trigger_data = self.get(w_id, oauth_data, external_id, db, tx).await?; + serde_json::to_value(&trigger_data) + .map_err(|e| Error::InternalErr(format!("Failed to serialize trigger data: {}", e))) + } + + async fn get( + &self, + w_id: &str, + oauth_data: &Self::OAuthData, + external_id: &str, + db: &DB, + tx: &mut PgConnection, + ) -> Result { + let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id); + self.http_client_request::<_, ()>(&url, Method::GET, w_id, tx, db, None, None).await + } + + async fn delete( + &self, + w_id: &str, + oauth_data: &Self::OAuthData, + external_id: &str, + db: &DB, + tx: &mut PgConnection, + ) -> Result<()> { + let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id); + let _: serde_json::Value = self + .http_client_request::<_, ()>(&url, Method::DELETE, w_id, tx, db, None, None) + .await + .or_else(|e| match &e { + Error::InternalErr(msg) if msg.contains("404") => Ok(serde_json::Value::Null), + _ => Err(e), + })?; + Ok(()) + } + + async fn exists( + &self, + w_id: &str, + oauth_data: &Self::OAuthData, + external_id: &str, + db: &DB, + tx: &mut PgConnection, + ) -> Result { + match self.get(w_id, oauth_data, external_id, db, tx).await { + Ok(_) => Ok(true), + Err(Error::NotFound(_)) => Ok(false), + Err(e) => Err(e), + } + } + + /// Background maintenance. Choose the right pattern for your service: + /// - For services with queryable external state: use reconcile_with_external_state() + /// - For channel-based services with expiration: implement renewal logic + async fn maintain_triggers( + &self, + db: &DB, + workspace_id: &str, + triggers: &[NativeTrigger], + oauth_data: &Self::OAuthData, + synced: &mut Vec, + errors: &mut Vec, + ) { + // Option A: Reconcile with external state (Nextcloud pattern) + // Fetch all triggers from external service and compare with DB + let external_triggers = match self.list_all(workspace_id, oauth_data, db).await { + Ok(triggers) => triggers, + Err(e) => { + errors.push(SyncError { + resource_path: format!("workspace:{}", workspace_id), + error_message: format!("Failed to list triggers: {}", e), + error_type: "api_error".to_string(), + }); + return; + } + }; + + // Convert to (external_id, config_json) pairs + let external_pairs: Vec<(String, serde_json::Value)> = external_triggers + .into_iter() + .map(|t| (t.id.clone(), serde_json::to_value(&t).unwrap_or_default())) + .collect(); + + crate::sync::reconcile_with_external_state( + db, workspace_id, Self::SERVICE_NAME, triggers, &external_pairs, synced, errors, + ).await; + } + + fn external_id_and_metadata_from_response( + &self, + resp: &Self::CreateResponse, + ) -> (String, Option) { + (resp.id.clone(), None) + } + + // service_config_from_create_response: NOT overridden (returns None). + // This means the handler uses the update+get pattern after create. + // Override and return Some(...) to skip the update+get cycle (Google pattern). +} + +impl NewService { + /// Private helper to list all triggers from the external service. + async fn list_all( + &self, + w_id: &str, + oauth_data: &::OAuthData, + db: &DB, + ) -> Result::TriggerData>> { + // Implementation depends on the external service's API + todo!() + } +} +``` + +### Step 4: Update lib.rs Registry + +In `backend/windmill-native-triggers/src/lib.rs`: + +```rust +// Service modules - add new services here: +#[cfg(feature = "native_trigger")] +pub mod newservice; // <-- Add this + +// ServiceName enum - add variant: +pub enum ServiceName { + Nextcloud, + Google, + NewService, // <-- Add this +} + +// Then add match arms in ALL ServiceName methods: +// as_str(), as_trigger_kind(), as_job_trigger_kind(), token_endpoint(), +// auth_endpoint(), oauth_scopes(), resource_type(), extra_auth_params(), +// integration_service(), TryFrom, Display +``` + +### Step 5: Update handler.rs Routes + +In `backend/windmill-native-triggers/src/handler.rs`: + +```rust +pub fn generate_native_trigger_routers() -> Router { + // ... + #[cfg(feature = "native_trigger")] + { + use crate::newservice::NewService; + return router + .nest("/nextcloud", service_routes(NextCloud)) + .nest("/google", service_routes(Google)) + .nest("/newservice", service_routes(NewService)); // <-- Add this + } + // ... +} +``` + +### Step 6: Update sync.rs + +In `backend/windmill-native-triggers/src/sync.rs`: + +```rust +pub async fn sync_all_triggers(db: &DB) -> Result { + // ... + #[cfg(feature = "native_trigger")] + { + use crate::newservice::NewService; + + // ... existing service syncs ... + + // New service sync + let (service_name, result) = sync_service_triggers(db, NewService).await; + total_synced += result.synced_triggers.len(); + total_errors += result.errors.len(); + service_results.insert(service_name, result); + } + // ... +} +``` + +### Step 7: Frontend Service Registry + +In `frontend/src/lib/components/triggers/native/utils.ts`: + +Add to `NATIVE_TRIGGER_SERVICES`, `getTriggerIconName()`, and `getServiceIcon()`. + +### Step 8: Frontend Trigger Form Component + +Create: `frontend/src/lib/components/triggers/native/services/newservice/NewServiceTriggerForm.svelte` + +### Step 9: Frontend Icon Component + +Create: `frontend/src/lib/components/icons/NewServiceIcon.svelte` + +### Step 10: Update NativeTriggerEditor + +Check `frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte` to ensure it dynamically loads form components based on service name. + +### Step 11: Workspace Integration UI + +Add your service to the `supportedServices` map in `frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte`: + +```typescript +const supportedServices: Record = { + // ... existing services ... + newservice: { + name: 'newservice', + displayName: 'New Service', + description: 'Connect to New Service for triggers', + icon: NewServiceIcon, + docsUrl: 'https://www.windmill.dev/docs/integrations/newservice', + requiresBaseUrl: false, // false for cloud services, true for self-hosted + setupInstructions: [ + 'Step 1: Create an OAuth app on the service', + 'Step 2: Configure the redirect URI shown below', + 'Step 3: Enter the client credentials below' + ] + } +} +``` + +### Step 12: Update `frontend/src/lib/components/triggers/utils.ts` + +Update ALL of these maps/functions: +1. `triggerIconMap` - import and add icon +2. `triggerDisplayNamesMap` - add display name +3. `triggerTypeOrder` in `sortTriggers()` - add type +4. `getLightConfig()` - add case for your service +5. `getTriggerLabel()` - add case for your service +6. `jobTriggerKinds` - add to array +7. `countPropertyMap` - add count property +8. `triggerSaveFunctions` - add save function + +### Step 13: Update TriggersBadge Component + +In `frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte`: + +1. Import the icon +2. Add to `baseConfig` with `countKey` (the dynamic `availableNativeServices` loop does NOT set `countKey`) +3. Add to the `allTypes` array + +### Step 14: Update TriggersWrapper.svelte + +In `frontend/src/lib/components/triggers/TriggersWrapper.svelte`: + +Add a `{:else if selectedTrigger.type === 'yourservice'}` case that renders `` with the same props pattern as the existing native trigger cases (e.g., `nextcloud`). + +### Step 15: Update AddTriggersButton.svelte + +In `frontend/src/lib/components/triggers/AddTriggersButton.svelte`: + +1. Add `yourserviceAvailable` state variable +2. Add `setYourserviceState()` async function using `isServiceAvailable('yourservice', $workspaceStore!)` +3. Call it at module level +4. Add a dropdown entry to `addTriggerItems` with `hidden: !yourserviceAvailable` + +### Step 16: Update TriggersEditor.svelte Delete Handling + +In `frontend/src/lib/components/triggers/TriggersEditor.svelte`: + +Add your service to the `nativeTriggerServices` map in `deleteDeployedTrigger()`. Native triggers use `NativeTriggerService.deleteNativeTrigger({ workspace, serviceName, externalId })` instead of the standard `path`-based delete. + +### Step 17: Update OpenAPI Spec and Regenerate Types + +Add to `JobTriggerKind` enum in `backend/windmill-api/openapi.yaml`, then: + +```bash +cd frontend && npm run generate-backend-client +``` + +--- + +## Special Patterns + +### Unified Service with `trigger_type` (Google Pattern) + +When a single service handles multiple trigger types (e.g., Google Drive + Calendar share OAuth and API patterns), use a single `ServiceName` variant with a discriminator field: + +```rust +pub enum GoogleTriggerType { Drive, Calendar } + +pub struct GoogleServiceConfig { + pub trigger_type: GoogleTriggerType, + // Drive-specific fields (only used when trigger_type = Drive) + pub resource_id: Option, + pub resource_name: Option, + // Calendar-specific fields (only used when trigger_type = Calendar) + pub calendar_id: Option, + pub calendar_name: Option, + // Metadata set after creation + pub google_resource_id: Option, + pub expiration: Option, +} +``` + +Branch in trait methods based on `trigger_type`. Frontend uses a `ToggleButtonGroup` to switch between types. This keeps the codebase simpler (one service, one OAuth flow, one set of routes). + +See `backend/windmill-native-triggers/src/google/` for the reference implementation. + +### Skipping update+get After Create (Google Pattern) + +Override `service_config_from_create_response()` to return `Some(config)` when the external_id is known before the create call: + +```rust +fn service_config_from_create_response( + &self, + data: &NativeTriggerData, + resp: &Self::CreateResponse, +) -> Option { + // Clone input config, add metadata from response + let mut config = data.service_config.clone(); + config.google_resource_id = Some(resp.resource_id.clone()); + config.expiration = Some(resp.expiration.clone()); + Some(serde_json::to_value(&config).unwrap()) +} +``` + +### Services with Absolute OAuth Endpoints (Google) + +Unlike self-hosted services where OAuth endpoints are relative paths appended to `base_url`, services like Google have absolute URLs: + +```rust +// Nextcloud: relative paths +ServiceName::Nextcloud => "/apps/oauth2/api/v1/token", +// Google: absolute URLs +ServiceName::Google => "https://oauth2.googleapis.com/token", +``` + +The `resolve_endpoint()` function handles both. For services with absolute endpoints: +- `base_url` can be empty +- `requiresBaseUrl: false` in the frontend workspace integration config +- Add `extra_auth_params()` if needed (Google requires `access_type=offline` and `prompt=consent`) + +### Channel-Based Push Notifications with Renewal (Google Pattern) + +For services using expiring watch channels instead of persistent webhooks: + +1. Store expiration in `service_config` (as part of `ServiceConfig`) +2. In `maintain_triggers()`, implement renewal logic instead of using `reconcile_with_external_state()`: + ```rust + async fn maintain_triggers(&self, db, workspace_id, triggers, oauth_data, synced, errors) { + for trigger in triggers { + if should_renew_channel(trigger) { + self.renew_channel(db, trigger, oauth_data).await; + } + } + } + ``` +3. Renewal: best-effort stop old channel, create new one with same external_id, update service_config with new expiration +4. Google example: Drive channels expire in 24h (renew when <1h left), Calendar channels expire in 7 days (renew when <1 day left) + +### reconcile_with_external_state (Nextcloud Pattern) + +The reusable function in `sync.rs` compares external triggers with DB state: +- Triggers missing externally: sets error "Trigger no longer exists on external service" +- Triggers present externally: clears errors, updates service_config if it differs + +Usage in `maintain_triggers()`: +```rust +let external_pairs: Vec<(String, serde_json::Value)> = /* fetch from external */; +crate::sync::reconcile_with_external_state( + db, workspace_id, Self::SERVICE_NAME, triggers, &external_pairs, synced, errors, +).await; +``` + +### Webhook Payload Processing + +Override `prepare_webhook()` to parse service-specific payloads into script/flow args: + +```rust +async fn prepare_webhook(&self, db, w_id, headers, body, script_path, is_flow) -> Result { + let mut args = HashMap::new(); + args.insert("event_type".to_string(), Box::new(headers.get("x-event-type").cloned()) as _); + args.insert("payload".to_string(), Box::new(serde_json::from_str::(&body)?) as _); + Ok(PushArgsOwned { extra: None, args }) +} +``` + +Then register in `prepare_native_trigger_args()` in `lib.rs`: +```rust +pub async fn prepare_native_trigger_args(service_name, db, w_id, headers, body) -> Result> { + match service_name { + ServiceName::Google => { /* ... */ Ok(Some(args)) } + ServiceName::NewService => { /* ... */ Ok(Some(args)) } + ServiceName::Nextcloud => Ok(None), // Uses default body parsing + } +} +``` + +### Instance-Level OAuth Credentials + +When `workspace_integrations.oauth_data.instance_shared == true`, `decrypt_oauth_data()` reads `client_id` and `client_secret` from instance-level global settings instead of workspace-level. This allows admins to share OAuth app credentials across workspaces. + +The frontend handles this via the `generate_instance_connect_url` endpoint in `workspace_integrations.rs`. + +--- + +## Testing Checklist + +- [ ] Database migration runs successfully +- [ ] `cargo check -p windmill-native-triggers --features native_trigger` passes +- [ ] `npx svelte-check --threshold error` passes (in frontend/) +- [ ] Service appears in workspace integrations list +- [ ] OAuth flow completes successfully +- [ ] Can create a new trigger +- [ ] Can view trigger details +- [ ] Can update trigger configuration +- [ ] Can delete trigger +- [ ] Webhook receives and processes payloads +- [ ] Background sync works correctly (reconciliation or channel renewal) +- [ ] Error handling works (expired tokens, service unavailable) + +--- + +## Reference Implementations + +### Nextcloud (Self-Hosted, Update+Get Pattern) + +| File | Purpose | +|------|---------| +| `nextcloud/mod.rs` | Types: NextCloudOAuthData, NextcloudServiceConfig, NextCloudTriggerData | +| `nextcloud/external.rs` | External trait: uses update+get pattern, reconcile_with_external_state for sync | +| `nextcloud/routes.rs` | Additional route: `GET /events` | + +Key patterns: relative OAuth endpoints, base_url required, list_all + reconcile for sync, update returns JSON from get(). + +### Google (Cloud, Unified Service, Short Create) + +| File | Purpose | +|------|---------| +| `google/mod.rs` | Types: GoogleServiceConfig with trigger_type discriminator, GoogleTriggerType enum | +| `google/external.rs` | External trait: overrides service_config_from_create_response, channel renewal for sync | +| `google/routes.rs` | Additional routes: `GET /calendars`, `GET /drive/files`, `GET /drive/shared_drives` | + +Key patterns: absolute OAuth endpoints, empty base_url, trigger_type for Drive/Calendar, expiring watch channels with renewal, service_config_from_create_response skips update+get, get() reconstructs data from stored service_config (no external "get channel" API). diff --git a/.agents/skills/pr/SKILL.md b/.agents/skills/pr/SKILL.md new file mode 100644 index 0000000000..2efcc4e0a6 --- /dev/null +++ b/.agents/skills/pr/SKILL.md @@ -0,0 +1,109 @@ +--- +name: pr +description: Open a draft pull request on GitHub. MUST use when you want to create/open a PR. +--- + +# Pull Request Skill + +Create a draft pull request with a clear title and explicit description of changes. + +## Instructions + +1. **Analyze branch changes**: Understand all commits since diverging from main +2. **Push to remote**: Ensure all commits are pushed +3. **Create draft PR**: Always open as draft for review before merging + +## PR Title Format + +Follow conventional commit format for the PR title: +``` +: +``` + +### Types +- `feat`: New feature or capability +- `fix`: Bug fix +- `refactor`: Code restructuring +- `docs`: Documentation changes +- `chore`: Maintenance tasks +- `perf`: Performance improvements + +### Title Rules +- Keep under 70 characters +- Use lowercase, imperative mood +- No period at the end +- If `*_ee.rs` files were modified, prefix with `[ee]`: `[ee] : ` + +## PR Body Format + +The body MUST be explicit about what changed. Structure: + +```markdown +## Summary + + +## Changes +- +- +- + +## Test plan +- [ ] +- [ ] + +--- +Generated with [Claude Code](https://claude.com/claude-code) +``` + +## Execution Steps + +1. Run `git status` to check for uncommitted changes +2. Run `git log main..HEAD --oneline` to see all commits in this branch +3. Run `git diff main...HEAD` to see the full diff against main +4. Check if remote branch exists and is up to date: + ```bash + git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || echo "no upstream" + ``` +5. Push to remote if needed: `git push -u origin HEAD` +6. Create draft PR using gh CLI: + ```bash + gh pr create --draft --title ": " --body "$(cat <<'EOF' + ## Summary + + + ## Changes + - + - + + ## Test plan + - [ ] + - [ ] + + --- + Generated with [Claude Code](https://claude.com/claude-code) + EOF + )" + ``` +7. Return the PR URL to the user + +## EE Companion PR (when `*_ee.rs` files were modified) + +The `*_ee.rs` files in the windmill repo are **symlinks** to `windmill-ee-private` — changes won't appear in `git diff` of the windmill repo. Instead, check the EE repo for uncommitted or unpushed changes. + +Follow the full EE PR workflow in `docs/enterprise.md`. The key PR-specific details: + +1. Find the EE repo/worktree: see "Finding the EE Repo" in `docs/enterprise.md` +2. Check for changes: `git -C status --short` + - If there are no changes in the EE repo, skip this entire section +3. Follow steps 1–5 from the "EE PR Workflow" in `docs/enterprise.md` +4. Create the companion PR (title does NOT get the `[ee]` prefix): + ```bash + gh pr create --draft --repo windmill-labs/windmill-ee-private --title ": " --body "$(cat <<'EOF' + Companion PR for windmill-labs/windmill# + + --- + Generated with [Claude Code](https://claude.com/claude-code) + EOF + )" + ``` +5. Commit `ee-repo-ref.txt` and push the updated windmill branch diff --git a/.agents/skills/refine/SKILL.md b/.agents/skills/refine/SKILL.md new file mode 100644 index 0000000000..b96e97e8a2 --- /dev/null +++ b/.agents/skills/refine/SKILL.md @@ -0,0 +1,38 @@ +--- +name: refine +description: End-of-session reflection. Reviews friction encountered during the session and proposes updates to docs/ to capture lessons learned. +--- + +# Refine Skill + +Reflect on the current session and update documentation with lessons learned. + +## Instructions + +1. **Identify friction**: Review what happened in this session: + - Run `git diff main...HEAD --stat` to see what files were touched + - Think about: what was slow, what failed, what required multiple attempts, what information was missing or hard to find + +2. **Read current docs**: Read the docs that were relevant to this session: + - `docs/validation.md` + - `docs/enterprise.md` + - `docs/autonomous-mode.md` + - Any skills that were invoked + +3. **Propose updates**: For each piece of friction, decide if it warrants a doc update: + - **Missing knowledge**: Information you had to discover that should be documented + - **Wrong guidance**: Instructions that led you astray + - **Missing validation rule**: A check that should be in the validation matrix + - **New pattern**: A codebase pattern worth capturing for next time + +4. **Apply updates**: Edit the relevant `docs/` files. Keep changes minimal and specific — add only what would have saved time this session. + +5. **Report**: Summarize what was added/changed and why. + +## Rules + +- Only add knowledge confirmed by this session — no speculative additions +- Keep docs concise — add a line or two, not a paragraph +- If a whole new doc is needed, create it in `docs/` and add a pointer in `CLAUDE.md` +- Don't update skills unless a coding pattern was genuinely wrong +- Don't add things Claude already knows — only Windmill-specific knowledge diff --git a/.agents/skills/rust-backend/SKILL.md b/.agents/skills/rust-backend/SKILL.md new file mode 100644 index 0000000000..f0c52002bc --- /dev/null +++ b/.agents/skills/rust-backend/SKILL.md @@ -0,0 +1,107 @@ +--- +name: rust-backend +description: Rust coding guidelines for the Windmill backend. MUST use when writing or modifying Rust code in the backend directory. +--- + +# Windmill Rust Patterns + +Apply these Windmill-specific patterns when writing Rust code in `backend/`. + +## Error Handling + +Use `Error` from `windmill_common::error`. Return `Result` or `JsonResult`: + +```rust +use windmill_common::error::{Error, Result}; + +pub async fn get_job(db: &DB, id: Uuid) -> Result { + sqlx::query_as!(Job, "SELECT id, workspace_id FROM v2_job WHERE id = $1", id) + .fetch_optional(db) + .await? + .ok_or_else(|| Error::NotFound("job not found".to_string()))?; +} +``` + +Never panic in library code. Reserve `.unwrap()` for compile-time guarantees. + +## SQLx Patterns + +**Never use `SELECT *`** — always list columns explicitly. Critical for backwards compatibility when workers lag behind API version: + +```rust +// Correct +sqlx::query_as!(Job, "SELECT id, workspace_id, path FROM v2_job WHERE id = $1", id) + +// Wrong — breaks when columns are added +sqlx::query_as!(Job, "SELECT * FROM v2_job WHERE id = $1", id) +``` + +Use batch operations to avoid N+1: + +```rust +// Preferred — single query with IN clause +sqlx::query!("SELECT ... WHERE id = ANY($1)", &ids[..]).fetch_all(db).await? +``` + +Use transactions for multi-step operations. Parameterize all queries. + +## JSON Handling + +Prefer `Box` over `serde_json::Value` when storing/passing JSON without inspection: + +```rust +pub struct Job { + pub args: Option>, +} +``` + +Only use `serde_json::Value` when you need to inspect or modify the JSON. + +## Serde Optimizations + +```rust +#[derive(Serialize, Deserialize)] +pub struct Job { + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_job: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + #[serde(default)] + pub priority: i32, +} +``` + +## Async & Concurrency + +Never block the async runtime. Use `spawn_blocking` for CPU-intensive work: + +```rust +let result = tokio::task::spawn_blocking(move || expensive_computation(&data)).await?; +``` + +**Mutex selection**: Prefer `std::sync::Mutex` (or `parking_lot::Mutex`) for data protection. Only use `tokio::sync::Mutex` when holding locks across `.await` points. + +Use `tokio::sync::mpsc` (bounded) for channels. Avoid `std::thread::sleep` in async contexts. + +## Module Structure & Visibility + +- Use `pub(crate)` instead of `pub` when possible +- Place new code in the appropriate crate based on functionality +- API endpoints go in `windmill-api/src/` organized by domain +- Shared functionality goes in `windmill-common/src/` + +## Code Navigation + +Always use rust-analyzer LSP for go-to-definition, find-references, and type info. Do not guess at module paths. + +## Axum Handlers + +Destructure extractors directly in function signatures: + +```rust +async fn process_job( + Extension(db): Extension, + Path((workspace, job_id)): Path<(String, Uuid)>, + Query(pagination): Query, +) -> Result> { ... } +``` diff --git a/.agents/skills/svelte-frontend/SKILL.md b/.agents/skills/svelte-frontend/SKILL.md new file mode 100644 index 0000000000..57cac70302 --- /dev/null +++ b/.agents/skills/svelte-frontend/SKILL.md @@ -0,0 +1,80 @@ +--- +name: svelte-frontend +description: Svelte coding guidelines for the Windmill frontend. MUST use when writing or modifying code in the frontend directory. +--- + +# Windmill Svelte Patterns + +Apply these Windmill-specific patterns when writing Svelte code in `frontend/`. For general Svelte 5 syntax (runes, snippets, event handling), use the Svelte MCP server. + +## Windmill UI Components (MUST use) + +Always use Windmill's design-system components. Never use raw HTML elements. + +### Buttons — ` + - {#if hubRtSync.status === 'error'} - - Error syncing resource types : {JSON.stringify(hubRtSync.error)} - - {/if} - {/if} +
+ { + connectsManual = undefined + await loadResourceTypes() + connects = undefined + await loadConnects() + }} + /> +
{:else if step == 2 && manual}
+ {#if resourceTypeNotFound} +
+

+ Resource type '{resourceType}' not found in your workspace +

+ +
+ {/if} {#key resourceTypeInfo} {/key}
diff --git a/frontend/src/lib/components/DBManager.svelte b/frontend/src/lib/components/DBManager.svelte index 3000eb2d5f..2b7e643451 100644 --- a/frontend/src/lib/components/DBManager.svelte +++ b/frontend/src/lib/components/DBManager.svelte @@ -572,36 +572,30 @@ dbTableEditorState = { open: false } }} {dbType} - computePreview={({ values }) => { + computePreview={async ({ values }) => { if (dbTableEditorState.alterTableKey && dbTableEditorAlterTableData.current) { let diff = diffTableEditorValues(dbTableEditorAlterTableData.current, values) - let queries = dbSchemaOps.previewAlterSql({ - values: diff, - schema: selected.schemaKey - }) + let sql = await dbSchemaOps.previewAlterSql({ values: diff, schema: selected.schemaKey }) let alert = !dbSupportsTransactionalDdl(dbType) ? { title: capitalize(dbType) + ' does not support transactional DDL', body: 'Any of these statements failing may leave your database in an intermediate state.' } : undefined - return { sql: queries.join('\n'), ...(alert ? { alert } : {}) } + return { sql, ...(alert ? { alert } : {}) } } else { - return { sql: dbSchemaOps.previewCreateSql({ values, schema: selected.schemaKey }) } + let sql = await dbSchemaOps.previewCreateSql({ values, schema: selected.schemaKey }) + return { sql } } }} computeBtnProps={({ values }) => { if (dbTableEditorState.alterTableKey && dbTableEditorAlterTableData.current) { let diff = diffTableEditorValues(dbTableEditorAlterTableData.current, values) - let queries = dbSchemaOps.previewAlterSql({ - values: diff, - schema: selected.schemaKey - }) - if (!queries.length) { + if (!diff.operations.length) { return { text: 'No changes detected', disabled: true } } return { - text: `Alter table (${pluralize(queries.length, 'change')} detected)` + text: `Alter table (${pluralize(diff.operations.length, 'change')} detected)` } } else { return { text: 'Create table' } diff --git a/frontend/src/lib/components/DBTable.svelte b/frontend/src/lib/components/DBTable.svelte index 72bd3aa370..6566542b77 100644 --- a/frontend/src/lib/components/DBTable.svelte +++ b/frontend/src/lib/components/DBTable.svelte @@ -115,8 +115,8 @@ refresh?.() sendUserToast('Row deleted') }) - .catch(() => { - sendUserToast('Error deleting row', true) + .catch((e) => { + sendUserToast(`Error deleting row: ${e?.message ?? e}`, true) }) } }) diff --git a/frontend/src/lib/components/DBTableEditor.svelte b/frontend/src/lib/components/DBTableEditor.svelte index d9e52dacc3..2146e8909a 100644 --- a/frontend/src/lib/components/DBTableEditor.svelte +++ b/frontend/src/lib/components/DBTableEditor.svelte @@ -42,6 +42,7 @@ +{#snippet depBadge(dep: string)} + {#if existingDeps.has(dep)} + + {dep} + + + {:else} + + Workspace dependency '{dep}' not found. Create it in workspace settings to enable shared + runners. + + + + {dep} + + {/if} +{/snippet} + +{#snippet tagRow(tag: string, info: SelectedTagInfo | undefined)} +
+
+ {#if info?.type === 'flow' && info.runners && info.runners.length > 0} + + {:else} +
+ {/if} +
+ {#if info} + {#if info.type === 'flow'} + + {:else} + + {/if} + {info.path} + ({info.workspace}) + + {#if !tagRunnerGroup.has(tag)} + {#if info.workspaceDeps} + {#each info.workspaceDeps as dep} + {@render depBadge(dep)} + {/each} + {/if} + {#if info.type === 'flow' && info.runners} + + {info.runners.length} runner{info.runners.length !== 1 ? 's' : ''} + + {:else if info.language} + {info.language} + {/if} + {/if} + {:else} + {tag} + {/if} +
+ {#if !disabled} + + {/if} +
+ + {#if info?.type === 'flow' && info.expanded && info.runners} +
+ {#each info.runners as runner (runner.stepId)} +
+ {runner.stepId} + {#if runner.stepSummary} + {runner.stepSummary} + {/if} + + {runner.isInline ? runner.language : runner.scriptPath} + +
+ {/each} +
+ {/if} +
+{/snippet} +
- {#if selectedTags.length > 0}
-
- {#each selectedTags as tag (tag)} - {@const info = selectedTagsInfo.get(tag)} +
+ + {#each runnerGroups as group (`${group.depName}:${group.language}`)}
-
- {#if info?.type === 'flow' && info.runners && info.runners.length > 0} - - {:else} -
- {/if} -
-
- {#if info} - {#if info.type === 'flow'} - - {:else} - - {/if} - {info.path} - ({info.workspace}) - {#if info.type === 'flow' && info.runners} - - {info.runners.length} runner{info.runners.length !== 1 ? 's' : ''} - - {:else if info.type === 'script'} - 1 runner - {/if} - {:else} - {tag} - {/if} -
-
- {#if !disabled} - - {/if} +
+ + Shared runner + + {@render depBadge(group.depName)} + {group.language}
+
+ {#each group.tags as tag (tag)} + {@const info = selectedTagsInfo.get(tag)} + {@render tagRow(tag, info)} + {/each} +
+
+ {/each} - {#if info?.type === 'flow' && info.expanded && info.runners} -
- {#each info.runners as runner (runner.stepId)} -
- {runner.stepId} - {#if runner.stepSummary} - {runner.stepSummary} - {/if} - - {runner.isInline ? runner.language : runner.scriptPath} - -
- {/each} -
+ + {#each standaloneTags as tag (tag)} + {@const info = selectedTagsInfo.get(tag)} +
+ {#if info?.type === 'flow'} + + {:else} + + {/if} + {info?.path ?? tag} + ({info?.workspace ?? ''}) + + {#if info?.workspaceDeps} + {#each info.workspaceDeps as dep} + {@render depBadge(dep)} + {/each} + {/if} + {#if info?.type === 'flow' && info.runners} + + {info.runners.length} runner{info.runners.length !== 1 ? 's' : ''} + + {:else if info?.language} + {info.language} + {/if} + {#if !disabled} + {/if}
{/each} @@ -585,15 +824,18 @@ {/if}
- {runnable.displayName} + {runnable.displayName} {#if runnable.type === 'flow' && runnable.runners} {runnable.runners.length} {/if} - + {#if runnable.workspaceDeps} + {#each runnable.workspaceDeps as dep} + {@render depBadge(dep)} + {/each} + {/if} + {runnable.type === 'flow' ? 'flow' : runnable.language} diff --git a/frontend/src/lib/components/Dev.svelte b/frontend/src/lib/components/Dev.svelte index 6ccf699579..c8ed95a3d9 100644 --- a/frontend/src/lib/components/Dev.svelte +++ b/frontend/src/lib/components/Dev.svelte @@ -34,6 +34,7 @@ import type { FlowEditorContext, FlowInput, FlowInputEditorState } from './flows/types' import { SelectionManager } from './graph/selectionUtils.svelte' import { NoteEditor, setNoteEditorContext } from './graph/noteEditor.svelte' + import { GroupEditor, setGroupEditorContext } from './graph/groupEditor.svelte' import { dfs } from './flows/dfs' import { loadSchemaFromModule } from './flows/flowInfers' import { CornerDownLeft, Play } from 'lucide-svelte' @@ -581,6 +582,11 @@ }) setNoteEditorContext(noteEditor) + // Set up GroupEditor context for group editing capabilities + const groupEditor = new GroupEditor(flowStore) + let canCreateGroup = $state({ val: false }) + setGroupEditorContext(groupEditor, canCreateGroup) + let lastSent: OpenFlow | undefined = undefined const isInIframe = window.parent !== window function updateFlow(flow: OpenFlow) { diff --git a/frontend/src/lib/components/FirstStepInputs.svelte b/frontend/src/lib/components/FirstStepInputs.svelte index 30399e20f7..cf936b64f0 100644 --- a/frontend/src/lib/components/FirstStepInputs.svelte +++ b/frontend/src/lib/components/FirstStepInputs.svelte @@ -85,7 +85,6 @@ > flowStore.val.value.modules }) diff --git a/frontend/src/lib/components/FlowGraphDiffViewer.svelte b/frontend/src/lib/components/FlowGraphDiffViewer.svelte index f66a582eb5..62e5b208c2 100644 --- a/frontend/src/lib/components/FlowGraphDiffViewer.svelte +++ b/frontend/src/lib/components/FlowGraphDiffViewer.svelte @@ -140,6 +140,7 @@ @@ -64,6 +63,7 @@ failureModule={flow?.value?.failure_module} preprocessorModule={flow?.value?.preprocessor_module} notes={flow?.value?.notes} + groups={flow?.value?.groups} onSelect={(nodeId) => { if (nodeId === 'Trigger') { dispatch('triggerDetail') diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index c75b4c7d3d..2c580bb02b 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -236,7 +236,9 @@ }) let jobResults: any[] = $state( - untrack(() => flowJobIds)?.flowJobs?.map((x, id) => `iter #${id + 1} not loaded by frontend yet`) ?? [] + untrack(() => flowJobIds)?.flowJobs?.map( + (x, id) => `iter #${id + 1} not loaded by frontend yet` + ) ?? [] ) function asWorkflowStatus(x: any): Record { @@ -255,7 +257,7 @@ let retry_selected = $state('') let timeout: number | undefined = undefined - let expandedSubflows: Record = $state({}) + let expandedSubflows: Record = $state({}) let selectionManager = new SelectionManager() @@ -684,10 +686,7 @@ } }) .catch((e) => { - console.error( - `Could not load inner module duration status for job ${mod.job}`, - e - ) + console.error(`Could not load inner module duration status for job ${mod.job}`, e) }) } } else { @@ -1154,7 +1153,7 @@ function allModulesForTimeline( modules: FlowModule[], - expandedSubflows: Record + expandedSubflows: Record ): FlowModuleForTimeline[] { const ids = dfs(modules, (x) => ({ id: x.id, type: x.value.type }) as FlowModuleForTimeline, { skipToolNodes: true @@ -1166,7 +1165,7 @@ ): FlowModuleForTimeline[] { return ids.concat( ids.flatMap(({ id }) => { - let fms = expandedSubflows[id] + let fms = expandedSubflows[id]?.modules let oid = id.split(':').pop() if (!oid) { return [] @@ -1902,6 +1901,7 @@ cache={job.raw_flow?.cache_ttl !== undefined} modules={job.raw_flow?.modules ?? []} notes={job.raw_flow?.notes ?? []} + groups={job.raw_flow?.groups} failureModule={job.raw_flow?.failure_module} preprocessorModule={job.raw_flow?.preprocessor_module} allowSimplifiedPoll={false} @@ -1994,7 +1994,9 @@ {#if job.args} {:else} @@ -2064,20 +2066,25 @@
Inputs
{/if} {#if node.workflow_as_code_status}
-
Workflow timeline
+
Workflow timeline
{/if} diff --git a/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte b/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte index 60f30b1adc..195a1d6fad 100644 --- a/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte +++ b/frontend/src/lib/components/FlowStatusWaitingForEvents.svelte @@ -18,11 +18,9 @@ light?: boolean } - let { isOwner, workspaceId, job, light = false }: Props = $props() + let { isOwner: _isOwner, workspaceId, job, light = false }: Props = $props() let default_payload: object = $state({}) - let resumeUrl: string | undefined = $state(undefined) - let cancelUrl: string | undefined = $state(undefined) let description: any = $state(undefined) let hide_cancel = $state(false) @@ -49,8 +47,6 @@ defaultValues = JSON.parse(JSON.stringify(args)) default_payload = args - resumeUrl = job_result?.['resume'] - cancelUrl = job_result?.['cancel'] hide_cancel = job?.raw_flow?.modules?.[approvalStep]?.suspend?.hide_cancel ?? false schema = mergeSchema( job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema ?? {}, @@ -61,61 +57,19 @@ let loading = $state(false) async function continu(approve: boolean) { loading = true - if ((resumeUrl && approve) || (cancelUrl && !approve)) { - let split = (approve ? resumeUrl : cancelUrl)!.split('/') - let signatureUrl = split.pop() ?? '' - const regex = /([^?]+)(?:\?[^=]+=(\w+))?/ - - const matches = signatureUrl.match(regex) - - const signature = matches?.[1] - if (!signature) { - sendUserToast(`Could not parse signature: ${signatureUrl}`, true) - return - } - const approver = matches?.[2] || undefined - - let resumeId = -1 - let parsedResumeId = split.pop() ?? '' - try { - resumeId = new Number(parsedResumeId).valueOf() - } catch (e) { - console.error(`Could not parse resume id: ${parsedResumeId}`) - } - let jobId = split.pop() ?? '' - if (approve) { - await JobService.resumeSuspendedJobPost({ - workspace: workspaceId ?? $workspaceStore ?? '', - id: jobId, - requestBody: default_payload as any, - resumeId, - signature, - approver - }) - } else { - await JobService.cancelSuspendedJobPost({ - workspace: workspaceId ?? $workspaceStore ?? '', - id: jobId, - resumeId, - signature, - approver, - requestBody: {} - }) - } - } else { - if (approve) { - await JobService.resumeSuspendedFlowAsOwner({ - workspace: workspaceId ?? $workspaceStore ?? '', - id: job?.id ?? '', - requestBody: default_payload as any - }) - } else { - await JobService.cancelQueuedJob({ - workspace: workspaceId ?? $workspaceStore ?? '', - id: job?.id ?? '', - requestBody: {} - }) - } + try { + await JobService.resumeSuspended({ + workspace: workspaceId ?? $workspaceStore ?? '', + jobId: job?.id ?? '', + requestBody: { + payload: approve ? (default_payload as any) : undefined, + approved: approve + } + }) + } catch (e: any) { + sendUserToast(e?.body ?? e?.message ?? 'Failed', true) + } finally { + loading = false } } let approvalStep = $derived((job?.flow_status?.step ?? 1) - 1) @@ -130,51 +84,41 @@
{/if}
- {#if isOwner || resumeUrl} -
- {#if !hide_cancel} -
-
- {/if} +
+ {#if !hide_cancel}
- +
- - {#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema} -
- -
- - The payload is optional, it is passed to the following step through the `resume` - variable - - {/if} + {/if} +
+
- {:else} - You cannot resume the flow yourself without receiving the resume secret since you are not an - owner of {job.script_path} and the approval step did not contain the resume url at key `resume` - {/if} + + {#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema} +
+ +
+ + The payload is optional, it is passed to the following step through the `resume` variable + + {/if} +
diff --git a/frontend/src/lib/components/Login.svelte b/frontend/src/lib/components/Login.svelte index b9c0de018d..c1a2a7ebdb 100644 --- a/frontend/src/lib/components/Login.svelte +++ b/frontend/src/lib/components/Login.svelte @@ -164,7 +164,8 @@ workspace: $workspaceStore! }) if (!emptyString(defaultApp.default_app_path)) { - goto(`/apps/get/${defaultApp.default_app_path}`) + const prefix = defaultApp.default_app_raw ? '/apps_raw/get' : '/apps/get' + goto(`${prefix}/${defaultApp.default_app_path}`) } else { goto(rd ?? '/') } @@ -341,6 +342,13 @@ btnClasses="mt-2 w-full" on:click={() => { if (saml) { + if (rd) { + try { + localStorage.setItem('rd', rd) + } catch (e) { + console.error('Could not persist redirection to local storage', e) + } + } window.location.href = saml } else { sendUserToast('No SAML login available', true) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 70aaf6a2ee..416068bf8b 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -25,6 +25,7 @@ import Button from './common/button/Button.svelte' import { clearJsonSchemaResourceCache } from './schema/jsonSchemaResource.svelte' import ResourceGen from './copilot/ResourceGen.svelte' + import SyncResourceTypes from './SyncResourceTypes.svelte' interface Props { canSave?: boolean @@ -33,6 +34,7 @@ hidePath?: boolean onChange?: (args: { path: string; args: Record; description: string }) => void defaultValues?: Record | undefined + workspace?: string | undefined } let { @@ -41,9 +43,12 @@ path = $bindable(''), hidePath = false, onChange, - defaultValues = undefined + defaultValues = undefined, + workspace = undefined }: Props = $props() + let effectiveWorkspace = $derived(workspace ?? $workspaceStore!) + let isValid = $state(true) let jsonError = $state('') let can_write = $state(true) @@ -68,13 +73,13 @@ let rawCode: string | undefined = $state(undefined) async function initEdit() { - resourceToEdit = await ResourceService.getResource({ workspace: $workspaceStore!, path }) + resourceToEdit = await ResourceService.getResource({ workspace: effectiveWorkspace, path }) description = resourceToEdit!.description ?? '' resource_type = resourceToEdit!.resource_type args = resourceToEdit?.value ?? ({} as any) loadResourceType() can_write = - resourceToEdit.workspace_id == $workspaceStore && + resourceToEdit.workspace_id == effectiveWorkspace && canWrite(path, resourceToEdit.extra_perms ?? {}, $userStore) linkedVars = Object.entries(args) .filter(([_, v]) => typeof v == 'string' && v == `$var:${initialPath}`) @@ -92,12 +97,12 @@ export async function editResource(): Promise { if (resourceToEdit) { await ResourceService.updateResource({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, path: resourceToEdit.path, requestBody: { path, value: args, description } }) if (resourceToEdit.resource_type === 'json_schema') { - clearJsonSchemaResourceCache(resourceToEdit.path, $workspaceStore!) + clearJsonSchemaResourceCache(resourceToEdit.path, effectiveWorkspace) } sendUserToast(`Updated resource at ${path}`) dispatch('refresh', path) @@ -108,7 +113,7 @@ export async function createResource(): Promise { await ResourceService.createResource({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, requestBody: { path, value: args, description, resource_type: resource_type! } }) sendUserToast(`Updated resource at ${path}`) @@ -119,7 +124,7 @@ if (resource_type) { try { const resourceType = await ResourceService.getResourceType({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, path: resource_type }) @@ -343,10 +348,13 @@ {:else} {#if !viewJsonSchema} -

- No corresponding resource type found in your workspace for {resource_type}. Define the - value in JSON directly -

+
+

+ Resource type '{resource_type}' not found in your workspace +

+ +

Define the value in JSON directly

+
{/if} {#if !emptyString(jsonError)} - + void excludedValues?: string[] datatableAsPgResource?: boolean + workspace?: string | undefined + disableChatOffset?: boolean } let { @@ -47,9 +49,13 @@ class: className = '', onClear = undefined, excludedValues = undefined, - datatableAsPgResource = false + datatableAsPgResource = false, + workspace = undefined, + disableChatOffset = false }: Props = $props() + let effectiveWorkspace = $derived(workspace ?? $workspaceStore!) + if (initialValue && value == undefined) { value = initialValue } @@ -104,7 +110,7 @@ const resources = await Promise.all( resourceTypesToQuery.map((rt) => ResourceService.listResource({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, resourceType: rt }) ) @@ -122,7 +128,7 @@ if (datatableAsPgResource && resourceType === 'postgresql') { try { const datatables = await WorkspaceService.listDataTables({ - workspace: $workspaceStore! + workspace: effectiveWorkspace }) for (const dt of datatables) { nc.push({ @@ -155,7 +161,7 @@ let previousResourceType = untrack(() => resourceType) $effect(() => { - $workspaceStore && resourceType + effectiveWorkspace && resourceType untrack(() => { if (previousResourceType != resourceType) { previousResourceType = resourceType @@ -167,7 +173,7 @@ $effect(() => { excludedValues - if ($workspaceStore && resourceType && !disabled) { + if (effectiveWorkspace && resourceType && !disabled) { untrack(() => loadResources(resourceType)) } }) @@ -186,9 +192,13 @@ }} bind:this={appConnect} {expressOAuthSetup} + {workspace} + {disableChatOffset} /> { await loadResources(resourceType) if (e.detail) { diff --git a/frontend/src/lib/components/SuperadminSettings.svelte b/frontend/src/lib/components/SuperadminSettings.svelte index b9dbb7c6d6..316a6237cd 100644 --- a/frontend/src/lib/components/SuperadminSettings.svelte +++ b/frontend/src/lib/components/SuperadminSettings.svelte @@ -146,6 +146,7 @@ bind:this={innerComponent} closeDrawer={handleClose} showHeaderInfo={false} + {disableChatOffset} bind:yamlMode bind:hasUnsavedChanges bind:hasAnyInvalid diff --git a/frontend/src/lib/components/SuperadminSettingsInner.svelte b/frontend/src/lib/components/SuperadminSettingsInner.svelte index 964414cafa..683db57724 100644 --- a/frontend/src/lib/components/SuperadminSettingsInner.svelte +++ b/frontend/src/lib/components/SuperadminSettingsInner.svelte @@ -17,7 +17,7 @@ import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import { userStore, workspaceStore } from '$lib/stores' - import { ExternalLink, Pencil, UserMinus, UserPlus } from 'lucide-svelte' + import { Ban, CheckCircle2, ExternalLink, Pencil, UserMinus, UserPlus } from 'lucide-svelte' import DropdownV2 from './DropdownV2.svelte' import Popover from './meltComponents/Popover.svelte' import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte' @@ -39,12 +39,14 @@ import TextInput from './text_input/TextInput.svelte' import SettingsPageHeader from './settings/SettingsPageHeader.svelte' import SettingsSearchInput from './instanceSettings/SettingsSearchInput.svelte' + import InstanceAISettings from './instanceSettings/InstanceAISettings.svelte' let filter = $state('') let { closeDrawer, showHeaderInfo = true, + disableChatOffset = false, yamlMode = $bindable(false), hasUnsavedChanges = $bindable(false), hasAnyInvalid = $bindable(false) @@ -65,6 +67,8 @@ let filteredUsers: GlobalUserInfo[] = $state([]) let deleteConfirmedCallback: (() => void) | undefined = $state(undefined) let deleteUserEmail: string = $state('') + let disableConfirmedCallback: (() => void) | undefined = $state(undefined) + let disableUserEmail: string = $state('') let editWrappers: Record = $state({}) let activeOnly = $state(false) @@ -234,7 +238,9 @@
- {#if tab === 'users' && !yamlMode} + {#if tab === 'ai' && !yamlMode} + + {:else if tab === 'users' && !yamlMode}
{#if !automateUsernameCreation && !isCloudHosted()}
@@ -289,9 +295,9 @@ /> @@ -343,13 +349,25 @@ {#if filteredUsers && users} - {#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, role_source }, i (email)} - - {email} + {#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, role_source, disabled }, i (email)} + + +
+ {email} + {#if disabled} + Disabled + {/if} +
+
{#if automateUsernameCreation} {#if username} @@ -510,6 +528,39 @@ if (btn instanceof HTMLElement) btn.click() } }, + { + displayName: disabled ? 'Enable' : 'Disable', + icon: disabled ? CheckCircle2 : Ban, + action: () => { + if (!disabled) { + disableUserEmail = email + disableConfirmedCallback = async () => { + try { + await UserService.globalUserUpdate({ + email, + requestBody: { disabled: true } + }) + sendUserToast('User disabled') + listUsers(activeOnly) + } catch (e) { + sendUserToast('Failed to disable user', true) + } + } + } else { + UserService.globalUserUpdate({ + email, + requestBody: { disabled: false } + }) + .then(() => { + sendUserToast('User enabled') + listUsers(activeOnly) + }) + .catch(() => { + sendUserToast('Failed to enable user', true) + }) + } + } + }, { displayName: 'Remove', icon: UserMinus, @@ -574,6 +625,33 @@ }} >
- Are you sure you want to remove {deleteUserEmail}? + Are you sure you want to remove {deleteUserEmail}? They will be removed from all + workspaces and instance groups, and all their sessions and tokens will be revoked. This action + is irreversible. Their workspace content (scripts, flows, apps) will not be deleted. +
+ + { + disableConfirmedCallback = undefined + listUsers(activeOnly) + }} + on:confirmed={() => { + if (disableConfirmedCallback) { + disableConfirmedCallback() + } + disableConfirmedCallback = undefined + }} +> +
+ Are you sure you want to disable {disableUserEmail}? All their active sessions and + tokens will be revoked immediately. They will be unable to log in until re-enabled. Their + workspace memberships and content will be preserved.
diff --git a/frontend/src/lib/components/SyncResourceTypes.svelte b/frontend/src/lib/components/SyncResourceTypes.svelte new file mode 100644 index 0000000000..dd343e43f6 --- /dev/null +++ b/frontend/src/lib/components/SyncResourceTypes.svelte @@ -0,0 +1,41 @@ + + +{#if $superadmin} + + {#if hubRtSync.status === 'error'} + + Error syncing resource types: {hubRtSync.error?.message ?? JSON.stringify(hubRtSync.error)} + + {/if} +{/if} diff --git a/frontend/src/lib/components/WorkflowTimeline.svelte b/frontend/src/lib/components/WorkflowTimeline.svelte index 3dea7279d6..2ea5ebba50 100644 --- a/frontend/src/lib/components/WorkflowTimeline.svelte +++ b/frontend/src/lib/components/WorkflowTimeline.svelte @@ -1,6 +1,6 @@ {#if flow_status} @@ -167,22 +216,74 @@ sleep ({(v as any).sleep_duration_s}s)
{:else if isApproval} -
-
- - - {v.name ?? stepKey(k)} - - {#if !isDone} - - - waiting + {@const selfApprovalDisabled = (v as any).self_approval_disabled === true} + {@const formSchema = (v as any).form?.schema ?? (v as any).form} + {@const hasForm = + formSchema && typeof formSchema === 'object' && Object.keys(formSchema).length > 0} + {@const canApprove = !isDone && jobId} +
+
+
+ + + {v.name ?? stepKey(k)} - {:else} - {msToSec(v.duration_ms ?? 0)}s + {#if !isDone} + + + waiting + + {#if canApprove} +
+ + +
+ {/if} + {:else} + {msToSec(v.duration_ms ?? 0)}s + {/if} +
+ {#if canApprove && selfApprovalDisabled && $userStore?.is_admin} +
+ Self-approval is disabled but allowed because you are an admin/owner +
+ {/if} + {#if canApprove && hasForm} +
+ {#if emptyString($enterpriseLicense)} + + {:else} + + {/if} +
{/if}
{:else} @@ -275,13 +376,13 @@ {@const result = stepResults[stepKey(k)]} {#if isDone && result !== undefined}
-
Result
+
Result
{:else} -
Step completed (no result)
+
Step completed (no result)
{/if} {:else if loadingJobs[k] && !childJobs[k]}
@@ -293,7 +394,7 @@ {#if job.logs || isRunning}
-
Logs
+
Logs
{#if isDone && job.result !== undefined}
-
Result
+
Result
diff --git a/frontend/src/lib/components/apps/components/display/dbtable/DeleteRow.svelte b/frontend/src/lib/components/apps/components/display/dbtable/DeleteRow.svelte index 5d8778b01e..0e30c6b421 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/DeleteRow.svelte +++ b/frontend/src/lib/components/apps/components/display/dbtable/DeleteRow.svelte @@ -62,8 +62,8 @@ onCancel: () => { sendUserToast('Error deleting row', true) }, - onError: () => { - sendUserToast('Error updating row', true) + onError: (e) => { + sendUserToast(`Error deleting row: ${e?.message ?? e}`, true) } } ) diff --git a/frontend/src/lib/components/apps/components/display/dbtable/InsertRowRunnable.svelte b/frontend/src/lib/components/apps/components/display/dbtable/InsertRowRunnable.svelte index 6b11362f3b..fea5232c6b 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/InsertRowRunnable.svelte +++ b/frontend/src/lib/components/apps/components/display/dbtable/InsertRowRunnable.svelte @@ -59,8 +59,8 @@ onCancel: () => { sendUserToast('Error inserting row', true) }, - onError: () => { - sendUserToast('Error inserting row', true) + onError: (e) => { + sendUserToast(`Error inserting row: ${e?.message ?? e}`, true) } }) } diff --git a/frontend/src/lib/components/apps/components/display/dbtable/UpdateCell.svelte b/frontend/src/lib/components/apps/components/display/dbtable/UpdateCell.svelte index e90ea9d96b..c324d7e408 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/UpdateCell.svelte +++ b/frontend/src/lib/components/apps/components/display/dbtable/UpdateCell.svelte @@ -64,8 +64,8 @@ onCancel: () => { sendUserToast('Error updating value', true) }, - onError: () => { - sendUserToast('Error updating value', true) + onError: (e) => { + sendUserToast(`Error updating value: ${e?.message ?? e}`, true) } } ) diff --git a/frontend/src/lib/components/apps/components/display/dbtable/metadata.ts b/frontend/src/lib/components/apps/components/display/dbtable/metadata.ts index 4d970b2303..2fdb61522d 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/metadata.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/metadata.ts @@ -1,4 +1,4 @@ -import { JobService, ResourceService, type ScriptLang } from '$lib/gen' +import { JobService, ResourceService } from '$lib/gen' import { runScriptAndPollResult } from '$lib/components/jobs/utils' import type { DbInput } from '$lib/components/dbTypes' @@ -15,9 +15,18 @@ import type { DBSchema, GraphqlSchema, SQLSchema } from '$lib/stores' import { stringifyGraphqlSchema, stringifySchema } from '$lib/components/copilot/lib' import type { DbType } from '$lib/components/dbTypes' -import { getDatabaseArg } from '$lib/components/dbOps' +import { getDatabaseArg, getDbType } from '$lib/components/dbOps' import { sendUserToast } from '$lib/toast' +function makeMetadataMarker( + op: string, + payload: Record, + ducklake: string | undefined +): string { + if (ducklake) payload.ducklake = ducklake + return `-- WM_INTERNAL_DB_${op} ${JSON.stringify(payload)}` +} + export async function loadTableMetaData( input: DbInput, workspace: string | undefined, @@ -25,11 +34,26 @@ export async function loadTableMetaData( ): Promise { if (!input || !table || !workspace) return undefined - let { language, query } = await makeLoadTableMetaDataQuery(input, workspace, table) + const dbType = getDbType(input) + const language = getLanguageByResourceType(dbType) + const ducklake = input.type === 'ducklake' ? input.ducklake : undefined + const dbArg = getDatabaseArg(input) + + // MySQL needs the database name for metadata queries + let databaseName: string | undefined + if (input.type === 'database' && input.resourceType === 'mysql') { + const resourceObj = (await ResourceService.getResourceValue({ + workspace, + path: input.resourcePath + })) as any + databaseName = resourceObj?.database + } + + const content = makeMetadataMarker('LOAD_TABLE_METADATA', { table, databaseName }, ducklake) const job = await JobService.runScriptPreview({ - workspace: workspace, - requestBody: { language, content: query, args: getDatabaseArg(input) } + workspace, + requestBody: { language, content, args: dbArg } }) const maxRetries = 8 @@ -39,7 +63,7 @@ export async function loadTableMetaData( await new Promise((resolve) => setTimeout(resolve, 1000 * (attempts || 0.6))) const testResult = (await JobService.getCompletedJob({ - workspace: workspace, + workspace, id: job })) as any @@ -78,10 +102,30 @@ export async function loadAllTablesMetaData( if (!input || !workspace) return undefined try { - let { language, query } = await makeLoadTableMetaDataQuery(input, workspace, undefined) + const dbType = getDbType(input) + const dbArg = getDatabaseArg(input) + const ducklake = input.type === 'ducklake' ? input.ducklake : undefined + + // MySQL needs the database name for metadata queries + let databaseName: string | undefined + if (input.type === 'database' && input.resourceType === 'mysql') { + const resourceObj = (await ResourceService.getResourceValue({ + workspace, + path: input.resourcePath + })) as any + databaseName = resourceObj?.database + } + + const language = getLanguageByResourceType(dbType) + const content = makeMetadataMarker( + 'LOAD_TABLE_METADATA', + { table: undefined, databaseName }, + ducklake + ) + let result = (await runScriptAndPollResult({ - workspace: workspace, - requestBody: { language, content: query, args: getDatabaseArg(input) } + workspace, + requestBody: { language, content, args: dbArg } })) as ({ table_name: string; schema_name?: string } & object)[] const map: Record = {} @@ -101,241 +145,6 @@ export async function loadAllTablesMetaData( } } -async function makeLoadTableMetaDataQuery( - input: DbInput, - workspace: string, - table: string | undefined -): Promise<{ query: string; language: ScriptLang }> { - if (input.type === 'ducklake') { - const query = `ATTACH 'ducklake://${input.ducklake}' AS __ducklake__; - SELECT - COLUMN_NAME as field, - DATA_TYPE as DataType, - COLUMN_DEFAULT as DefaultValue, - false as IsPrimaryKey, - false as IsIdentity, - IS_NULLABLE as IsNullable, - false as IsEnum, - TABLE_NAME as table_name - FROM information_schema.columns c - WHERE table_catalog = '__ducklake__' AND table_schema = current_schema()` - return { query, language: 'duckdb' } - } else if (input.resourceType === 'mysql') { - const resourceObj = (await ResourceService.getResourceValue({ - workspace, - path: input.resourcePath - })) as any - const query = ` - SELECT - COLUMN_NAME as field, - COLUMN_TYPE as DataType, - COLUMN_DEFAULT as DefaultValue, - CASE WHEN COLUMN_KEY = 'PRI' THEN 1 ELSE 0 END as IsPrimaryKey, - CASE WHEN EXTRA like '%auto_increment%' THEN 'YES' ELSE 'NO' END as IsIdentity, - CASE WHEN IS_NULLABLE = 'YES' THEN 'YES' ELSE 'NO' END as IsNullable, - CASE WHEN DATA_TYPE = 'enum' THEN true ELSE false END as IsEnum${ - table - ? '' - : `, - TABLE_NAME as table_name` - } - FROM - INFORMATION_SCHEMA.COLUMNS${ - table - ? ` - WHERE - TABLE_NAME = '${table.split('.').reverse()[0]}' AND TABLE_SCHEMA = '${ - table.split('.').reverse()[1] ?? resourceObj?.database ?? '' - }'` - : ` - WHERE - TABLE_SCHEMA NOT IN ('mysql', 'performance_schema', 'information_schema', 'sys')` - } - ORDER BY - TABLE_NAME, - ORDINAL_POSITION; - ` - return { query, language: 'mysql' } - } else if (input.resourceType === 'postgresql') { - const query = ` - SELECT - a.attname as field, - pg_catalog.format_type(a.atttypid, a.atttypmod) as DataType, - (SELECT substring(pg_catalog.pg_get_expr(d.adbin, d.adrelid, true) for 128) - FROM pg_catalog.pg_attrdef d - WHERE d.adrelid = a.attrelid AND d.adnum = a.attnum AND a.atthasdef) as DefaultValue, - (SELECT CASE WHEN i.indisprimary THEN true ELSE 'NO' END - FROM pg_catalog.pg_class tbl, pg_catalog.pg_class idx, pg_catalog.pg_index i, pg_catalog.pg_attribute att - WHERE tbl.oid = a.attrelid AND idx.oid = i.indexrelid AND att.attrelid = tbl.oid - AND i.indrelid = tbl.oid AND att.attnum = any(i.indkey) AND att.attname = a.attname LIMIT 1) as IsPrimaryKey, - CASE a.attidentity - WHEN 'd' THEN 'By Default' - WHEN 'a' THEN 'Always' - ELSE 'No' - END as IsIdentity, - CASE a.attnotnull - WHEN false THEN 'YES' - ELSE 'NO' - END as IsNullable, - (SELECT true - FROM pg_catalog.pg_enum e - WHERE e.enumtypid = a.atttypid FETCH FIRST ROW ONLY) as IsEnum${ - table - ? '' - : `, - ns.nspname AS schema_name, - c.relname AS table_name` - } - FROM pg_catalog.pg_attribute a${ - table - ? ` - WHERE a.attrelid = (SELECT c.oid FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid WHERE relname = '${ - table.split('.').reverse()[0] - }' AND ns.nspname = '${table.split('.').reverse()[1] ?? 'public'}') - AND a.attnum > 0 AND NOT a.attisdropped - ` - : ` - JOIN pg_catalog.pg_class c ON a.attrelid = c.oid - JOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid - WHERE c.relkind = 'r' AND a.attnum > 0 AND NOT a.attisdropped - AND ns.nspname != 'pg_catalog' AND ns.nspname != 'information_schema'` - } - ORDER BY ${table ? 'a.attnum' : 'ns.nspname, c.relname, a.attnum'}; - - ` - return { query, language: 'postgresql' } - } else if (input.resourceType === 'ms_sql_server') { - const query = ` - SELECT - c.COLUMN_NAME as field, - c.DATA_TYPE as DataType, - c.COLUMN_DEFAULT as DefaultValue, - CASE WHEN COLUMNPROPERTY(OBJECT_ID(c.TABLE_SCHEMA + '.' + c.TABLE_NAME), c.COLUMN_NAME, 'IsIdentity') = 1 THEN 'By Default' ELSE 'No' END as IsIdentity, - CASE WHEN pk.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END as IsPrimaryKey, - CASE WHEN c.IS_NULLABLE = 'YES' THEN 'YES' ELSE 'NO' END as IsNullable, - CASE WHEN c.DATA_TYPE = 'enum' THEN 1 ELSE 0 END as IsEnum, - dc.name as default_constraint_name${ - table - ? '' - : `, - c.TABLE_NAME as table_name` - } -FROM - INFORMATION_SCHEMA.COLUMNS c - LEFT JOIN ( - SELECT - ku.TABLE_SCHEMA, - ku.TABLE_NAME, - ku.COLUMN_NAME - FROM - INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc - INNER JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE ku - ON tc.CONSTRAINT_TYPE = 'PRIMARY KEY' - AND tc.CONSTRAINT_NAME = ku.CONSTRAINT_NAME - AND tc.TABLE_SCHEMA = ku.TABLE_SCHEMA - AND tc.TABLE_NAME = ku.TABLE_NAME - ) pk ON c.TABLE_SCHEMA = pk.TABLE_SCHEMA - AND c.TABLE_NAME = pk.TABLE_NAME - AND c.COLUMN_NAME = pk.COLUMN_NAME - LEFT JOIN sys.default_constraints dc - ON dc.parent_object_id = OBJECT_ID(c.TABLE_SCHEMA + '.' + c.TABLE_NAME) - AND dc.parent_column_id = COLUMNPROPERTY(OBJECT_ID(c.TABLE_SCHEMA + '.' + c.TABLE_NAME), c.COLUMN_NAME, 'ColumnId')${ - table - ? ` -WHERE - c.TABLE_NAME = '${table}'` - : '' - } -ORDER BY - c.ORDINAL_POSITION; - ` - return { query, language: 'mssql' } - } else if ( - input.resourceType === 'snowflake' || - (input.resourceType as any) === 'snowflake_oauth' - ) { - const query = ` - select COLUMN_NAME as field, - DATA_TYPE as DataType, - COLUMN_DEFAULT as DefaultValue, - CASE WHEN COLUMN_DEFAULT like 'AUTOINCREMENT%' THEN 'By Default' ELSE 'No' END as IsIdentity, - 0 as IsPrimaryKey, -- a one-query solution is not trivial, we will use SHOW PRIMARY KEYS separately - CASE WHEN IS_NULLABLE = 'YES' THEN 'YES' ELSE 'NO' END as IsNullable, - CASE WHEN DATA_TYPE = 'enum' THEN 1 ELSE 0 END as IsEnum${ - table - ? '' - : `, - table_name as table_name, - table_schema as schema_name` - } - from information_schema.columns${ - table - ? ` - where table_name = '${table.split('.').reverse()[0]}' and table_schema = '${ - table.split('.').reverse()[1] ?? 'PUBLIC' - }'` - : "\nwhere table_schema <> 'INFORMATION_SCHEMA'\n" - } - order by ORDINAL_POSITION; - ` - return { query, language: 'snowflake' } - } else if (input.resourceType === 'bigquery') { - if (table) { - const query = `SELECT - c.COLUMN_NAME as field, - DATA_TYPE as DataType, - CASE WHEN COLUMN_DEFAULT = 'NULL' THEN '' ELSE COLUMN_DEFAULT END as DefaultValue, - CASE WHEN constraint_name is not null THEN true ELSE false END as IsPrimaryKey, - 'No' as IsIdentity, - IS_NULLABLE as IsNullable, - false as IsEnum -FROM - ${table.split('.')[0]}.INFORMATION_SCHEMA.COLUMNS c - LEFT JOIN - ${table.split('.')[0]}.INFORMATION_SCHEMA.KEY_COLUMN_USAGE p - on c.table_name = p.table_name AND c.column_name = p.COLUMN_NAME -WHERE - c.TABLE_NAME = '${table.split('.')[1]}' -order by c.ORDINAL_POSITION;` - return { query, language: 'bigquery' } - } else { - const query = `import { BigQuery } from '@google-cloud/bigquery@7.5.0'; -export async function main(database: bigquery) { -const bq = new BigQuery({ - credentials: database -}) -const [datasets] = await bq.getDatasets(); -if (!datasets) return {} -const schema = {} as any -let queries = datasets.map(dataset => \` - (SELECT - c.COLUMN_NAME as field, - '\${dataset.id}' as schema_name, - c.TABLE_NAME as table_name, - DATA_TYPE as DataType, - CASE WHEN COLUMN_DEFAULT = 'NULL' THEN '' ELSE COLUMN_DEFAULT END as DefaultValue, - CASE WHEN constraint_name is not null THEN true ELSE false END as IsPrimaryKey, - 'No' as IsIdentity, - IS_NULLABLE as IsNullable, - false as IsEnum -FROM - \\\`\${dataset.id}\\\`.INFORMATION_SCHEMA.COLUMNS c - LEFT JOIN - \\\`\${dataset.id}\\\`.INFORMATION_SCHEMA.KEY_COLUMN_USAGE p - on c.table_name = p.table_name AND c.column_name = p.COLUMN_NAME -ORDER BY c.ORDINAL_POSITION)\` -) -let query = queries.join('\\nUNION ALL \\n') -const [rows] = await bq.query(query) -return rows -}` - return { query, language: 'bun' } - } - } else { - throw new Error('Unsupported database type:' + input.resourceType) - } -} - type SnowflakeShowPrimaryKeysResult = { column_name: string database_name: string @@ -379,12 +188,15 @@ async function fetchSnowflakePrimaryKeys( dbArg: any, tableKey?: string ): Promise { + const payload: Record = {} + if (tableKey) payload.table = tableKey + const content = makeMetadataMarker('SNOWFLAKE_PRIMARY_KEYS', payload, undefined) return (await JobService.runScriptPreviewAndWaitResult({ workspace, requestBody: { language: 'snowflake', args: dbArg, - content: tableKey ? `SHOW PRIMARY KEYS IN TABLE ${tableKey}` : 'SHOW PRIMARY KEYS IN ACCOUNT' + content } })) as SnowflakeShowPrimaryKeysResult[] } diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/count.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/count.ts index 3a7bf66a62..dc422f48e6 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/queries/count.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/count.ts @@ -1,3 +1,12 @@ +/** + * LEGACY: These query builders generate full SQL on the frontend. + * They exist only for backwards compatibility with Database Studio (dbexplorercomponent) apps + * whose policies were generated with expanded SQL digests. + * + * New code (Database Manager) should use WM_INTERNAL_DB markers instead, + * which are expanded server-side by the Rust query_builders module. + * See: dbOps.ts → dbTableOpsWithPreviewScripts() + */ import type { AppInput, RunnableByName } from '$lib/components/apps/inputType' import { wrapDucklakeQuery } from '../../../../../ducklake' import type { DbType, DbInput } from '$lib/components/dbTypes' diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/delete.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/delete.ts index a6ded0f727..3c6419634b 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/queries/delete.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/delete.ts @@ -1,3 +1,12 @@ +/** + * LEGACY: These query builders generate full SQL on the frontend. + * They exist only for backwards compatibility with Database Studio (dbexplorercomponent) apps + * whose policies were generated with expanded SQL digests. + * + * New code (Database Manager) should use WM_INTERNAL_DB markers instead, + * which are expanded server-side by the Rust query_builders module. + * See: dbOps.ts → dbTableOpsWithPreviewScripts() + */ import type { AppInput, RunnableByName } from '$lib/components/apps/inputType' import type { DbType, DbInput } from '$lib/components/dbTypes' import { wrapDucklakeQuery } from '../../../../../ducklake' diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/insert.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/insert.ts index 9983b7c8e8..7a79a3cbad 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/queries/insert.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/insert.ts @@ -1,3 +1,12 @@ +/** + * LEGACY: These query builders generate full SQL on the frontend. + * They exist only for backwards compatibility with Database Studio (dbexplorercomponent) apps + * whose policies were generated with expanded SQL digests. + * + * New code (Database Manager) should use WM_INTERNAL_DB markers instead, + * which are expanded server-side by the Rust query_builders module. + * See: dbOps.ts → dbTableOpsWithPreviewScripts() + */ import type { AppInput } from '$lib/components/apps/inputType' import { wrapDucklakeQuery } from '../../../../../ducklake' import type { DbType, DbInput } from '$lib/components/dbTypes' diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/relationalKeys.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/relationalKeys.ts index 1bda320bd6..ad42728d44 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/queries/relationalKeys.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/relationalKeys.ts @@ -275,7 +275,7 @@ function makeSnowflakeForeignKeysQuery(tableName: string, schemaName: string): s * pk_database_name, pk_schema_name, pk_table_name, pk_column_name, key_sequence, * update_rule, delete_rule, fk_name, pk_name, deferrability */ -function transformSnowflakeForeignKeys(snowflakeResults: any[]): RawForeignKey[] { +export function transformSnowflakeForeignKeys(snowflakeResults: any[]): RawForeignKey[] { if (!snowflakeResults || !Array.isArray(snowflakeResults)) { return [] } diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/select.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/select.ts index 47b548c9d6..737dfdc329 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/queries/select.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/select.ts @@ -1,3 +1,12 @@ +/** + * LEGACY: These query builders generate full SQL on the frontend. + * They exist only for backwards compatibility with Database Studio (dbexplorercomponent) apps + * whose policies were generated with expanded SQL digests. + * + * New code (Database Manager) should use WM_INTERNAL_DB markers instead, + * which are expanded server-side by the Rust query_builders module. + * See: dbOps.ts → dbTableOpsWithPreviewScripts() + */ import type { AppInput, RunnableByName } from '$lib/components/apps/inputType' import { wrapDucklakeQuery } from '../../../../../ducklake' import type { DbType, DbInput } from '$lib/components/dbTypes' diff --git a/frontend/src/lib/components/apps/components/display/dbtable/queries/update.ts b/frontend/src/lib/components/apps/components/display/dbtable/queries/update.ts index 53aad833d4..b57fa4989b 100644 --- a/frontend/src/lib/components/apps/components/display/dbtable/queries/update.ts +++ b/frontend/src/lib/components/apps/components/display/dbtable/queries/update.ts @@ -1,3 +1,12 @@ +/** + * LEGACY: These query builders generate full SQL on the frontend. + * They exist only for backwards compatibility with Database Studio (dbexplorercomponent) apps + * whose policies were generated with expanded SQL digests. + * + * New code (Database Manager) should use WM_INTERNAL_DB markers instead, + * which are expanded server-side by the Rust query_builders module. + * See: dbOps.ts → dbTableOpsWithPreviewScripts() + */ import type { AppInput, RunnableByName } from '$lib/components/apps/inputType' import { wrapDucklakeQuery } from '../../../../../ducklake' import type { DbInput, DbType } from '$lib/components/dbTypes' diff --git a/frontend/src/lib/components/copilot/TestAIKey.svelte b/frontend/src/lib/components/copilot/TestAIKey.svelte index 575159aef8..1f0f97c4ef 100644 --- a/frontend/src/lib/components/copilot/TestAIKey.svelte +++ b/frontend/src/lib/components/copilot/TestAIKey.svelte @@ -7,6 +7,7 @@ interface Props { disabled?: boolean apiKey?: string | undefined + workspace?: string | undefined resourcePath?: string | undefined aiProvider: AIProvider model: string @@ -15,6 +16,7 @@ let { disabled = false, apiKey = undefined, + workspace = undefined, resourcePath = undefined, aiProvider, model @@ -38,6 +40,7 @@ await testKey({ apiKey, + workspace, resourcePath, messages: [ { diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 00a1a11fdf..140bbf4f14 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -23,8 +23,7 @@ import { } from './shared' import type { ChatCompletionMessageParam, - ChatCompletionSystemMessageParam, - ChatCompletionUserMessageParam + ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs' import { prepareInlineChatSystemPrompt, @@ -37,7 +36,7 @@ import { loadApiTools } from './api/apiTools' import { prepareScriptUserMessage } from './script/core' import { prepareNavigatorUserMessage } from './navigator/core' import { sendUserToast } from '$lib/toast' -import { getCompletion, getModelContextWindow, parseOpenAICompletion } from '../lib' +import { getModelContextWindow, workspaceAIClients } from '../lib' import { dfs } from '$lib/components/flows/previousResults' import { getStringError } from './utils' import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState' @@ -56,8 +55,7 @@ import type { import type { Selection } from 'monaco-editor' import type AIChatInput from './AIChatInput.svelte' import { prepareApiSystemMessage, prepareApiUserMessage } from './api/core' -import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic' -import { getOpenAIResponsesCompletion, parseOpenAIResponsesCompletion } from './openai-responses' +import { runChatLoop } from './chatLoop' import type { ReviewChangesOpts } from './monaco-adapter' import { getCurrentModel, tryGetCurrentModel, getCombinedCustomPrompt } from '$lib/aiStore' @@ -413,130 +411,63 @@ class AIChatManager { systemMessage?: ChatCompletionSystemMessageParam }) => { try { - let addedMessages: ChatCompletionMessageParam[] = [] - while (true) { - const systemMessage = systemMessageOverride ?? this.systemMessage - const helpers = this.helpers - const tools = this.tools - for (const tool of tools) { - if (tool.setSchema) { - await tool.setSchema(helpers) - } - } - - let pendingPrompt = this.pendingPrompt - let pendingUserMessage: ChatCompletionUserMessageParam | undefined = undefined - if (pendingPrompt) { + // Use JS getters so runChatLoop re-reads tools/helpers/systemMessage/modelProvider + // on each iteration. This is critical for changeModeTool (Navigator → Script/Flow) + // which reassigns this.tools, this.helpers, this.systemMessage mid-loop. + const self = this + const result = await runChatLoop({ + messages, + get systemMessage() { + return systemMessageOverride ?? self.systemMessage + }, + get tools() { + return self.tools + }, + get helpers() { + return self.helpers + }, + abortController, + callbacks, + get modelProvider() { + return getCurrentModel() + }, + clients: { + openai: workspaceAIClients.getOpenaiClient(), + anthropic: workspaceAIClients.getAnthropicClient() + }, + workspace: get(workspaceStore) ?? '', + skipResponsesApi: this.skipResponsesApi, + onSkipResponsesApi: () => { + this.skipResponsesApi = true + }, + getPendingUserMessage: () => { + const pendingPrompt = this.pendingPrompt + if (!pendingPrompt) return undefined + this.pendingPrompt = '' if (this.mode === AIMode.SCRIPT) { - pendingUserMessage = prepareScriptUserMessage( + return prepareScriptUserMessage( pendingPrompt, this.contextManager.getSelectedContext() ) } else if (this.mode === AIMode.FLOW) { - pendingUserMessage = prepareFlowUserMessage( + return prepareFlowUserMessage( pendingPrompt, this.flowAiChatHelpers!.getFlowAndSelectedId() ) } else if (this.mode === AIMode.NAVIGATOR) { - pendingUserMessage = prepareNavigatorUserMessage(pendingPrompt) + return prepareNavigatorUserMessage(pendingPrompt) } - this.pendingPrompt = '' - } - - const model = getCurrentModel() - const isOpenAI = model.provider === 'openai' || model.provider === 'azure_openai' - const isAnthropic = model.provider === 'anthropic' - - const messageParams = [ - systemMessage, - ...messages, - ...(pendingUserMessage ? [pendingUserMessage] : []) - ] - const toolDefs = tools.map((t) => t.def) - - // For OpenAI/Azure, try Responses API first, fallback to Completions API - if (isOpenAI) { - let useCompletionsApi = this.skipResponsesApi - if (!this.skipResponsesApi) { - try { - const completion = await getOpenAIResponsesCompletion( - messageParams, - abortController, - toolDefs - ) - const continueCompletion = await parseOpenAIResponsesCompletion( - completion, - callbacks, - messages, - addedMessages, - tools, - helpers - ) - if (!continueCompletion) { - break - } - } catch (err) { - console.warn('OpenAI Responses API failed, falling back to Completions API:', err) - // If the error indicates Responses API is not available in this region, skip it for future requests - const errorMessage = err instanceof Error ? err.message : String(err) - if (errorMessage.includes('Responses API is not enabled')) { - this.skipResponsesApi = true - } - useCompletionsApi = true - } - } - - // Use Completions API if Responses API is not available or failed - if (useCompletionsApi) { - const completion = await getCompletion(messageParams, abortController, toolDefs, { - forceCompletions: true - }) - const continueCompletion = await parseOpenAICompletion( - completion, - callbacks, - messages, - addedMessages, - tools, - helpers - ) - if (!continueCompletion) { - break - } - } - } else if (isAnthropic) { - const completion = await getAnthropicCompletion(messageParams, abortController, toolDefs) - if (completion) { - const continueCompletion = await parseAnthropicCompletion( - completion, - callbacks, - messages, - addedMessages, - tools, - helpers, - abortController - ) - if (!continueCompletion) { - break - } - } - } else { - const completion = await getCompletion(messageParams, abortController, toolDefs) - if (completion) { - const continueCompletion = await parseOpenAICompletion( - completion, - callbacks, - messages, - addedMessages, - tools, - helpers - ) - if (!continueCompletion) { - break + return undefined + }, + onBeforeIteration: async (tools) => { + for (const tool of tools) { + if (tool.setSchema) { + await tool.setSchema(this.helpers) } } } - } - return addedMessages + }) + return result.addedMessages } catch (err) { console.log('chatRequest error', err) console.error('chatRequest error', err) diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/appChat.eval.test.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/appChat.eval.test.ts index 5183377caf..a42ee1f099 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/app/appChat.eval.test.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/appChat.eval.test.ts @@ -6,44 +6,77 @@ import { loadAppFixtureForEval } from './appFixtureLoader' import { dirname, join } from 'path' // @ts-ignore - Node.js url import { fileURLToPath } from 'url' +import type { AIProvider } from '$lib/gen/types.gen' -// Get API key from environment - tests will be skipped if not set +// Get API keys from environment - tests will be skipped if none are set // @ts-ignore -const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY +const OPENAI_API_KEY = process.env.OPENAI_API_KEY +// @ts-ignore +const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY -// Skip all tests if no API key is provided -const describeWithApiKey = OPENROUTER_API_KEY ? describe : describe.skip +const hasAnyKey = OPENAI_API_KEY || ANTHROPIC_API_KEY +const describeWithApiKey = hasAnyKey ? describe : describe.skip // Get __dirname equivalent for ES modules const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) -const MODELS = ['google/gemini-2.5-flash', 'anthropic/claude-haiku-4.5', 'openai/gpt-4o'] +// Build model variants based on available keys +interface ModelVariant { + model: string + provider: AIProvider + apiKey: string +} + +const MODEL_VARIANTS: ModelVariant[] = [ + ...(OPENAI_API_KEY + ? [{ model: 'gpt-4o', provider: 'openai' as AIProvider, apiKey: OPENAI_API_KEY }] + : []), + ...(ANTHROPIC_API_KEY + ? [ + { + model: 'claude-haiku-4-5-20241022', + provider: 'anthropic' as AIProvider, + apiKey: ANTHROPIC_API_KEY + } + ] + : []) +] + const VARIANTS = [ - ...MODELS.map((model) => ({ + ...MODEL_VARIANTS.map((mv) => ({ ...BASELINE_VARIANT, - model, - name: `baseline-${model.replace('/', '-')}` + model: mv.model, + name: `baseline-${mv.provider}-${mv.model}`, + _provider: mv.provider, + _apiKey: mv.apiKey })), - ...MODELS.map((model) => ({ + ...MODEL_VARIANTS.map((mv) => ({ ...STREAMLINED_VARIANT, - model, - name: `streamlined-${model.replace('/', '-')}` + model: mv.model, + name: `streamlined-${mv.provider}-${mv.model}`, + _provider: mv.provider, + _apiKey: mv.apiKey })) ] describeWithApiKey('App Chat LLM Evaluation', () => { const TEST_TIMEOUT = 120_000 - if (!OPENROUTER_API_KEY) { - console.warn('OPENROUTER_API_KEY is not set, skipping tests') + if (!hasAnyKey) { + console.warn('No API keys set (OPENAI_API_KEY or ANTHROPIC_API_KEY), skipping tests') } it( 'test1: creates a simple counter app', async () => { const USER_PROMPT = `Create a counter app with increment/decrement buttons` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!) - // Write results to files + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + undefined, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) console.log(`App files: ${appPaths.join(', ')}`) @@ -56,17 +89,21 @@ describeWithApiKey('App Chat LLM Evaluation', () => { it( 'test2: modifies existing counter app to add reset button', async () => { - // Load initial app from fixture folder const { initialFrontend, initialBackend } = await loadAppFixtureForEval( join(__dirname, 'initial', 'test1_counter_app') ) const USER_PROMPT = `Add a reset button that sets the counter back to 0` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) - // Write results to files + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) console.log(`App files: ${appPaths.join(', ')}`) @@ -86,10 +123,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => { ) const USER_PROMPT = `Add a quantity selector (+ and - buttons) to each cart item so users can adjust quantities without removing and re-adding items` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -108,10 +151,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => { ) const USER_PROMPT = `Add a discount code input field in the cart. When the code "SAVE10" is entered, apply a 10% discount to the total` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -132,10 +181,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => { ) const USER_PROMPT = `Add a search bar in the toolbar that filters files and folders by name as the user types` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -154,10 +209,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => { ) const USER_PROMPT = `Show file size (formatted as KB/MB) and modified date in the file list for each item` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -176,10 +237,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => { ) const USER_PROMPT = `Add a "Select All" checkbox in the file list header and individual checkboxes for each file. Add a "Delete Selected" button that appears when items are selected` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialFrontend, - initialBackend - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialFrontend, + initialBackend + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -196,7 +263,13 @@ describeWithApiKey('App Chat LLM Evaluation', () => { 'test8: create quiz app from scratch', async () => { const USER_PROMPT = `Create a multiple choice quiz app with 5 questions about general knowledge. Show one question at a time with 4 answer options. Track the score and show results at the end with percentage correct.` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + undefined, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -211,7 +284,13 @@ describeWithApiKey('App Chat LLM Evaluation', () => { 'test9: create recipe book from scratch', async () => { const USER_PROMPT = `Create a recipe book app where users can add recipes with a name, ingredients list, and instructions. Include a search bar to filter recipes by name and the ability to delete recipes.` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + undefined, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalComparison.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalComparison.ts index 456299c142..e6c795d445 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalComparison.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalComparison.ts @@ -1,4 +1,4 @@ -import OpenAI from 'openai' +import Anthropic from '@anthropic-ai/sdk' import type { AppFiles, BackendRunnable } from '../../app/core' import { BASE_EVALUATOR_RESPONSE_FORMAT } from '../shared' import type { EvaluationResult } from '../shared' @@ -71,12 +71,7 @@ ${BASE_EVALUATOR_RESPONSE_FORMAT}` /** * Evaluates how well a generated app fulfills the user's request, considering any initial app state. - * This evaluator does not require an expected reference app - it evaluates based on the request alone. - * - * @param userPrompt The original user request - * @param generatedApp The app generated by the AI - * @param initialApp Optional initial app state (what the app looked like before AI changes) - * @returns Evaluation result with score, statement, and missing requirements + * Uses Anthropic API directly. */ export async function evaluateAppGeneration( userPrompt: string, @@ -84,9 +79,17 @@ export async function evaluateAppGeneration( initialApp?: InitialApp ): Promise { // @ts-ignore - const apiKey = process.env.OPENROUTER_API_KEY + const apiKey = process.env.ANTHROPIC_API_KEY + if (!apiKey) { + return { + success: false, + resemblanceScore: 0, + statement: 'No API key available for evaluation', + error: 'ANTHROPIC_API_KEY not set' + } + } - const client = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey }) + const client = new Anthropic({ apiKey }) let userMessage = `## User's Original Request ${userPrompt} @@ -117,16 +120,18 @@ Please evaluate how well the generated app: 2. ${initialApp ? 'Makes appropriate modifications to the initial app state' : 'Implements a complete and correct new app'}` try { - const response = await client.chat.completions.create({ - model: 'anthropic/claude-sonnet-4.5', + const response = await client.messages.create({ + model: 'claude-sonnet-4-5-20250514', + max_tokens: 2048, + system: APP_GENERATION_EVALUATOR_SYSTEM_PROMPT, messages: [ - { role: 'system', content: APP_GENERATION_EVALUATOR_SYSTEM_PROMPT }, { role: 'user', content: userMessage } ], temperature: 0 }) - const content = response.choices[0]?.message?.content + const textBlock = response.content.find((block) => block.type === 'text') + const content = textBlock?.text if (!content) { return { success: false, diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts index 3f0da73c92..2e6a491bce 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts @@ -14,6 +14,7 @@ import { type VariantDefaults } from '../shared' import { writeAppComparisonResultsToFolders } from './appResultsWriter' +import type { AIProvider } from '$lib/gen/types.gen' // Re-export for convenience export type { InitialApp } from './appEvalComparison' @@ -38,6 +39,8 @@ export interface AppEvalOptions { variant?: VariantConfig /** Whether to evaluate the generated app with LLM. Default: true. Set to false to skip evaluation. */ evaluateWithLLM?: boolean + /** AI provider (inferred from model name if omitted) */ + provider?: AIProvider } /** @@ -49,12 +52,11 @@ const appDefaults: VariantDefaults = { } /** - * Runs an app chat evaluation with real OpenAI API calls. - * Executes tool calls using the actual app tools from core.ts or variant-configured tools. + * Runs an app chat evaluation using the shared chat loop (same code path as production). */ export async function runAppEval( userPrompt: string, - openaiApiKey: string, + apiKey: string, options?: AppEvalOptions ): Promise { const { helpers, getFiles } = createAppEvalHelpers( @@ -69,7 +71,7 @@ export async function runAppEval( appDefaults, options?.customSystemPrompt ) - const { toolDefs, tools } = resolveTools(options?.variant, appDefaults) + const { tools } = resolveTools(options?.variant, appDefaults) const model = resolveModel(options?.variant, options?.model) // Build user message @@ -80,15 +82,15 @@ export async function runAppEval( userPrompt, systemMessage, userMessage, - toolDefs, tools, helpers, - apiKey: openaiApiKey, + apiKey, getOutput: getFiles, options: { maxIterations: options?.maxIterations, model, - workspace: 'test-workspace' + workspace: 'test-workspace', + provider: options?.provider } }) @@ -114,21 +116,32 @@ export async function runAppEval( } } +/** + * Per-variant provider override. + */ +export interface VariantProviderOverride { + provider: AIProvider + apiKey: string +} + /** * Runs the same prompt against multiple variants sequentially for comparison. - * Returns results in the same order as the input variants. + * Accepts optional per-variant provider/apiKey overrides. */ export async function runVariantComparison( userPrompt: string, variants: VariantConfig[], - openaiApiKey: string, - baseOptions?: Omit + defaultApiKey: string, + baseOptions?: Omit, + providerOverrides?: VariantProviderOverride[] ): Promise { const results: AppEvalResult[] = await Promise.all( - variants.map(async (variant) => { - return await runAppEval(userPrompt, openaiApiKey, { + variants.map(async (variant, i) => { + const override = providerOverrides?.[i] + return await runAppEval(userPrompt, override?.apiKey ?? defaultApiKey, { ...baseOptions, - variant + variant, + provider: override?.provider ?? baseOptions?.provider }) }) ) diff --git a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowChat.eval.test.ts b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowChat.eval.test.ts index 8210ea50fb..de9b8e5f43 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowChat.eval.test.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowChat.eval.test.ts @@ -22,35 +22,60 @@ import initialTest6 from './initial/test6_initial.json' // @ts-ignore - JSON import import initialTest7 from './initial/test7_initial.json' import type { FlowModule } from '$lib/gen' +import type { AIProvider } from '$lib/gen/types.gen' -// Get API key from environment - tests will be skipped if not set +// Get API keys from environment - tests will be skipped if none are set // @ts-ignore -// const OPENAI_API_KEY = process.env.OPENAI_API_KEY -const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY +const OPENAI_API_KEY = process.env.OPENAI_API_KEY +// @ts-ignore +const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY -// Skip all tests if no API key is provided -// const describeWithApiKey = OPENAI_API_KEY ? describe : describe.skip -const describeWithApiKey = OPENROUTER_API_KEY ? describe : describe.skip +const hasAnyKey = OPENAI_API_KEY || ANTHROPIC_API_KEY +const describeWithApiKey = hasAnyKey ? describe : describe.skip -const MODELS = ['google/gemini-2.5-flash', 'anthropic/claude-haiku-4.5', 'openai/gpt-4o'] +// Build model variants based on available keys +interface ModelVariant { + model: string + provider: AIProvider + apiKey: string +} + +const MODEL_VARIANTS: ModelVariant[] = [ + ...(OPENAI_API_KEY + ? [{ model: 'gpt-4o', provider: 'openai' as AIProvider, apiKey: OPENAI_API_KEY }] + : []), + ...(ANTHROPIC_API_KEY + ? [ + { + model: 'claude-haiku-4-5-20241022', + provider: 'anthropic' as AIProvider, + apiKey: ANTHROPIC_API_KEY + } + ] + : []) +] const VARIANTS = [ - ...MODELS.map((model) => ({ + ...MODEL_VARIANTS.map((mv) => ({ ...BASELINE_VARIANT, - model, - name: `baseline-${model.replace('/', '-')}` + model: mv.model, + name: `baseline-${mv.provider}-${mv.model}`, + _provider: mv.provider, + _apiKey: mv.apiKey })), - ...MODELS.map((model) => ({ + ...MODEL_VARIANTS.map((mv) => ({ ...MINIMAL_SINGLE_TOOL_VARIANT, - model, - name: `minimal-single-tool-${model.replace('/', '-')}` + model: mv.model, + name: `minimal-single-tool-${mv.provider}-${mv.model}`, + _provider: mv.provider, + _apiKey: mv.apiKey })) ] describeWithApiKey('Flow Chat LLM Evaluation', () => { const TEST_TIMEOUT = 120_000 - if (!OPENROUTER_API_KEY) { - console.warn('OPENROUTER_API_KEY is not set, skipping tests') + if (!hasAnyKey) { + console.warn('No API keys set (OPENAI_API_KEY or ANTHROPIC_API_KEY), skipping tests') } it( @@ -65,9 +90,15 @@ STEP 3: Loop on all users STEP 4: Do branches based on user's role, do different action based on that. Roles are admin, user, moderator STEP 5: Return action taken for each user ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - expectedFlow: expectedTest1 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + expectedFlow: expectedTest1 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) // Write results to files const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) @@ -112,9 +143,15 @@ STEP 5: Branch based on inventory - if all items available, create shipment reco STEP 6: Send confirmation (mock email to customer_email) STEP 7: Return final order summary with status ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - expectedFlow: expectedTest2 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + expectedFlow: expectedTest2 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -161,9 +198,15 @@ STEP 5: Branch based on quality score: - If score < 70: Store in quarantine and send alert STEP 6: Return processing report with statistics (total records, quality score, destination) ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - expectedFlow: expectedTest3 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + expectedFlow: expectedTest3 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -210,9 +253,15 @@ STEP 3: Use an AI agent to handle the customer query. The agent should have acce STEP 4: Log the interaction to audit trail (customer_id, query, response summary) STEP 5: Return the agent's response and any actions taken ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - expectedFlow: expectedTest4 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + expectedFlow: expectedTest4 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -256,11 +305,17 @@ Modify this existing flow to add error handling: - If validation passes, return the data for the next step - Update save_results to handle the validation result appropriately ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialModules: initialTest5.value.modules as FlowModule[], - initialSchema: initialTest5.schema, - expectedFlow: expectedTest5 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialModules: initialTest5.value.modules as FlowModule[], + initialSchema: initialTest5.schema, + expectedFlow: expectedTest5 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -302,11 +357,17 @@ Modify the order processing loop to handle different order types: - Move the original process_order step to the default branch for unknown order types - Each branch step should return the orderId, shipping cost, and shipping type ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialModules: initialTest6.value.modules as FlowModule[], - initialSchema: initialTest6.schema, - expectedFlow: expectedTest6 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialModules: initialTest6.value.modules as FlowModule[], + initialSchema: initialTest6.schema, + expectedFlow: expectedTest6 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) @@ -348,11 +409,17 @@ Refactor this flow for better performance by parallelizing the enrichment steps: - The combine_data step should check if any enrichment used a fallback value and set a hasFallbacks flag - Keep get_item as the first step and return_result as the last step unchanged ` - const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { - initialModules: initialTest7.value.modules as FlowModule[], - initialSchema: initialTest7.schema, - expectedFlow: expectedTest7 as ExpectedFlow - }) + const results = await runVariantComparison( + USER_PROMPT, + VARIANTS, + VARIANTS[0]._apiKey, + { + initialModules: initialTest7.value.modules as FlowModule[], + initialSchema: initialTest7.schema, + expectedFlow: expectedTest7 as ExpectedFlow + }, + VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey })) + ) const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) diff --git a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalComparison.ts b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalComparison.ts index f55979bb40..4c2b41d577 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalComparison.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalComparison.ts @@ -59,14 +59,10 @@ export async function evaluateFlowComparison( expectedFlow: ExpectedFlow, userPrompt: string ): Promise { - // @ts-ignore - const apiKey = process.env.OPENROUTER_API_KEY - return evaluateWithLLM({ userPrompt, generatedOutput: generatedFlow, expectedOutput: expectedFlow, - evaluatorSystemPrompt: FLOW_EVALUATOR_SYSTEM_PROMPT, - apiKey + evaluatorSystemPrompt: FLOW_EVALUATOR_SYSTEM_PROMPT }) } diff --git a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalRunner.ts b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalRunner.ts index 3f27143c69..f3c976950d 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalRunner.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalRunner.ts @@ -1,4 +1,5 @@ import type { FlowModule } from '$lib/gen' +import type { AIProvider } from '$lib/gen/types.gen' import type { ExtendedOpenFlow } from '$lib/components/flows/types' import { flowTools, prepareFlowSystemMessage, prepareFlowUserMessage, type FlowAIChatHelpers } from '../../flow/core' import { createFlowEvalHelpers } from './flowEvalHelpers' @@ -38,6 +39,8 @@ export interface FlowEvalOptions { maxIterations?: number variant?: VariantConfig expectedFlow?: ExpectedFlow + /** AI provider (inferred from model name if omitted) */ + provider?: AIProvider } /** @@ -49,12 +52,11 @@ const flowDefaults: VariantDefaults = { } /** - * Runs a flow chat evaluation with real OpenAI API calls. - * Executes tool calls using the actual flowTools from core.ts or variant-configured tools. + * Runs a flow chat evaluation using the shared chat loop (same code path as production). */ export async function runFlowEval( userPrompt: string, - openaiApiKey: string, + apiKey: string, options?: FlowEvalOptions ): Promise { const { helpers, getFlow } = createFlowEvalHelpers( @@ -65,7 +67,7 @@ export async function runFlowEval( // Resolve variant configuration const variantName = options?.variant?.name ?? 'baseline' const systemMessage = resolveSystemPrompt(options?.variant, flowDefaults, options?.customSystemPrompt) - const { toolDefs, tools } = resolveTools(options?.variant, flowDefaults) + const { tools } = resolveTools(options?.variant, flowDefaults) const model = resolveModel(options?.variant, options?.model) // Build user message @@ -76,15 +78,15 @@ export async function runFlowEval( userPrompt, systemMessage, userMessage, - toolDefs, tools, helpers, - apiKey: openaiApiKey, + apiKey, getOutput: getFlow, options: { maxIterations: options?.maxIterations, model, - workspace: 'test-workspace' + workspace: 'test-workspace', + provider: options?.provider } }) @@ -111,21 +113,32 @@ export async function runFlowEval( } } +/** + * Per-variant provider override. + */ +export interface VariantProviderOverride { + provider: AIProvider + apiKey: string +} + /** * Runs the same prompt against multiple variants sequentially for comparison. - * Returns results in the same order as the input variants. + * Accepts optional per-variant provider/apiKey overrides. */ export async function runVariantComparison( userPrompt: string, variants: VariantConfig[], - openaiApiKey: string, - baseOptions?: Omit + defaultApiKey: string, + baseOptions?: Omit, + providerOverrides?: VariantProviderOverride[] ): Promise { const results: FlowEvalResult[] = await Promise.all( - variants.map(async (variant) => { - return await runFlowEval(userPrompt, openaiApiKey, { + variants.map(async (variant, i) => { + const override = providerOverrides?.[i] + return await runFlowEval(userPrompt, override?.apiKey ?? defaultApiKey, { ...baseOptions, - variant + variant, + provider: override?.provider ?? baseOptions?.provider }) }) ) diff --git a/frontend/src/lib/components/copilot/chat/__tests__/shared/baseEvalRunner.ts b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseEvalRunner.ts index b9b7820568..f46acb9108 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/shared/baseEvalRunner.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseEvalRunner.ts @@ -1,8 +1,14 @@ -import OpenAI, { APIError } from 'openai' -import type { ChatCompletionMessageParam, ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs' -import type { ChatCompletionTool } from 'openai/resources/chat/completions.mjs' +import OpenAI from 'openai' +import Anthropic from '@anthropic-ai/sdk' +import type { + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam +} from 'openai/resources/chat/completions.mjs' +import type { AIProvider, AIProviderModel } from '$lib/gen/types.gen' import type { TokenUsage, ToolCallDetail, EvalRunnerOptions } from './types' import type { Tool } from './baseVariants' +import { runChatLoop, type ChatClients } from '../../chatLoop' +import type { Tool as ProductionTool, ToolCallbacks } from '../../shared' /** * Result from a single eval run (before domain-specific evaluation). @@ -29,13 +35,13 @@ export interface RunEvalParams { systemMessage: ChatCompletionSystemMessageParam /** User message for the LLM */ userMessage: ChatCompletionMessageParam - /** Tool definitions for the LLM API */ - toolDefs: ChatCompletionTool[] + /** Tool definitions for the LLM API (unused — derived from tools) */ + toolDefs?: unknown /** Full tool implementations for execution */ tools: Tool[] /** Domain-specific helpers for tool execution */ helpers: THelpers - /** API key for OpenRouter */ + /** API key for the provider */ apiKey: string /** Function to get the current output state */ getOutput: () => TOutput @@ -44,10 +50,37 @@ export interface RunEvalParams { } /** - * Runs a generic evaluation with real LLM API calls. - * Executes tool calls in a loop until the LLM stops calling tools. - * - * This is the core execution loop shared across all chat eval tests. + * Creates SDK clients for the given provider. + */ +function createEvalClients(provider: AIProvider, apiKey: string): ChatClients { + if (provider === 'anthropic') { + return { + openai: new OpenAI({ apiKey: 'unused' }), + anthropic: new Anthropic({ apiKey }) + } + } + return { + openai: new OpenAI({ apiKey }), + anthropic: new Anthropic({ apiKey: 'unused' }) + } +} + +/** + * Resolves model string to AIProviderModel. + */ +function resolveModelProvider( + model: string, + provider?: AIProvider +): AIProviderModel { + if (provider) return { provider, model } + if (model.startsWith('claude')) return { provider: 'anthropic', model } + if (model.startsWith('gpt') || model.startsWith('o')) return { provider: 'openai', model } + return { provider: 'openai', model } +} + +/** + * Runs a generic evaluation using the shared chat loop (same code path as production). + * Uses streaming via real provider SDKs instead of OpenRouter non-streaming. */ export async function runEval( params: RunEvalParams @@ -55,7 +88,6 @@ export async function runEval( const { systemMessage, userMessage, - toolDefs, tools, helpers, apiKey, @@ -63,134 +95,82 @@ export async function runEval( options } = params - const client = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey }) const model = options?.model ?? 'gpt-4o' const maxIterations = options?.maxIterations ?? 20 const workspace = options?.workspace ?? 'test-workspace' + const provider = options?.provider - const messages: ChatCompletionMessageParam[] = [systemMessage, userMessage] - const totalTokens: TokenUsage = { prompt: 0, completion: 0, total: 0 } + const modelProvider = resolveModelProvider(model, provider) + const clients = createEvalClients(modelProvider.provider, apiKey) + + const messages: ChatCompletionMessageParam[] = [userMessage] let toolCallsCount = 0 const toolsCalled: string[] = [] const toolCallDetails: ToolCallDetail[] = [] - let iterations = 0 - // No-op tool callbacks for eval - const toolCallbacks = { + // Wrap tools to intercept fn calls for tracking. + // Cast to ProductionTool since the eval Tool has a narrower toolCallbacks type + // but the actual callbacks passed at runtime will satisfy both interfaces. + const wrappedTools = tools.map((tool) => ({ + ...tool, + fn: async (p: any) => { + toolCallsCount++ + toolsCalled.push(tool.def.function.name) + try { + const args = + typeof p.args === 'string' ? JSON.parse(p.args) : p.args + toolCallDetails.push({ name: tool.def.function.name, arguments: args }) + } catch { + toolCallDetails.push({ + name: tool.def.function.name, + arguments: p.args + }) + } + return tool.fn(p) + } + })) as ProductionTool[] + + // No-op callbacks for eval + const callbacks: ToolCallbacks & { + onNewToken: (token: string) => void + onMessageEnd: () => void + } = { setToolStatus: () => {}, - removeToolStatus: () => {} + removeToolStatus: () => {}, + onNewToken: () => {}, + onMessageEnd: () => {} } + const abortController = new AbortController() + try { - // Tool resolution loop - while (iterations < maxIterations) { - iterations++ - - const response = await client.chat.completions.create({ - model, - messages, - tools: toolDefs, - temperature: 0 - }) - - // Track token usage - if (response.usage) { - totalTokens.prompt += response.usage.prompt_tokens - totalTokens.completion += response.usage.completion_tokens - totalTokens.total += response.usage.total_tokens - } - - if (!response.choices.length) { - throw new Error('No response from API') - } - - const choice = response.choices[0] - const assistantMessage = choice.message - - // Add assistant message to history - messages.push(assistantMessage) - - // If no tool calls, we're done - if (!assistantMessage.tool_calls?.length) { - break - } - - // Execute each tool call - for (const toolCall of assistantMessage.tool_calls) { - toolCallsCount++ - - // Type guard: only handle function tool calls - if (toolCall.type !== 'function') { - messages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: `Unsupported tool type: ${toolCall.type}` - }) - continue - } - - toolsCalled.push(toolCall.function.name) - - const tool = tools.find((t) => t.def.function.name === toolCall.function.name) - if (!tool) { - messages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: `Unknown tool: ${toolCall.function.name}` - }) - continue - } - - try { - const args = JSON.parse(toolCall.function.arguments) - toolCallDetails.push({ name: toolCall.function.name, arguments: args }) - const result = await tool.fn({ - args, - workspace, - helpers, - toolCallbacks, - toolId: toolCall.id - }) - messages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: result - }) - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err) - messages.push({ - role: 'tool', - tool_call_id: toolCall.id, - content: `Error: ${errorMessage}` - }) - } - } - } + const result = await runChatLoop({ + messages, + systemMessage, + tools: wrappedTools, + helpers, + abortController, + callbacks, + modelProvider, + clients, + workspace, + maxIterations, + skipResponsesApi: modelProvider.provider !== 'openai' && modelProvider.provider !== 'azure_openai' + }) return { success: true, output: getOutput(), - tokenUsage: totalTokens, + tokenUsage: { prompt: 0, completion: 0, total: 0 }, toolCallsCount, toolsCalled, toolCallDetails, - iterations, + iterations: Math.max(1, result.addedMessages.filter((m) => m.role === 'assistant').length), messages } } catch (err) { - // Build detailed error message let errorMessage: string - if (err instanceof APIError) { - const details: string[] = [`${err.status} ${err.message}`] - if (err.code) details.push(`Code: ${err.code}`) - if (err.type) details.push(`Type: ${err.type}`) - if (err.param) details.push(`Param: ${err.param}`) - if (err.requestID) details.push(`Request ID: ${err.requestID}`) - if (err.error && typeof err.error === 'object') { - details.push(`Response: ${JSON.stringify(err.error, null, 2)}`) - } - errorMessage = details.join('\n') - } else if (err instanceof Error) { + if (err instanceof Error) { errorMessage = err.stack ?? err.message } else { errorMessage = String(err) @@ -200,11 +180,11 @@ export async function runEval( success: false, output: getOutput(), error: errorMessage, - tokenUsage: totalTokens, + tokenUsage: { prompt: 0, completion: 0, total: 0 }, toolCallsCount, toolsCalled, toolCallDetails, - iterations, + iterations: 0, messages } } diff --git a/frontend/src/lib/components/copilot/chat/__tests__/shared/baseLLMEvaluator.ts b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseLLMEvaluator.ts index 63c17828f4..bd7bd06d44 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/shared/baseLLMEvaluator.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseLLMEvaluator.ts @@ -1,4 +1,4 @@ -import OpenAI from 'openai' +import Anthropic from '@anthropic-ai/sdk' import type { EvaluationResult } from './types' /** @@ -13,9 +13,9 @@ export interface EvaluateParams { expectedOutput: unknown /** Domain-specific system prompt for the evaluator */ evaluatorSystemPrompt: string - /** API key for OpenRouter */ - apiKey: string - /** Model to use for evaluation (default: 'anthropic/claude-sonnet-4.5') */ + /** Anthropic API key for evaluation */ + apiKey?: string + /** Model to use for evaluation (default: 'claude-sonnet-4-5-20250514') */ model?: string } @@ -41,10 +41,7 @@ Score guidelines: /** * Evaluates how well a generated output matches an expected output using an LLM. - * Returns a resemblance score (0-100), a qualitative statement, and any missing requirements. - * - * @param params Evaluation parameters including prompts, outputs, and API configuration - * @returns Evaluation result with score, statement, and missing requirements + * Uses Anthropic API directly instead of OpenRouter. */ export async function evaluateWithLLM(params: EvaluateParams): Promise { const { @@ -53,10 +50,21 @@ export async function evaluateWithLLM(params: EvaluateParams): Promise block.type === 'text') + const content = textBlock?.text if (!content) { return { success: false, @@ -98,7 +108,6 @@ Please evaluate how well the generated output: // Parse JSON response - handle potential markdown code blocks let jsonContent = content.trim() if (jsonContent.startsWith('```')) { - // Remove markdown code block wrapper jsonContent = jsonContent.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '') } diff --git a/frontend/src/lib/components/copilot/chat/__tests__/shared/types.ts b/frontend/src/lib/components/copilot/chat/__tests__/shared/types.ts index 021e776440..61f7f1fd1f 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/shared/types.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/shared/types.ts @@ -1,4 +1,5 @@ import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.mjs' +import type { AIProvider } from '$lib/gen/types.gen' /** * Token usage tracking for LLM calls. @@ -83,6 +84,8 @@ export interface EvalRunnerOptions { model?: string /** Workspace ID for tool calls */ workspace?: string + /** AI provider (inferred from model name if omitted) */ + provider?: AIProvider } /** diff --git a/frontend/src/lib/components/copilot/chat/anthropic.ts b/frontend/src/lib/components/copilot/chat/anthropic.ts index 03d0f363a0..ac45c175a6 100644 --- a/frontend/src/lib/components/copilot/chat/anthropic.ts +++ b/frontend/src/lib/components/copilot/chat/anthropic.ts @@ -1,4 +1,5 @@ import { OpenAI } from 'openai' +import Anthropic from '@anthropic-ai/sdk' import type { ChatCompletionMessageParam, ChatCompletionMessageFunctionToolCall @@ -13,19 +14,28 @@ import type { RawMessageStreamEvent } from '@anthropic-ai/sdk/resources' import type { MessageStream } from '@anthropic-ai/sdk/lib/MessageStream' +import type { AIProviderModel } from '$lib/gen' import { getProviderAndCompletionConfig, workspaceAIClients } from '../lib' import { processToolCall, type Tool, type ToolCallbacks } from './shared' export async function getAnthropicCompletion( messages: ChatCompletionMessageParam[], abortController: AbortController, - tools?: OpenAI.Chat.Completions.ChatCompletionFunctionTool[] + tools?: OpenAI.Chat.Completions.ChatCompletionFunctionTool[], + options?: { + forceModelProvider?: AIProviderModel + anthropicClient?: Anthropic + } ): Promise { - const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true }) + const { provider, config } = getProviderAndCompletionConfig({ + messages, + stream: true, + forceModelProvider: options?.forceModelProvider + }) const { system, messages: anthropicMessages } = convertOpenAIToAnthropicMessages(messages) const anthropicTools = convertOpenAIToolsToAnthropic(tools) - const anthropicClient = workspaceAIClients.getAnthropicClient() + const client = options?.anthropicClient ?? workspaceAIClients.getAnthropicClient() const anthropicParams = { model: config.model, @@ -36,7 +46,7 @@ export async function getAnthropicCompletion( ...(typeof config.temperature === 'number' && { temperature: config.temperature }) } - const stream = anthropicClient.messages.stream(anthropicParams, { + const stream = client.messages.stream(anthropicParams, { signal: abortController.signal, headers: { 'X-Provider': provider, @@ -58,7 +68,8 @@ export async function parseAnthropicCompletion( addedMessages: ChatCompletionMessageParam[], tools: Tool[], helpers: any, - abortController?: AbortController + abortController?: AbortController, + options?: { workspace?: string } ): Promise { let toolCallsToProcess: ChatCompletionMessageFunctionToolCall[] = [] let error = null @@ -209,7 +220,8 @@ export async function parseAnthropicCompletion( tools, toolCall, helpers, - toolCallbacks: callbacks + toolCallbacks: callbacks, + workspace: options?.workspace }) messages.push(messageToAdd) addedMessages.push(messageToAdd) diff --git a/frontend/src/lib/components/copilot/chat/app/core.ts b/frontend/src/lib/components/copilot/chat/app/core.ts index 8fc88d4d16..c6a64a3e5a 100644 --- a/frontend/src/lib/components/copilot/chat/app/core.ts +++ b/frontend/src/lib/components/copilot/chat/app/core.ts @@ -10,6 +10,7 @@ import { createGetRunnableDetailsTool, type Tool } from '../shared' +import { getDatatableSdkReference } from '$system_prompts' import { aiChatManager } from '../AIChatManager.svelte' import type { ContextElement, @@ -842,38 +843,30 @@ For inline scripts, the code must have a \`main\` function as its entrypoint. Backend runnables should only perform **data operations** (SELECT, INSERT, UPDATE, DELETE) on **existing tables**. Never use CREATE TABLE, DROP TABLE, or ALTER TABLE inside runnables. -**TypeScript (Bun)**: +**TypeScript (Bun) example**: \`\`\`typescript import * as wmill from 'windmill-client'; export async function main(user_id: string) { const sql = ${datatableCall}; - - // Safe string interpolation (parameterized query) const user = await sql\`SELECT * FROM ${schemaPrefix}users WHERE id = \${user_id}\`.fetchOne(); return user; } \`\`\` -**Python**: +**Python example**: \`\`\`python import wmill def main(user_id: str): db = ${datatableCall} - - # Use positional arguments ($1, $2, etc.) user = db.query('SELECT * FROM ${schemaPrefix}users WHERE id = $1', user_id).fetch_one() return user \`\`\` -### Common Operations (for use in backend runnables) +### Datatable Client API Reference -- **Fetch all**: \`sql\`SELECT * FROM ${schemaPrefix}table\`.fetch()\` or \`db.query('SELECT * FROM ${schemaPrefix}table').fetch()\` -- **Fetch one**: \`.fetchOne()\` or \`.fetch_one()\` -- **Insert**: \`sql\`INSERT INTO ${schemaPrefix}table (col) VALUES (\${value})\`\` -- **Update**: \`sql\`UPDATE ${schemaPrefix}table SET col = \${value} WHERE id = \${id}\`\` -- **Delete**: \`sql\`DELETE FROM ${schemaPrefix}table WHERE id = \${id}\`\` +${getDatatableSdkReference()} ### Schema Modifications (DDL) - Use exec_datatable_sql tool ONLY diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.ts b/frontend/src/lib/components/copilot/chat/chatLoop.ts new file mode 100644 index 0000000000..4b239e4a05 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/chatLoop.ts @@ -0,0 +1,211 @@ +import OpenAI from 'openai' +import Anthropic from '@anthropic-ai/sdk' +import type { + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionUserMessageParam +} from 'openai/resources/chat/completions.mjs' +import type { AIProviderModel } from '$lib/gen' +import { getCompletion, parseOpenAICompletion } from '../lib' +import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic' +import { + getOpenAIResponsesCompletion, + parseOpenAIResponsesCompletion +} from './openai-responses' +import type { Tool, ToolCallbacks } from './shared' + +export interface ChatClients { + openai: OpenAI + anthropic: Anthropic +} + +export interface ChatLoopConfig { + messages: ChatCompletionMessageParam[] + /** + * System message, tools, helpers, and modelProvider are re-read from this config + * on every iteration. Callers can use JS getters to provide dynamic values + * (e.g. AIChatManager uses getters so mode changes mid-loop take effect). + */ + systemMessage: ChatCompletionSystemMessageParam + tools: Tool[] + helpers: any + abortController: AbortController + callbacks: ToolCallbacks & { + onNewToken: (token: string) => void + onMessageEnd: () => void + } + modelProvider: AIProviderModel + clients: ChatClients + workspace: string + /** Maximum iterations for the loop. undefined = unlimited (production). */ + maxIterations?: number + skipResponsesApi?: boolean + onSkipResponsesApi?: () => void + /** Return a pending user message to inject between iterations, or undefined. */ + getPendingUserMessage?: () => ChatCompletionUserMessageParam | undefined + /** Called before each iteration (e.g. to refresh tool schemas). */ + onBeforeIteration?: (tools: Tool[], helpers: any) => Promise +} + +export interface ChatLoopResult { + addedMessages: ChatCompletionMessageParam[] +} + +export async function runChatLoop(config: ChatLoopConfig): Promise { + const { + messages, + abortController, + callbacks, + clients, + workspace, + maxIterations, + onSkipResponsesApi, + getPendingUserMessage, + onBeforeIteration + } = config + let skipResponsesApi = config.skipResponsesApi ?? false + + const addedMessages: ChatCompletionMessageParam[] = [] + let iterations = 0 + + while (true) { + if (maxIterations !== undefined && iterations >= maxIterations) { + break + } + iterations++ + + // Re-read these from config each iteration so that mode changes + // (e.g. changeModeTool in Navigator) take effect immediately. + // Callers can use JS getter properties to provide dynamic values. + const tools = config.tools + const helpers = config.helpers + const systemMessage = config.systemMessage + const modelProvider = config.modelProvider + + if (onBeforeIteration) { + await onBeforeIteration(tools, helpers) + } + + const pendingUserMessage = getPendingUserMessage?.() + + const isOpenAI = + modelProvider.provider === 'openai' || modelProvider.provider === 'azure_openai' + const isAnthropic = modelProvider.provider === 'anthropic' + + const messageParams = [ + systemMessage, + ...messages, + ...(pendingUserMessage ? [pendingUserMessage] : []) + ] + const toolDefs = tools.map((t) => t.def) + const parseOptions = { workspace } + + if (isOpenAI) { + let useCompletionsApi = skipResponsesApi + if (!skipResponsesApi) { + try { + const completion = await getOpenAIResponsesCompletion( + messageParams, + abortController, + toolDefs, + { + forceModelProvider: modelProvider, + openaiClient: clients.openai + } + ) + const continueCompletion = await parseOpenAIResponsesCompletion( + completion, + callbacks, + messages, + addedMessages, + tools, + helpers, + parseOptions + ) + if (!continueCompletion) { + break + } + } catch (err) { + console.warn( + 'OpenAI Responses API failed, falling back to Completions API:', + err + ) + const errorMessage = err instanceof Error ? err.message : String(err) + if (errorMessage.includes('Responses API is not enabled')) { + skipResponsesApi = true + onSkipResponsesApi?.() + } + useCompletionsApi = true + } + } + + if (useCompletionsApi) { + const completion = await getCompletion(messageParams, abortController, toolDefs, { + forceCompletions: true, + forceModelProvider: modelProvider, + openaiClient: clients.openai + }) + const continueCompletion = await parseOpenAICompletion( + completion, + callbacks, + messages, + addedMessages, + tools, + helpers, + undefined, + parseOptions + ) + if (!continueCompletion) { + break + } + } + } else if (isAnthropic) { + const completion = await getAnthropicCompletion( + messageParams, + abortController, + toolDefs, + { + forceModelProvider: modelProvider, + anthropicClient: clients.anthropic + } + ) + if (completion) { + const continueCompletion = await parseAnthropicCompletion( + completion, + callbacks, + messages, + addedMessages, + tools, + helpers, + abortController, + parseOptions + ) + if (!continueCompletion) { + break + } + } + } else { + const completion = await getCompletion(messageParams, abortController, toolDefs, { + forceModelProvider: modelProvider, + openaiClient: clients.openai + }) + if (completion) { + const continueCompletion = await parseOpenAICompletion( + completion, + callbacks, + messages, + addedMessages, + tools, + helpers, + undefined, + parseOptions + ) + if (!continueCompletion) { + break + } + } + } + } + + return { addedMessages } +} diff --git a/frontend/src/lib/components/copilot/chat/openai-responses.ts b/frontend/src/lib/components/copilot/chat/openai-responses.ts index d7e5ad5c96..56364e1401 100644 --- a/frontend/src/lib/components/copilot/chat/openai-responses.ts +++ b/frontend/src/lib/components/copilot/chat/openai-responses.ts @@ -5,10 +5,14 @@ import type { ChatCompletionCreateParams } from 'openai/resources/index.mjs' import type { ResponseErrorEvent } from 'openai/resources/responses/responses.mjs' -import { getProviderAndCompletionConfig, workspaceAIClients } from '../lib' +import { + createOpenAIProxyClient, + getAiProxyBaseURL, + getProviderAndCompletionConfig, + workspaceAIClients +} from '../lib' import { processToolCall, type Tool, type ToolCallbacks } from './shared' import type { ResponseStream } from 'openai/lib/responses/ResponseStream.mjs' -import { OpenAPI } from '$lib/gen' import type { AIProviderModel } from '$lib/gen' // Conversion utilities for Responses API @@ -121,15 +125,24 @@ function convertCompletionConfigToResponsesConfig( export async function getOpenAIResponsesCompletion( messages: ChatCompletionMessageParam[], abortController: AbortController, - tools?: OpenAI.Chat.Completions.ChatCompletionTool[] + tools?: OpenAI.Chat.Completions.ChatCompletionTool[], + options?: { + forceModelProvider?: AIProviderModel + openaiClient?: OpenAI + } ) { - const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true, tools }) + const { provider, config } = getProviderAndCompletionConfig({ + messages, + stream: true, + tools, + forceModelProvider: options?.forceModelProvider + }) const { instructions, input } = convertMessagesToResponsesInput(messages) const responsesConfig = convertCompletionConfigToResponsesConfig(config) - const openaiClient = workspaceAIClients.getOpenaiClient() + const client = options?.openaiClient ?? workspaceAIClients.getOpenaiClient() - const runner = openaiClient.responses.stream( + const runner = client.responses.stream( { ...responsesConfig, input, @@ -204,7 +217,8 @@ export async function parseOpenAIResponsesCompletion( messages: ChatCompletionMessageParam[], addedMessages: ChatCompletionMessageParam[], tools: Tool[], - helpers: any + helpers: any, + options?: { workspace?: string } ): Promise { let toolCallsToProcess: ChatCompletionMessageFunctionToolCall[] = [] let error: OpenAIError | ResponseErrorEvent | null = null @@ -338,7 +352,8 @@ export async function parseOpenAIResponsesCompletion( tools, toolCall, helpers, - toolCallbacks: callbacks + toolCallbacks: callbacks, + workspace: options?.workspace }) messages.push(messageToAdd) addedMessages.push(messageToAdd) @@ -354,6 +369,7 @@ export async function getNonStreamingOpenAIResponsesCompletion( abortController: AbortController, testOptions?: { apiKey?: string + workspace?: string resourcePath?: string forceModelProvider: AIProviderModel } @@ -390,15 +406,10 @@ export async function getNonStreamingOpenAIResponsesCompletion( } const openaiClient = testOptions?.apiKey - ? new OpenAI({ - baseURL: `${location.origin}${OpenAPI.BASE}/ai/proxy`, - apiKey: 'fake-key', - defaultHeaders: { - Authorization: '' // a non empty string will be unable to access Windmill backend proxy - }, - dangerouslyAllowBrowser: true - }) - : workspaceAIClients.getOpenaiClient() + ? createOpenAIProxyClient(getAiProxyBaseURL()) + : testOptions?.workspace + ? workspaceAIClients.createOpenaiClient(testOptions.workspace) + : workspaceAIClients.getOpenaiClient() const response = await openaiClient.responses.create( { diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 4a95912b47..20e488d923 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -417,12 +417,14 @@ export async function processToolCall({ tools, toolCall, helpers, - toolCallbacks + toolCallbacks, + workspace }: { tools: Tool[] toolCall: ChatCompletionMessageFunctionToolCall helpers: T toolCallbacks: ToolCallbacks + workspace?: string }): Promise { try { const args = JSON.parse(toolCall.function.arguments || '{}') @@ -472,7 +474,7 @@ export async function processToolCall({ tools, functionName: toolCall.function.name, args, - workspace: get(workspaceStore) ?? '', + workspace: workspace ?? get(workspaceStore) ?? '', helpers, toolCallbacks, toolId: toolCall.id diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index f9afd9bb2c..d8149086d4 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -67,7 +67,14 @@ export const AI_PROVIDERS: Record = { }, googleai: { label: 'Google AI', - defaultModels: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.5-flash-lite', 'gemini-3-flash', 'gemini-3.1-pro', 'gemini-3.1-flash-lite'] + defaultModels: [ + 'gemini-2.5-flash', + 'gemini-2.5-pro', + 'gemini-2.5-flash-lite', + 'gemini-3-flash', + 'gemini-3.1-pro', + 'gemini-3.1-flash-lite' + ] }, groq: { label: 'Groq', @@ -289,7 +296,12 @@ function getModelSpecificConfig( ) { const defaultMaxTokens = getModelMaxTokens(modelProvider.provider, modelProvider.model) const modelKey = `${modelProvider.provider}:${modelProvider.model}` - const customMaxTokensStore = get(copilotInfo)?.maxTokensPerModel + let customMaxTokensStore: Record | undefined + try { + customMaxTokensStore = get(copilotInfo)?.maxTokensPerModel + } catch { + // copilotInfo store may not be initialized in vitest + } const maxTokens = customMaxTokensStore?.[modelKey] ?? defaultMaxTokens if ( (modelProvider.provider === 'openai' || modelProvider.provider === 'azure_openai') && @@ -364,38 +376,46 @@ export const PROVIDER_COMPLETION_CONFIG_MAP: Record> { - const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true, tools }) + const { provider, config } = getProviderAndCompletionConfig({ + messages, + stream: true, + tools, + forceModelProvider: options?.forceModelProvider + }) // Use Responses API for OpenAI and Azure OpenAI if ((provider === 'openai' || provider === 'azure_openai') && !options?.forceCompletions) { @@ -876,8 +903,8 @@ export async function getCompletion( } // Use Completions API for other providers - const openaiClient = workspaceAIClients.getOpenaiClient() - const completion = openaiClient.chat.completions.create(config, { + const client = options?.openaiClient ?? workspaceAIClients.getOpenaiClient() + const completion = client.chat.completions.create(config, { signal: abortController.signal, headers: { 'X-Provider': provider @@ -906,7 +933,8 @@ export async function parseOpenAICompletion( addedMessages: ChatCompletionMessageParam[], tools: Tool[], helpers: any, - _abortController?: AbortController // unused, for signature compatibility with parseAnthropicCompletion + _abortController?: AbortController, // unused, for signature compatibility with parseAnthropicCompletion + options?: { workspace?: string } ): Promise { const finalToolCalls: Record = {} let malformedFunctionCallError = false @@ -1045,7 +1073,8 @@ export async function parseOpenAICompletion( tools, toolCall, helpers, - toolCallbacks: callbacks + toolCallbacks: callbacks, + workspace: options?.workspace }) messages.push(messageToAdd) addedMessages.push(messageToAdd) diff --git a/frontend/src/lib/components/dbOps.ts b/frontend/src/lib/components/dbOps.ts index 2f38b78a71..8a528fd8e3 100644 --- a/frontend/src/lib/components/dbOps.ts +++ b/frontend/src/lib/components/dbOps.ts @@ -3,29 +3,21 @@ import { type ColumnDef, type TableMetadata } from './apps/components/display/dbtable/utils' -import { makeSelectQuery } from './apps/components/display/dbtable/queries/select' import { runScriptAndPollResult } from './jobs/utils' -import { makeCountQuery } from './apps/components/display/dbtable/queries/count' -import { makeUpdateQuery } from './apps/components/display/dbtable/queries/update' -import { makeDeleteQuery } from './apps/components/display/dbtable/queries/delete' -import { makeInsertQuery } from './apps/components/display/dbtable/queries/insert' -import { makeDeleteTableQuery } from './apps/components/display/dbtable/queries/deleteTable' import type { DBSchema, SQLSchema } from '$lib/stores' import { stringifySchema } from './copilot/lib' import type { DbInput, DbType } from './dbTypes' -import { wrapDucklakeQuery } from './ducklake' import { assert } from '$lib/utils' import { buildTableEditorValues, type TableEditorValues } from './apps/components/display/dbtable/tableEditor' +import { type AlterTableValues } from './apps/components/display/dbtable/queries/alterTable' import { - makeAlterTableQueries, - makeAlterTableQuery, - type AlterTableValues -} from './apps/components/display/dbtable/queries/alterTable' -import { makeCreateTableQuery } from './apps/components/display/dbtable/queries/createTable' -import { fetchTableRelationalKeys } from './apps/components/display/dbtable/queries/relationalKeys' + transformForeignKeys, + transformSnowflakeForeignKeys, + type RawForeignKey +} from './apps/components/display/dbtable/queries/relationalKeys' export type IDbTableOps = { dbType: DbType @@ -63,28 +55,35 @@ export function dbTableOpsWithPreviewScripts({ const dbType = getDbType(input) const language = getLanguageByResourceType(dbType) const dbArg = getDatabaseArg(input) + const ducklake = input.type === 'ducklake' ? input.ducklake : undefined + + function makeMarker(op: string, payload: Record): string { + if (ducklake) payload.ducklake = ducklake + return `-- WM_INTERNAL_DB_${op} ${JSON.stringify(payload)}` + } + return { dbType, tableKey, colDefs, getCount: async ({ quicksearch }) => { - let countQuery = makeCountQuery(dbType, tableKey, undefined, colDefs) - if (input.type === 'ducklake') countQuery = wrapDucklakeQuery(countQuery, input.ducklake) + const content = makeMarker('COUNT', { table: tableKey, columnDefs: colDefs }) const result = await runScriptAndPollResult({ workspace, - requestBody: { args: { ...dbArg, quicksearch }, language, content: countQuery } + requestBody: { args: { ...dbArg, quicksearch }, language, content } }) const count = result?.[0].count as number return count }, getRows: async (params) => { - let query = makeSelectQuery(tableKey, colDefs, undefined, dbType, undefined, { + const content = makeMarker('SELECT', { + table: tableKey, + columnDefs: colDefs, fixPgIntTypes: true }) - if (input.type === 'ducklake') query = wrapDucklakeQuery(query, input.ducklake) let items = (await runScriptAndPollResult({ workspace, - requestBody: { args: { ...dbArg, ...params }, language, content: query } + requestBody: { args: { ...dbArg, ...params }, language, content } })) as unknown[] if (!items || !Array.isArray(items)) { throw 'items is not an array' @@ -92,31 +91,32 @@ export function dbTableOpsWithPreviewScripts({ return items }, onUpdate: async ({ values }, colDef, newValue) => { - let updateQuery = makeUpdateQuery(tableKey, colDef, colDefs, dbType) - if (input.type === 'ducklake') updateQuery = wrapDucklakeQuery(updateQuery, input.ducklake) + const content = makeMarker('UPDATE', { + table: tableKey, + column: colDef, + columns: colDefs + }) await runScriptAndPollResult({ workspace, requestBody: { args: { ...dbArg, value_to_update: newValue, ...values }, language, - content: updateQuery + content } }) }, onDelete: async ({ values }) => { - let deleteQuery = makeDeleteQuery(tableKey, colDefs, dbType) - if (input.type === 'ducklake') deleteQuery = wrapDucklakeQuery(deleteQuery, input.ducklake) + const content = makeMarker('DELETE', { table: tableKey, columns: colDefs }) await runScriptAndPollResult({ workspace, - requestBody: { args: { ...dbArg, ...values }, language, content: deleteQuery } + requestBody: { args: { ...dbArg, ...values }, language, content } }) }, onInsert: async ({ values }) => { - let insertQuery = makeInsertQuery(tableKey, colDefs, dbType) - if (input.type === 'ducklake') insertQuery = wrapDucklakeQuery(insertQuery, input.ducklake) + const content = makeMarker('INSERT', { table: tableKey, columns: colDefs }) await runScriptAndPollResult({ workspace, - requestBody: { args: { ...dbArg, ...values }, language, content: insertQuery } + requestBody: { args: { ...dbArg, ...values }, language, content } }) } } @@ -125,9 +125,9 @@ export function dbTableOpsWithPreviewScripts({ export type IDbSchemaOps = { onDelete: (params: { tableKey: string; schema?: string }) => Promise onCreate: (params: { values: TableEditorValues; schema?: string }) => Promise - previewCreateSql: (params: { values: TableEditorValues; schema?: string }) => string + previewCreateSql: (params: { values: TableEditorValues; schema?: string }) => Promise onAlter: (params: { values: AlterTableValues; schema?: string }) => Promise - previewAlterSql: (params: { values: AlterTableValues; schema?: string }) => string[] + previewAlterSql: (params: { values: AlterTableValues; schema?: string }) => Promise onCreateSchema: (params: { schema: string }) => Promise onDeleteSchema: (params: { schema: string }) => Promise onFetchTableEditorDefinition: (params: { @@ -147,61 +147,130 @@ export function dbSchemaOpsWithPreviewScripts({ const dbType = getDbType(input) const dbArg = getDatabaseArg(input) const language = getLanguageByResourceType(dbType) + const ducklake = input.type === 'ducklake' ? input.ducklake : undefined + + function makeMarker(op: string, payload: Record): string { + if (ducklake) payload.ducklake = ducklake + return `-- WM_INTERNAL_DB_${op} ${JSON.stringify(payload)}` + } + return { onDelete: async ({ tableKey, schema }) => { - let deleteQuery = makeDeleteTableQuery(tableKey, dbType, schema) - if (input.type === 'ducklake') deleteQuery = wrapDucklakeQuery(deleteQuery, input.ducklake) + const content = makeMarker('DROP_TABLE', { table: tableKey, schema }) await runScriptAndPollResult({ workspace, - requestBody: { args: { ...dbArg }, language, content: deleteQuery } + requestBody: { args: { ...dbArg }, language, content } }) }, onCreate: async ({ values, schema }) => { - let query = makeCreateTableQuery(values, dbType, schema) - if (input?.type === 'ducklake') query = wrapDucklakeQuery(query, input.ducklake) + const content = makeMarker('CREATE_TABLE', { + name: values.name, + columns: values.columns, + foreignKeys: values.foreignKeys, + schema + }) await runScriptAndPollResult({ workspace, - requestBody: { args: dbArg, content: query, language } + requestBody: { args: dbArg, content, language } }) }, - previewCreateSql: ({ values, schema }) => makeCreateTableQuery(values, dbType, schema), + previewCreateSql: async ({ values, schema }) => { + const content = makeMarker('CREATE_TABLE', { + name: values.name, + columns: values.columns, + foreignKeys: values.foreignKeys, + schema + }) + return expandMarker(workspace, language, content) + }, onAlter: async ({ values, schema }) => { - let query = makeAlterTableQuery(values, dbType, schema) - if (input.type === 'ducklake') query = wrapDucklakeQuery(query, input.ducklake) + const content = makeMarker('ALTER_TABLE', { + name: values.name, + operations: values.operations, + schema + }) await runScriptAndPollResult({ workspace, - requestBody: { args: dbArg, content: query, language } + requestBody: { args: dbArg, content, language } }) }, - previewAlterSql: ({ values, schema }) => makeAlterTableQueries(values, dbType, schema), + previewAlterSql: async ({ values, schema }) => { + const content = makeMarker('ALTER_TABLE', { + name: values.name, + operations: values.operations, + schema + }) + return expandMarker(workspace, language, content) + }, onCreateSchema: async ({ schema }) => { - let createSchemaQuery = `CREATE SCHEMA ${schema};` - if (input.type === 'ducklake') - createSchemaQuery = wrapDucklakeQuery(createSchemaQuery, input.ducklake) + const content = makeMarker('CREATE_SCHEMA', { schema }) await runScriptAndPollResult({ workspace, - requestBody: { args: { ...dbArg }, language, content: createSchemaQuery } + requestBody: { args: { ...dbArg }, language, content } }) }, onDeleteSchema: async ({ schema }) => { - let dropSchemaQuery = `DROP SCHEMA ${schema} CASCADE;` - if (input.type === 'ducklake') - dropSchemaQuery = wrapDucklakeQuery(dropSchemaQuery, input.ducklake) + const content = makeMarker('DROP_SCHEMA', { schema }) await runScriptAndPollResult({ workspace, - requestBody: { args: { ...dbArg }, language, content: dropSchemaQuery } + requestBody: { args: { ...dbArg }, language, content } }) }, onFetchTableEditorDefinition: async ({ table, schema, colDefs }) => { - let { foreignKeys, pk_constraint_name } = await fetchTableRelationalKeys( - input, - dbType, - table, - schema, - workspace, - dbArg, - language - ) + let foreignKeys: import('./apps/components/display/dbtable/tableEditor').TableEditorForeignKey[] = + [] + let pk_constraint_name: string | undefined + + // Fetch foreign keys (not supported for BigQuery) + if (dbType !== 'bigquery') { + try { + const fkContent = makeMarker('FOREIGN_KEYS', { table, schema }) + const fkResult = await runScriptAndPollResult({ + workspace, + requestBody: { args: dbArg, content: fkContent, language } + }) + + let rawForeignKeys: RawForeignKey[] + if (dbType === 'snowflake') { + rawForeignKeys = transformSnowflakeForeignKeys(fkResult as any[]) + } else { + rawForeignKeys = fkResult as RawForeignKey[] + if (rawForeignKeys && Array.isArray(rawForeignKeys)) { + rawForeignKeys = rawForeignKeys.map((fk) => { + const lowerFk: any = {} + Object.keys(fk).forEach((key) => { + lowerFk[key.toLowerCase()] = fk[key] + }) + return lowerFk + }) + } + } + + if (rawForeignKeys && Array.isArray(rawForeignKeys)) { + foreignKeys = transformForeignKeys(rawForeignKeys) + } + } catch (e) { + console.warn('Failed to fetch foreign keys:', e) + } + } + + // Fetch primary key constraint name (not supported for BigQuery/MySQL) + if (dbType !== 'bigquery' && dbType !== 'mysql') { + try { + const pkContent = makeMarker('PRIMARY_KEY_CONSTRAINT', { table, schema }) + const pkResult = (await runScriptAndPollResult({ + workspace, + requestBody: { args: dbArg, content: pkContent, language } + })) as { constraint_name?: string; CONSTRAINT_NAME?: string }[] + + if (pkResult && Array.isArray(pkResult) && pkResult.length > 0) { + const pkRecord: any = pkResult[0] + pk_constraint_name = pkRecord?.constraint_name || pkRecord?.CONSTRAINT_NAME || '' + } + } catch (e) { + console.warn('Failed to fetch primary key constraint:', e) + } + } return buildTableEditorValues({ tableName: table, @@ -278,3 +347,16 @@ export function getDatabaseArg(input: DbInput | undefined) { } return {} } + +async function expandMarker(workspace: string, language: string, content: string): Promise { + const response = await fetch(`/api/w/${workspace}/internal_db/expand_marker`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ language, content }) + }) + if (!response.ok) { + throw new Error(await response.text()) + } + const result = (await response.json()) as { code: string } + return result.code +} diff --git a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte index 3d9a533f48..471790d539 100644 --- a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte +++ b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte @@ -112,6 +112,7 @@ onDeleteSelected={() => flowModuleSchemaMap?.deleteMultiple(resolvedModuleIds)} onDuplicateSelected={() => flowModuleSchemaMap?.duplicateMultiple(resolvedModuleIds)} onMoveSelected={() => flowModuleSchemaMap?.moveMultiple(resolvedModuleIds)} + onCreateGroup={() => flowModuleSchemaMap?.createGroup(selectionManager.selectedIds)} {canMoveSelected} resolvedCount={resolvedModuleIds.length} /> diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index da9a70398e..64a7682eae 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -48,6 +48,7 @@ import { isCloudHosted } from '$lib/cloud' import { loadSchemaFromModule } from '../flowInfers' import FlowModuleSkip from './FlowModuleSkip.svelte' + import FlowModuleDebounce from './FlowModuleDebounce.svelte' import { type Job } from '$lib/gen' import { workspaceStore } from '$lib/stores' import { checkIfParentLoop } from '../utils.svelte' @@ -1135,6 +1136,11 @@ label="Suspend" /> +
+ {:else if advancedSelected === 'debounce'} +
+ +
{:else if advancedSelected === 'mock'}
diff --git a/frontend/src/lib/components/flows/content/FlowModuleDebounce.svelte b/frontend/src/lib/components/flows/content/FlowModuleDebounce.svelte new file mode 100644 index 0000000000..5ce33b4852 --- /dev/null +++ b/frontend/src/lib/components/flows/content/FlowModuleDebounce.svelte @@ -0,0 +1,59 @@ + + +-${flowModule.id}`} + size="xs" +/> diff --git a/frontend/src/lib/components/flows/content/FlowSelectionPanel.svelte b/frontend/src/lib/components/flows/content/FlowSelectionPanel.svelte index 7efb44b82d..1b54d83928 100644 --- a/frontend/src/lib/components/flows/content/FlowSelectionPanel.svelte +++ b/frontend/src/lib/components/flows/content/FlowSelectionPanel.svelte @@ -3,8 +3,8 @@ import type { SelectionManager } from '$lib/components/graph/selectionUtils.svelte' import { Button } from '$lib/components/common' import DropdownV2 from '$lib/components/DropdownV2.svelte' - import { getNoteEditorContext } from '$lib/components/graph/noteEditor.svelte' - import { StickyNote, Move, Copy, Trash2 } from 'lucide-svelte' + import { getGroupEditorContext } from '$lib/components/graph/groupEditor.svelte' + import { Group, Move, Copy, Trash2 } from 'lucide-svelte' import type { Item } from '$lib/utils' interface Props { @@ -13,6 +13,7 @@ onDeleteSelected?: () => void onDuplicateSelected?: () => void onMoveSelected?: () => void + onCreateGroup?: () => void canMoveSelected?: boolean resolvedCount?: number } @@ -22,18 +23,14 @@ onDeleteSelected, onDuplicateSelected, onMoveSelected, + onCreateGroup, canMoveSelected = false, resolvedCount = 0 }: Props = $props() - const noteEditorContext = getNoteEditorContext() + const groupEditorContext = getGroupEditorContext() - function addGroupNote() { - if (selectionManager.selectedIds.length > 0 && noteEditorContext?.noteEditor) { - // Create the group note - noteEditorContext.noteEditor.createGroupNote(selectionManager.selectedIds) - } - } + let canCreateGroup = $derived(groupEditorContext?.canCreateGroup.val ?? false) let menuItems: Item[] = $derived([ { @@ -60,11 +57,11 @@ {#snippet action()}
{#if resolvedCount > 0} diff --git a/frontend/src/lib/components/flows/content/SuspendDrawer.svelte b/frontend/src/lib/components/flows/content/SuspendDrawer.svelte index ee22349cac..3ac2afe001 100644 --- a/frontend/src/lib/components/flows/content/SuspendDrawer.svelte +++ b/frontend/src/lib/components/flows/content/SuspendDrawer.svelte @@ -39,27 +39,9 @@ render a cancel button, providing the operator with an option to cancel the step. e.g: - {#snippet content()} - - - () + for (const g of groups) { + const key = `${g.start_id}:${g.end_id}` + if (seen.has(key)) { + throw new Error(`Duplicate group: '${g.start_id}' → '${g.end_id}'`) + } + seen.add(key) + } + } + function apply() { try { const parsed = YAML.parse(code) + validateGroups(parsed.value?.groups) if (parsed.summary && typeof parsed.summary === 'string') { flowStore.val.summary = parsed.summary } @@ -59,7 +72,7 @@ initialCode = code sendUserToast('Changes applied') } catch (e) { - ;(sendUserToast('Error parsing yaml: ' + e), true) + sendUserToast('Error parsing yaml: ' + e, true) } } @@ -69,8 +82,12 @@ drawer?.toggleDrawer()}> {#snippet actions()} - - + + {/snippet} {#if flowStore.val} diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte index 8e0250cec8..e6a548b39a 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte @@ -192,10 +192,10 @@ !!id && !!$flowPropPickerConfig && !!pickableIds && Object.keys(pickableIds).includes(id) ) - let isDragging = $derived(!!moveManager?.dragging) + let isMoving = $derived(!!moveManager?.dragging || !!moveManager?.movingModuleId) const outputPickerVisible = $derived( - editMode && (isConnectingCandidate || alwaysShowOutputPicker) && !!id && !isDragging + editMode && (isConnectingCandidate || alwaysShowOutputPicker) && !!id && !isMoving ) const icon_render = $derived(icon) @@ -214,7 +214,7 @@ flowStore?.val?.value.failure_module )} - (editId = false)}> + (editId = false)}>
{#snippet icon()} @@ -484,11 +482,10 @@ {/if}
- {#if deletable && !isDragging} + {#if deletable && !isMoving} {#if maximizeSubflow !== undefined} {@render buttonMaximizeSubflow?.()} {/if} - {#if (id && Object.values($flowInputsStore?.[id]?.flowStepWarnings || {}).length > 0) || Boolean(warningMessage)} - {#if editMode && enableTestRun && flowJob?.type !== 'QueuedJob' && !isDragging} + {#if editMode && enableTestRun && flowJob?.type !== 'QueuedJob' && !isMoving}
(hover = false)} > {#if !isMultiSelected && (hover || selected || testRunDropdownOpen) && outputPickerVisible} -
+
{#if !testIsLoading}
{/each} + + 0} + on:confirmed={() => { + affectedGroupsAction?.() + affectedGroupsPending = [] + affectedGroupsAction = undefined + affectedGroupsCancel = undefined + }} + on:canceled={() => { + affectedGroupsCancel?.() + affectedGroupsPending = [] + affectedGroupsAction = undefined + affectedGroupsCancel = undefined + }} + > + {#if affectedGroupsPending.length === 1} + {@const group = affectedGroupsPending[0]} +

The group{group.summary ? ` "${group.summary}"` : ''} will be removed (empty or duplicate). + Are you sure you want to {affectedGroupsActionLabel} the step?

+ {:else} +

The following groups will be removed (empty or duplicate):

+
    + {#each affectedGroupsPending as group} +
  • {group.summary || `${group.start_id} → ${group.end_id}`}
  • + {/each} +
+

Are you sure you want to {affectedGroupsActionLabel} the step?

+ {/if} +
{ dependents = getDependentComponents(id, flowStore.val) - const cb = () => { - push(history, flowStore.val) - if (id === 'preprocessor') { + + if (id === 'preprocessor') { + const cb = () => { + push(history, flowStore.val) selectionManager.selectId('Input') flowStore.val.value.preprocessor_module = undefined - } else { - selectNextId(id) - removeAtId(flowStore.val.value.modules, id) + refreshStateStore(flowStore) + onDelete?.(id) + delete flowStateStore.val[id] } + if (Object.keys(dependents).length > 0) { + deleteCallback = cb + } else { + cb() + } + return + } + + const dsOpts = { displayState: groupDisplayState } + const { emptiedGroups, duplicateGroups, commit } = proxy.prepareMutation((tree) => { + const found = findInStructure(tree, id) + if (found) found.parentChildren.splice(found.index, 1) + }, dsOpts) + + const affectedGroups = [...emptiedGroups, ...duplicateGroups] + + const cb = () => { + push(history, flowStore.val) + selectNextId(id) + commit({ removeDuplicates: duplicateGroups.length > 0 }) refreshStateStore(flowStore) onDelete?.(id) delete flowStateStore.val[id] } - if (Object.keys(dependents).length > 0) { - deleteCallback = cb + const proceed = () => { + if (Object.keys(dependents).length > 0) { + deleteCallback = cb + } else { + cb() + } + } + + if (affectedGroups.length > 0) { + affectedGroupsPending = affectedGroups + affectedGroupsActionLabel = 'delete' + affectedGroupsAction = proceed } else { - cb() + proceed() } }} onInsert={async (detail) => { - { - let originalModules - let targetModules - if ( - detail.sourceId == 'Input' || - detail.targetId == 'Result' || - detail.kind == 'trigger' - ) { - targetModules = flowStore.val.value.modules + if (!flowStore.val.value.modules || !Array.isArray(flowStore.val.value.modules)) return + await tick() + + // --- MOVE --- + if (moveManager.movingModuleId) { + const movedIds = moveManager.movingIds ?? [moveManager.movingModuleId] + const movingId = moveManager.movingModuleId + + let mutated = false + const moveOpts = { displayState: groupDisplayState } + const { emptiedGroups, duplicateGroups, commit } = proxy.prepareMutation((tree) => { + let originalModules: FlowStructureNode[] | undefined + let targetModules: FlowStructureNode[] | undefined + + if (detail.sourceId == 'Input' || detail.targetId == 'Result') { + targetModules = tree + } + dfsStructure(tree, (node, parentArray) => { + if (matchStructureNode(node, movingId)) originalModules = parentArray + if (detail.branch && matchStructureNode(node, detail.branch.rootId)) { + targetModules = node.branches[detail.branch.branch]?.children + } else if ( + matchStructureNode(node, detail.sourceId ?? '') || + matchStructureNode(node, detail.targetId ?? '') + ) { + targetModules = parentArray + } + }) + + if (!originalModules || !targetModules) return + + if (movedIds.length > 1) { + const firstIndex = originalModules.findIndex((m) => + matchStructureNode(m, movedIds[0]) + ) + if (firstIndex < 0) return + const removedModules = originalModules.splice(firstIndex, movedIds.length) + let insertIndex = detail.index + if (originalModules === targetModules && firstIndex < detail.index) { + insertIndex -= movedIds.length + } + targetModules.splice(insertIndex, 0, ...removedModules) + } else { + const indexToRemove = originalModules.findIndex((m) => + matchStructureNode(m, movingId) + ) + if (indexToRemove < 0) return + const [removed] = originalModules.splice(indexToRemove, 1) + let insertIndex = detail.index + if (originalModules === targetModules && indexToRemove < detail.index) + insertIndex -= 1 + targetModules.splice(insertIndex, 0, removed) + } + mutated = true + }, moveOpts) + + if (!mutated) { + moveManager.clearMoving() + return } - dfs(flowStore.val.value.modules, (mod, modules, branches) => { - if (mod.id == moveManager.movingModuleId) { - originalModules = modules - } - if (detail.branch) { - if (mod.id == detail.branch.rootId) { - targetModules = branches[detail.branch.branch] - } - } else if (mod.id == detail.sourceId || mod.id == detail.targetId) { - targetModules = modules - } else if (mod.id == detail.agentId && mod.value.type === 'aiagent') { - targetModules = mod.value.tools - } - }) - if (flowStore.val.value.modules && Array.isArray(flowStore.val.value.modules)) { - await tick() - if (moveManager.movingModuleId) { - push(history, flowStore.val) - if (!originalModules || !targetModules) { - moveManager.clearMoving() - return - } - if (moveManager.movingIds && moveManager.movingIds.length > 1) { - // Multi-move: splice out all moving modules from their parent, insert at target - const firstIndex = originalModules.findIndex( - (m) => m.id === moveManager.movingIds?.[0] - ) - const removedModules = originalModules.splice( - firstIndex, - moveManager.movingIds.length - ) - let insertIndex = detail.index - if (originalModules === targetModules && firstIndex < detail.index) { - insertIndex -= moveManager.movingIds.length - } - targetModules.splice(insertIndex, 0, ...removedModules) - selectionManager.selectByIds(removedModules.map((m) => m.id)) - } else { - let indexToRemove = originalModules.findIndex( - (m) => moveManager.movingModuleId == m.id - ) - let [removedModule] = originalModules.splice(indexToRemove, 1) - // When moving within the same array, removal shifts subsequent indices down by 1 - let insertIndex = detail.index - if (originalModules === targetModules && indexToRemove < detail.index) { - insertIndex -= 1 - } - targetModules.splice(insertIndex, 0, removedModule) - selectionManager.selectId(removedModule.id) - } - moveManager.clearMoving() + const affectedGroups = [...emptiedGroups, ...duplicateGroups] + + const doMove = () => { + push(history, flowStore.val) + commit({ removeDuplicates: duplicateGroups.length > 0 }) + if (movedIds.length > 1) { + selectionManager.selectByIds(movedIds) } else { - if (detail.isPreprocessor) { - await insertNewPreprocessorModule( - flowStore, - flowStateStore, - detail.inlineScript, - detail.script - ) - selectionManager.selectId('preprocessor') - - if (detail.inlineScript?.instructions) { - dispatch('generateStep', { - moduleId: 'preprocessor', - lang: detail.inlineScript?.language, - instructions: detail.inlineScript?.instructions - }) - } - } else { - const index = (detail.agentId ? targetModules?.length : detail.index) ?? 0 - const toolKind: SpecialToolKind | 'flowmoduleTool' | undefined = detail.agentId - ? (SPECIAL_TOOL_KINDS as readonly string[]).includes(detail.kind) - ? (detail.kind as SpecialToolKind) - : 'flowmoduleTool' - : undefined - - await insertNewModuleAtIndex( - targetModules, - index, - detail.kind, - detail.script, - detail.flow, - detail.inlineScript, - toolKind - ) - const id = targetModules[index].id - selectionManager.selectId(id) - - if (detail.inlineScript?.instructions) { - dispatch('generateStep', { - moduleId: id, - lang: detail.inlineScript?.language, - instructions: detail.inlineScript?.instructions - }) - } - if (detail.kind == 'trigger') { - await insertNewModuleAtIndex( - targetModules, - index + 1, - 'forloop', - undefined, - undefined, - undefined - ) - setExpr(targetModules[index + 1], `results.${id}`) - setScheduledPollSchedule(triggersState, triggersCount) - } - - if (detail.flow?.path) { - loadLastJob(detail.flow.path, id) - } else if (detail.script?.path) { - loadLastJob(detail.script?.path, id) - } - } - } - - if (['branchone', 'branchall'].includes(detail.kind)) { - await addBranch(targetModules[detail.index ?? 0].id) + selectionManager.selectId(movingId) } + moveManager.clearMoving() refreshStateStore(flowStore) dispatch('change') } + + if (affectedGroups.length > 0) { + affectedGroupsPending = affectedGroups + affectedGroupsActionLabel = 'move' + affectedGroupsAction = doMove + affectedGroupsCancel = () => moveManager.clearMoving() + } else { + doMove() + } + return } + + // --- INSERT --- + if (detail.isPreprocessor) { + await insertNewPreprocessorModule( + flowStore, + flowStateStore, + detail.inlineScript, + detail.script + ) + selectionManager.selectId('preprocessor') + if (detail.inlineScript?.instructions) { + dispatch('generateStep', { + moduleId: 'preprocessor', + lang: detail.inlineScript?.language, + instructions: detail.inlineScript?.instructions + }) + } + refreshStateStore(flowStore) + dispatch('change') + return + } + + push(history, flowStore.val) + + const isAgentInsert = !!detail.agentId + const toolKind: SpecialToolKind | 'flowmoduleTool' | undefined = isAgentInsert + ? (SPECIAL_TOOL_KINDS as readonly string[]).includes(detail.kind) + ? (detail.kind as SpecialToolKind) + : 'flowmoduleTool' + : undefined + + // Agent tool inserts operate on the FlowModule's tools array directly + if (isAgentInsert) { + const agentMod = getAllModules(flowStore.val.value.modules).find( + (m) => m.id === detail.agentId + ) + if (agentMod && (agentMod.value as any).tools) { + const tools = (agentMod.value as any).tools as AgentTool[] + await insertNewModuleAtIndex( + tools, + tools.length, + detail.kind as InsertKind, + detail.script, + detail.flow ? { path: detail.flow.path, summary: detail.flow.summary } : undefined, + detail.inlineScript, + toolKind + ) + const id = tools[tools.length - 1].id + selectionManager.selectId(id) + } + refreshStateStore(flowStore) + dispatch('change') + return + } + + // Regular module insert: create the module, then insert a leaf node via tree mutation + const module = await createNewModule( + detail.kind as InsertKind, + detail.script, + detail.flow ? { path: detail.flow.path, summary: detail.flow.summary } : undefined, + detail.inlineScript + ) + const index = detail.index ?? 0 + const extraModules: FlowModule[] = [module] + + // For trigger inserts, also create the forloop module + let loopModule: FlowModule | undefined + if (detail.kind == 'trigger') { + loopModule = await createNewModule('forloop') + setExpr(loopModule, `results.${module.id}`) + extraModules.push(loopModule) + } + + proxy.applyTreeMutation( + (tree) => { + // Find target array in the snapshot + let targetArray: FlowStructureNode[] | undefined + if ( + detail.sourceId == 'Input' || + detail.targetId == 'Result' || + detail.kind == 'trigger' + ) { + targetArray = tree + } + dfsStructure(tree, (node, parentArray) => { + if (detail.branch && matchStructureNode(node, detail.branch.rootId)) { + targetArray = node.branches[detail.branch.branch]?.children + } else if ( + matchStructureNode(node, detail.sourceId ?? '') || + matchStructureNode(node, detail.targetId ?? '') + ) { + targetArray = parentArray + } + }) + if (!targetArray) targetArray = tree + + // Insert the structure node (correct kind for containers like branchone/branchall) + targetArray.splice(index, 0, moduleToStructureNode(module)) + + // For trigger: also insert the forloop node after it + if (loopModule) { + targetArray.splice(index + 1, 0, moduleToStructureNode(loopModule)) + } + }, + { extraModules, displayState: groupDisplayState } + ) + + selectionManager.selectId(module.id) + + if (detail.inlineScript?.instructions) { + dispatch('generateStep', { + moduleId: module.id, + lang: detail.inlineScript?.language, + instructions: detail.inlineScript?.instructions + }) + } + if (detail.kind == 'trigger') { + setScheduledPollSchedule(triggersState, triggersCount) + } + if (detail.flow?.path) { + loadLastJob(detail.flow.path, module.id) + } else if (detail.script?.path) { + loadLastJob(detail.script?.path, module.id) + } + + if (['branchone', 'branchall'].includes(detail.kind)) { + await addBranch(module.id) + } + refreshStateStore(flowStore) + dispatch('change') }} onNewBranch={async (id) => { if (id) { @@ -765,6 +975,17 @@ mod.id = newId } }) + const groups = flowStore.val.value.groups + if (groups) { + for (const group of groups) { + if (group.start_id === id) { + group.start_id = newId + } + if (group.end_id === id) { + group.end_id = newId + } + } + } flowStateStore.val[newId] = flowStateStore.val[id] delete flowStateStore.val[id] refreshStateStore(flowStore) diff --git a/frontend/src/lib/components/flows/map/VirtualItemWrapper.svelte b/frontend/src/lib/components/flows/map/VirtualItemWrapper.svelte index 798116b47d..49cbb56cc6 100644 --- a/frontend/src/lib/components/flows/map/VirtualItemWrapper.svelte +++ b/frontend/src/lib/components/flows/map/VirtualItemWrapper.svelte @@ -46,7 +46,7 @@
= 1) { + sendUserToast('Multiple repositories requires Enterprise Edition', true) + return + } repositories.push({ git_repo_resource_path: '', script_path: undefined, @@ -669,6 +676,10 @@ export function createGitSyncContext(workspace: string) { } function addPromotionRepository() { + if (!get(enterpriseLicense)) { + sendUserToast('Promotion mode requires Enterprise Edition', true) + return + } repositories.push({ git_repo_resource_path: '', script_path: undefined, diff --git a/frontend/src/lib/components/git_sync/GitSyncSection.svelte b/frontend/src/lib/components/git_sync/GitSyncSection.svelte index 893e782b19..8041c734da 100644 --- a/frontend/src/lib/components/git_sync/GitSyncSection.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncSection.svelte @@ -6,12 +6,46 @@ import GitSyncRepositoryCard from './GitSyncRepositoryCard.svelte' import GitSyncModalManager from './GitSyncModalManager.svelte' import { enterpriseLicense, workspaceStore } from '$lib/stores' + import { WorkspaceService } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { untrack } from 'svelte' // Create context reactively based on workspaceStore const gitSyncContext = $derived($workspaceStore ? setGitSyncContext($workspaceStore) : null) + // Fetch git sync eligibility + let gitSyncStatus = $state<{ + enabled: boolean + reason: string | null + max_repos: number | null + user_count: number | null + max_users: number | null + }>({ enabled: false, reason: null, max_repos: null, user_count: null, max_users: null }) + + $effect(() => { + if ($workspaceStore) { + WorkspaceService.getGitSyncEnabled({ workspace: $workspaceStore }) + .then((status) => { + gitSyncStatus = status as typeof gitSyncStatus + }) + .catch(() => { + gitSyncStatus = { + enabled: false, + reason: null, + max_repos: null, + user_count: null, + max_users: null + } + }) + } + }) + + const gitSyncAllowed = $derived(gitSyncStatus.enabled) + const isFreeTier = $derived(gitSyncAllowed && !$enterpriseLicense) + const hasConfiguredRepos = $derived( + gitSyncContext?.repositories?.some((r) => r.git_repo_resource_path) ?? false + ) + // Load settings when workspace context changes $effect(() => { if (gitSyncContext) { @@ -58,7 +92,7 @@ link="https://www.windmill.dev/docs/advanced/git_sync" > {#snippet actions()} - {#if $enterpriseLicense && gitSyncContext.repositories != undefined} + {#if (gitSyncAllowed || gitSyncStatus.user_count != null) && gitSyncContext?.repositories != undefined} - - {#if secondarySyncExpanded} -
- {#if secondarySync.length === 0} -
- No secondary sync repositories configured -
- {:else} - {#each secondarySync as { repo, idx } (repo.git_repo_resource_path)} -
- -
- {/each} - {/if} - - {#if !hasUnsavedSecondary} -
- -
- {/if} -
- {/if} -
- {:else} - - {#if !hasUnsavedSecondary} -
- -
- {/if} - {/if} - {/if} - - -
- gitSyncContext.addPromotionRepository()} - isCollapsible={false} - showEmptyState={primaryPromotion?.repo === null} - /> - - - {#if primaryPromotion && !primaryPromotion.repo?.isUnsavedConnection} - {#if secondaryPromotion.length > 0 || secondaryPromotionExpanded} + {#if $enterpriseLicense} + + {#if primarySync && !primarySync.repo?.isUnsavedConnection} + {#if secondarySync.length > 0 || secondarySyncExpanded}
- {#if secondaryPromotionExpanded} + {#if secondarySyncExpanded}
- {#if secondaryPromotion.length === 0} + {#if secondarySync.length === 0}
- No secondary promotion repositories configured + No secondary sync repositories configured
{:else} - {#each secondaryPromotion as { repo, idx } (repo.git_repo_resource_path)} + {#each secondarySync as { repo, idx } (repo.git_repo_resource_path)}
{/each} {/if} - {#if !hasUnsavedSecondaryPromotion} + {#if !hasUnsavedSecondary}
{/if} @@ -216,23 +187,99 @@ {/if}
{:else} - - {#if !hasUnsavedSecondaryPromotion} + + {#if !hasUnsavedSecondary}
{/if} {/if} {/if} -
+ + +
+ gitSyncContext.addPromotionRepository()} + isCollapsible={false} + showEmptyState={primaryPromotion?.repo === null} + /> + + + {#if primaryPromotion && !primaryPromotion.repo?.isUnsavedConnection} + {#if secondaryPromotion.length > 0 || secondaryPromotionExpanded} +
+ + + {#if secondaryPromotionExpanded} +
+ {#if secondaryPromotion.length === 0} +
+ No secondary promotion repositories configured +
+ {:else} + {#each secondaryPromotion as { repo, idx } (repo.git_repo_resource_path)} +
+ +
+ {/each} + {/if} + + {#if !hasUnsavedSecondaryPromotion} +
+ +
+ {/if} +
+ {/if} +
+ {:else} + + {#if !hasUnsavedSecondaryPromotion} +
+ +
+ {/if} + {/if} + {/if} +
+ {/if}
diff --git a/frontend/src/lib/components/graph/DragGhost.svelte b/frontend/src/lib/components/graph/DragGhost.svelte index 0aefc5c9e8..6cfe4c327e 100644 --- a/frontend/src/lib/components/graph/DragGhost.svelte +++ b/frontend/src/lib/components/graph/DragGhost.svelte @@ -42,7 +42,12 @@ return { x: n.position.x, y: n.position.y } } - function computeGhost(moduleId: string, draggedNodeIds: Set, allNodes: Node[], allEdges: Edge[]) { + function computeGhost( + moduleId: string, + draggedNodeIds: Set, + allNodes: Node[], + allEdges: Edge[] + ) { // Use pre-computed draggedNodeIds when available (covers multi-select), // otherwise fall back to single-module subflow computation. let sfNodes: Node[] @@ -111,7 +116,15 @@ zoom: scale } - return { containerWidth, containerHeight, ghostNodes, ghostEdges, offsetX, offsetY, initialViewport } + return { + containerWidth, + containerHeight, + ghostNodes, + ghostEdges, + offsetX, + offsetY, + initialViewport + } } let isNearDrop = $derived(moveManager.nearestDropZone != null) @@ -128,7 +141,8 @@ class="fixed pointer-events-none z-[10001] flex items-center justify-center w-5 h-5 rounded-full shadow border border-border transition-colors duration-150 {isNearDrop ? 'bg-surface-accent-primary text-white' : 'bg-surface text-secondary'}" - style="left: {moveManager.ghostScreenX + CURSOR_INDICATOR_OFFSET}px; top: {moveManager.ghostScreenY + CURSOR_INDICATOR_OFFSET}px;" + style="left: {moveManager.ghostScreenX + + CURSOR_INDICATOR_OFFSET}px; top: {moveManager.ghostScreenY + CURSOR_INDICATOR_OFFSET}px;" >
diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index daa1fe94fc..3434420fdb 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -59,8 +59,22 @@ import AiToolNode, { computeAIToolNodes } from './renderers/nodes/AIToolNode.svelte' import NewAiToolNode from './renderers/nodes/NewAIToolNode.svelte' import NoteNode from './renderers/nodes/NoteNode.svelte' + import CollapsedGroupNode from './renderers/nodes/CollapsedGroupNode.svelte' + import GroupHeadNode from './renderers/nodes/GroupHeadNode.svelte' + import GroupEndNode from './renderers/nodes/GroupEndNode.svelte' import NoteTool from './NoteTool.svelte' import SelectionBoundingBox from './SelectionBoundingBox.svelte' + import GroupOverlay from './GroupOverlay.svelte' + import { + GroupDisplayState, + getGroupEditorContext, + groupKey, + type FlowGroup + } from './groupEditor.svelte' + import { buildStructureTree, computeGroupDepths, type FlowStructureNode } from './flowStructure' + import { stateSnapshot } from '$lib/svelte5Utils.svelte' + import { computeGroupModuleIds } from './groupDetectionUtils' + import { getAllModules } from '../flows/flowExplorer' import SelectionTool from './SelectionTool.svelte' import PaneContextMenu from './PaneContextMenu.svelte' import { SelectionManager } from './selectionUtils.svelte' @@ -72,6 +86,7 @@ import { compoundLayout } from './compoundLayout' import { deepEqual } from 'fast-equals' import type { AssetWithAltAccessType } from '../assets/lib' + import { computeNodeExtraSpace } from './nodeExtraSpace' import type { ModuleActionInfo } from '$lib/components/flows/flowDiff' import { setGraphContext } from './graphContext' import { computeNoteNodes } from './noteUtils.svelte' @@ -100,6 +115,8 @@ interface Props { success?: boolean | undefined modules?: FlowModule[] | undefined + groupedModules?: FlowStructureNode[] + groupError?: unknown failureModule?: FlowModule | undefined preprocessorModule?: FlowModule | undefined minHeight?: number @@ -124,7 +141,7 @@ workspace?: string editMode?: boolean allowSimplifiedPoll?: boolean - expandedSubflows?: Record + expandedSubflows?: Record isOwner?: boolean isRunning?: boolean individualStepTests?: boolean @@ -133,6 +150,8 @@ suspendStatus?: Record noteMode?: boolean notes?: FlowNote[] + groups?: FlowGroup[] + groupDisplayState?: GroupDisplayState chatInputEnabled?: boolean multiSelectEnabled?: boolean onDeleteMultiple?: (ids: string[]) => void @@ -152,6 +171,7 @@ script?: { path: string; summary: string; hash: string | undefined } flow?: { path: string; summary: string } kind: InsertKind + expandGroup?: { groupId: string; position: 'top' | 'bottom' } }) => Promise onNewBranch?: (id: string) => Promise onSelect?: (id: string | FlowModule) => void @@ -194,6 +214,8 @@ onSelectedIteration = undefined, success = undefined, modules = [], + groupedModules: groupedModulesProp = undefined, + groupError = undefined, failureModule = undefined, preprocessorModule = undefined, minHeight = 0, @@ -233,6 +255,8 @@ flowHasChanged = false, noteMode = false, notes = undefined, + groups = undefined, + groupDisplayState: groupDisplayStateProp = undefined, exitNoteMode = undefined, onNotePositionUpdate = undefined, chatInputEnabled = false, @@ -259,6 +283,9 @@ () => nodes ) + const groupDisplayState = + untrack(() => groupDisplayStateProp) ?? new GroupDisplayState(() => groups ?? []) + // Runtime text height tracking for notes (not stored in FlowNote) let noteTextHeights = $state>({}) @@ -266,6 +293,8 @@ let paneContextMenu: PaneContextMenu | undefined = $state(undefined) let flowContainer: HTMLDivElement | undefined = $state(undefined) + // Hover tracking for group overlay + // Selection manager - create one if not provided let selectionManager = untrack(() => selectionManagerProp) || new SelectionManager() const selectedId = $derived(selectionManager.getSelectedId()) @@ -300,7 +329,9 @@ moveManager: untrack(() => moveManager), clearFlowSelection, yOffset, - diffManager + diffManager, + getFlowNodes: () => currentGraphNodeDeps, + groupDisplayState } as any) if (triggerContext && untrack(() => allowSimplifiedPoll)) { @@ -334,14 +365,36 @@ type NodeDep = { id: string parentIds?: string[] - data?: { assets?: AssetWithAltAccessType[] } + data?: { assets?: AssetWithAltAccessType[]; module?: any } } type NodePos = { position: { x: number; y: number } } - let lastNodes: [NodeDep[], (NodeDep & NodePos)[]] | undefined = undefined + let lastNodes: + | [NodeDep[], Map | undefined, (NodeDep & NodePos)[]] + | undefined = undefined + let currentGraphNodeDeps: { id: string; parentIds?: string[] }[] = $state([]) - function layoutNodes(nodes: NodeDep[]): (NodeDep & NodePos)[] { - let lastResult = lastNodes?.[1] - if (lastResult && deepEqual(nodes, lastNodes?.[0])) { + // Keep canCreateGroup in sync for consumers (SelectionBoundingBox, FlowSelectionPanel, etc.) + const groupEditorCtx = getGroupEditorContext() + + $effect(() => { + if (!groupEditorCtx) return + const ids = selectionManager.selectedIds + groupEditorCtx.canCreateGroup.val = + ids.length >= 1 && groupEditorCtx.groupEditor.canCreateGroup(ids, currentGraphNodeDeps) + }) + + let lastGroupDimensions: Map | undefined = undefined + + function layoutNodes( + nodes: NodeDep[], + nodeExtraSpace?: Map + ): (NodeDep & NodePos)[] { + let lastResult = lastNodes?.[2] + if ( + lastResult && + deepEqual(nodes, lastNodes?.[0]) && + deepEqual(nodeExtraSpace, lastNodes?.[1]) + ) { console.debug('layoutNodes', 'same nodes') return lastResult } @@ -354,16 +407,23 @@ seenId.push(n.id) } - // Run recursive compound layout - const { positions, bbox } = compoundLayout(nodes, { - nodeWidth: NODE.width, - nodeHeight: NODE.height, - gapH: NODE.gap.horizontal, - gapV: NODE.gap.vertical - }) + // Run recursive compound layout with pre-computed extra space + const layoutResult = compoundLayout( + nodes, + { + nodeWidth: NODE.width, + nodeHeight: NODE.height, + gapH: NODE.gap.horizontal, + gapV: NODE.gap.vertical + }, + nodeExtraSpace + ) + const { positions, bbox } = layoutResult + lastGroupDimensions = layoutResult.groupDimensions + + const xCenter = (fullSize ? fullWidth : width) / 2 - bbox.width / 2 - (width - fullWidth) / 2 // Center horizontally - const xCenter = (fullSize ? fullWidth : width) / 2 - bbox.width / 2 - (width - fullWidth) / 2 const newNodes = nodes.map((n) => ({ id: n.id, position: { @@ -372,7 +432,7 @@ } })) - lastNodes = [nodes, newNodes] + lastNodes = [nodes, nodeExtraSpace, newNodes] return newNodes } @@ -416,13 +476,16 @@ }, expandSubflow: async (id: string, path: string) => { const flow = await FlowService.getFlowByPath({ workspace: workspace, path }) - expandedSubflows[id] = flow.value.modules + expandedSubflows[id] = { modules: flow.value.modules, groups: flow.value.groups } expandedSubflows = expandedSubflows }, minimizeSubflow: (id: string) => { delete expandedSubflows[id] expandedSubflows = expandedSubflows }, + expandGroup: (groupId: string) => { + groupDisplayState.expandGroup(groupId) + }, updateMock: (detail) => { onUpdateMock?.(detail) }, @@ -587,17 +650,37 @@ return } - // console.log('compute') + const graphNodeDeps = Object.values(graph.nodes).map((n) => ({ + id: n.id, + parentIds: n.parentIds, + data: { assets: (n.data as any).assets, module: (n.data as any).module } + })) + currentGraphNodeDeps = graphNodeDeps - let layoutedNodes = layoutNodes( - Object.values(graph.nodes).map((n) => ({ - id: n.id, - parentIds: n.parentIds, - data: { assets: (n.data as any).assets } - })) - ) - let newNodes: (Node & NodeLayout)[] = layoutedNodes.map((n) => ({ ...n, ...graph.nodes[n.id] })) + // Pre-compute extra space per node for assets, AI tools, group notes, group headers + const nodeExtraSpace = computeNodeExtraSpace(graphNodeDeps, { + showAssets: $showAssets ?? true, + showNotes, + notes, + noteTextHeights, + groupDisplayState, + insertable, + flowModuleStates + }) + // Layout with extra space baked into sugiyama + let layoutedNodes = layoutNodes(graphNodeDeps, nodeExtraSpace) + let newNodes: (Node & NodeLayout)[] = layoutedNodes.map((n) => { + const merged = { ...n, ...graph.nodes[n.id] } + // Augment group head nodes with wrapper dimensions from compound layout + if (graph.nodes[n.id]?.type === 'groupHead' && lastGroupDimensions?.has(n.id)) { + const dims = lastGroupDimensions.get(n.id)! + merged.data = { ...merged.data, wrapperWidth: dims.width, wrapperHeight: dims.height } + } + return merged + }) + + // Compute asset visual nodes (no position remapping) let assetNodesResult = $showAssets ? computeAssetNodes( newNodes.map((n) => ({ @@ -607,25 +690,17 @@ })) ) : undefined - if (assetNodesResult) { - newNodes = newNodes.map((n) => ({ - ...n, - position: assetNodesResult.newNodePositions[n.id] - })) - } - let aiToolNodesResult = computeAIToolNodes(newNodes, eventHandler, insertable, flowModuleStates) - let nodesAfterAITools = newNodes.map((n) => ({ - ...n, - position: aiToolNodesResult.newNodePositions[n.id] - })) - let finalNodes = [ - ...nodesAfterAITools, + // Compute AI tool visual nodes (no position remapping) + let aiToolNodesResult = computeAIToolNodes(newNodes, eventHandler, insertable, flowModuleStates) + + let finalNodes: (Node & NodeLayout)[] = [ + ...newNodes, ...(assetNodesResult?.newAssetNodes ?? []), ...aiToolNodesResult.toolNodes ] - // Compute note nodes and positions + // Compute note nodes (no position remapping) let noteNodesResult = showNotes ? computeNoteNodes( finalNodes.map((n) => ({ @@ -646,14 +721,6 @@ ) : undefined - // Apply note positioning to nodes if notes are enabled - if (noteNodesResult) { - finalNodes = finalNodes.map((n) => ({ - ...n, - position: noteNodesResult.newNodePositions[n.id] || n.position - })) - } - // update nodes nodes = [...finalNodes, ...(noteNodesResult?.noteNodes ?? [])] @@ -702,7 +769,10 @@ assetsOverflowed: AssetsOverflowedNode, aiTool: AiToolNode, newAiTool: NewAiToolNode, - note: NoteNode + note: NoteNode, + collapsedGroup: CollapsedGroupNode, + groupHead: GroupHeadNode, + groupEnd: GroupEndNode } as any const edgeTypes = { @@ -738,7 +808,41 @@ let graph = $derived.by(() => { moduleTracker.counter effectiveModuleActions - return graphBuilder( + currentGroups + + const collapsedGroupIds = new Set( + allGroups + .filter((g) => groupDisplayState.isRuntimeCollapsed(groupKey(g))) + .map((g) => groupKey(g)) + ) + + if (groupError) { + return { nodes: {}, edges: [], error: groupError } + } + + // Use provided structure tree (from proxy) or build locally (diff mode / read-only) + let gm: FlowStructureNode[] | undefined = groupedModulesProp + if (!gm) { + const allGroups = groups ?? [] + const graphGroups = allGroups.map((g) => ({ + ...g, + id: groupKey(g), + moduleIds: untrack(() => + computeGroupModuleIds(g.start_id, g.end_id, getAllModules(effectiveModules ?? [])) + ) + })) + try { + gm = buildStructureTree( + stateSnapshot(untrack(() => effectiveModules) ?? []) as FlowModule[], + graphGroups + ) + } catch (e) { + return { nodes: {}, edges: [], error: e } + } + } + + const result = graphBuilder( + gm, untrack(() => effectiveModules), { disableAi, @@ -770,16 +874,43 @@ untrack(() => selectedId), simplifiableFlow, triggerNode ? path : undefined, - expandedSubflows + expandedSubflows, + showNotes, + collapsedGroupIds ) + return { ...result, structureTree: gm } }) let hideAssetsToggle = $derived( $showAssets && Object.values(nodes).every((n) => n.type !== 'asset') ) - let hideNotesToggle = $derived(!notes || notes.length === 0) + let hideNotesToggle = $derived( + (!notes || notes.length === 0) && !(groups ?? []).some((g) => g.note != null) + ) + + let currentGroupDepths = $derived( + 'structureTree' in graph && graph.structureTree ? computeGroupDepths(graph.structureTree) : {} + ) + + // All groups including those from expanded subflows (for overlay rendering) + let allGroups = $derived.by(() => { + const base = groups ?? [] + const subflowGroups = Object.values(expandedSubflows).flatMap((sf) => sf.groups ?? []) + return subflowGroups.length > 0 ? [...base, ...subflowGroups] : base + }) + + // Track groups for re-layout when groups change + let currentGroups = $derived(groups ?? []) $effect(() => { - ;[graph, allowSimplifiedPoll, $showAssets, showNotes, noteManager.renderCount] + ;[ + graph, + allowSimplifiedPoll, + $showAssets, + showNotes, + noteManager.renderCount, + currentGroups, + groupDisplayState.renderCount + ] untrack(async () => { await updateStores() }) @@ -896,6 +1027,16 @@ } } + export function createGroupFromSelection(ids: string[]) { + if (groupEditorCtx?.groupEditor) { + groupEditorCtx.groupEditor.createGroup(ids, currentGraphNodeDeps) + tick().then(() => { + clearFlowSelection() + selectionManager.clearSelection() + }) + } + } + const modifierKey = isMac() ? 'Meta' : 'Control' @@ -912,7 +1053,7 @@ bind:this={flowContainer} > {#if graph?.error} -
+
{graph.error} @@ -1011,6 +1152,12 @@ /> {/if} + + @@ -1068,7 +1215,7 @@ try { localStorage.setItem( 'svelvet', - encodeState({ modules, failureModule, preprocessorModule, notes }) + encodeState({ modules, failureModule, preprocessorModule, notes, groups }) ) } catch (e) { console.error('error interacting with local storage', e) diff --git a/frontend/src/lib/components/graph/GroupActionBar.svelte b/frontend/src/lib/components/graph/GroupActionBar.svelte new file mode 100644 index 0000000000..1ae30395b5 --- /dev/null +++ b/frontend/src/lib/components/graph/GroupActionBar.svelte @@ -0,0 +1,158 @@ + + +
+ {#if moveManager && moveModuleId} + moveManager.toggleMoving(moveModuleId!)} + /> + {/if} + {#if note == null} + + {/if} + + {#snippet buttonReplacement()} + + {/snippet} + {#snippet menu()} +
+ +
+
+ {#each Object.values(NoteColor) as c (c)} + + {/each} +
+
+ + +
+ onUpdateAutocollapse(e.detail)} + /> +
+ +
+ + + + + {#if onDeleteGroup} +
+ + + + {/if} +
+ {/snippet} +
+
diff --git a/frontend/src/lib/components/graph/GroupHeader.svelte b/frontend/src/lib/components/graph/GroupHeader.svelte new file mode 100644 index 0000000000..474d71c1a4 --- /dev/null +++ b/frontend/src/lib/components/graph/GroupHeader.svelte @@ -0,0 +1,116 @@ + + + + +
{}))} + title={collapsed ? 'Expand group' : 'Collapse group'} +> +
+ +
+
+ {#if editingSummary} +
+ +
+ {:else} + {})) : undefined} + >{summary || PLACEHOLDER} + {/if} +
+
+ + diff --git a/frontend/src/lib/components/graph/GroupHeaderBlock.svelte b/frontend/src/lib/components/graph/GroupHeaderBlock.svelte new file mode 100644 index 0000000000..089c0642a4 --- /dev/null +++ b/frontend/src/lib/components/graph/GroupHeaderBlock.svelte @@ -0,0 +1,76 @@ + + + +
(hovered = true)} + onmouseleave={() => (hovered = false)} +> + graphContext?.groupDisplayState?.toggleRuntimeCollapse(groupId)} + onSummaryUpdate={(text) => groupEditorContext?.groupEditor.updateSummary(groupId, text)} + /> + {#if showNotes && note != null} + graphContext?.groupDisplayState?.setNoteHeight(groupId, h)} + onNoteUpdate={(text) => groupEditorContext?.groupEditor.updateNote(groupId, text)} + /> + {/if} + {#if editMode} + (menuOpen = open)} + onAddNote={() => groupEditorContext?.groupEditor.addNote(groupId)} + onRemoveNote={() => groupEditorContext?.groupEditor.removeNote(groupId)} + onUpdateColor={(c) => groupEditorContext?.groupEditor.updateColor(groupId, c)} + onUpdateAutocollapse={(v) => groupEditorContext?.groupEditor.updateAutocollapse(groupId, v)} + onDeleteGroup={() => groupEditorContext?.groupEditor.deleteGroup(groupId)} + /> + {/if} +
diff --git a/frontend/src/lib/components/graph/GroupModuleIcons.svelte b/frontend/src/lib/components/graph/GroupModuleIcons.svelte new file mode 100644 index 0000000000..3df1aecfaa --- /dev/null +++ b/frontend/src/lib/components/graph/GroupModuleIcons.svelte @@ -0,0 +1,182 @@ + + +
+ {#each displayModules as mod (mod.id)} + {@const selected = selectionManager.isNodeSelected(mod.id)} + {@const nodeState = flowModuleStates?.[mod.id]?.type} + {@const colorClasses = getNodeColorClasses(nodeState, selected)} + + {#snippet children()} + + +
selectModule(mod)} + > +
+ +
+ {mod.id} +
+ {/snippet} + {#snippet text()} + {mod.id}: {moduleLabel(mod)} + {/snippet} +
+ {/each} + {#if overflowModules.length > 0} + {@const overflowColorClasses = getNodeColorClasses(overflowAggregateState, false)} + + {#snippet buttonReplacement()} +
+ +{overflowModules.length} +
+ {/snippet} + {#snippet menu()} +
+ {#each overflowModules as mod (mod.id)} + {@const nodeState = flowModuleStates?.[mod.id]?.type} + {@const colorClasses = getNodeColorClasses(nodeState, false)} + {@const selected = selectionManager.isNodeSelected(mod.id)} + + +
selectModule(mod)} + > +
+ +
+ {moduleLabel(mod)} + {mod.id} +
+ {/each} +
+ {/snippet} +
+ {/if} +
diff --git a/frontend/src/lib/components/graph/GroupNodeCard.svelte b/frontend/src/lib/components/graph/GroupNodeCard.svelte new file mode 100644 index 0000000000..dd69fcf0d0 --- /dev/null +++ b/frontend/src/lib/components/graph/GroupNodeCard.svelte @@ -0,0 +1,165 @@ + + +
+
+
+ {#if modules && modules.length > 0} + + {:else} + + {/if} +
+ {#if editingSummary} + + {:else} + + + {})) : undefined} + >{summary || 'Group'} + {/if} +
+
+ {#if stepCount != null} + + + {stepCount} node{stepCount !== 1 ? 's' : ''} + {/if} +
+ + {#if showNote} +
+ onHeightChange?.(h)} + onNoteUpdate={(text) => onNoteUpdate?.(text)} + /> +
+ {/if} +
diff --git a/frontend/src/lib/components/graph/GroupNoteArea.svelte b/frontend/src/lib/components/graph/GroupNoteArea.svelte new file mode 100644 index 0000000000..1b4562e8d0 --- /dev/null +++ b/frontend/src/lib/components/graph/GroupNoteArea.svelte @@ -0,0 +1,151 @@ + + + +
+
+ {#if editing} +
+ + +
+ + {:else if note} + +
{}) : undefined} + > + +
+ {:else} + +
{}) : undefined} + > + Double click to edit the note +
+ {/if} +
+
diff --git a/frontend/src/lib/components/graph/GroupOverlay.svelte b/frontend/src/lib/components/graph/GroupOverlay.svelte new file mode 100644 index 0000000000..c15095bfb4 --- /dev/null +++ b/frontend/src/lib/components/graph/GroupOverlay.svelte @@ -0,0 +1,90 @@ + + +{#each groups as group (groupKey(group))} + {@const bounds = groupBoundsMap[groupKey(group)]} + {#if bounds} + +
+
+ {/if} +{/each} diff --git a/frontend/src/lib/components/graph/MiniFlowGraph.svelte b/frontend/src/lib/components/graph/MiniFlowGraph.svelte index fdb73745df..ab0c5042e8 100644 --- a/frontend/src/lib/components/graph/MiniFlowGraph.svelte +++ b/frontend/src/lib/components/graph/MiniFlowGraph.svelte @@ -1,6 +1,12 @@ - -{#if noteEditorContext?.noteEditor && selectedNodeIds.length > 1} - - {@render children()} - -{/if} diff --git a/frontend/src/lib/components/graph/NoteColorPicker.svelte b/frontend/src/lib/components/graph/NoteColorPicker.svelte index d17adb9317..ff3a1a276c 100644 --- a/frontend/src/lib/components/graph/NoteColorPicker.svelte +++ b/frontend/src/lib/components/graph/NoteColorPicker.svelte @@ -10,7 +10,11 @@ isOpen?: boolean } - let { selectedColor, onColorChange, isOpen = $bindable(false) }: Props = $props() + let { + selectedColor, + onColorChange, + isOpen = $bindable(false) + }: Props = $props() import { ViewportPortal, type Node } from '@xyflow/svelte' import { calculateNodesBoundsWithOffset } from './util' - import { StickyNote, Move, Copy, Trash2, EllipsisVertical } from 'lucide-svelte' + import { Move, Copy, Trash2, EllipsisVertical, Group } from 'lucide-svelte' import { Button } from '../common' import DropdownV2 from '../DropdownV2.svelte' - import { getNoteEditorContext } from './noteEditor.svelte' + import { getGroupEditorContext } from './groupEditor.svelte' import { getGraphContext } from './graphContext' import MoveHandleButton from './MoveHandleButton.svelte' import { tick } from 'svelte' @@ -36,18 +36,19 @@ let resolvedCount = $derived(resolvedModuleIds.length) - // Get NoteEditor context for group note creation - const noteEditorContext = getNoteEditorContext() + // Get GroupEditor context for group creation + const groupEditorContext = getGroupEditorContext() // Get Graph context for clearFlowSelection function and moveManager const graphContext = getGraphContext() const moveManager = graphContext?.moveManager - function handleAddGroupNote() { - if (selectedNodes.length > 0 && noteEditorContext?.noteEditor && graphContext) { - // Create the group note first - noteEditorContext.noteEditor.createGroupNote(selectedNodes) + let canCreateGroup = $derived(groupEditorContext?.canCreateGroup.val ?? false) + + function handleAddGroup() { + if (selectedNodes.length > 0 && groupEditorContext?.groupEditor && graphContext) { + const flowNodes = graphContext.getFlowNodes?.() ?? [] + groupEditorContext.groupEditor.createGroup(selectedNodes, flowNodes) - // Wait for next tick to ensure DOM updates tick().then(() => { graphContext?.clearFlowSelection?.() graphContext?.selectionManager.clearSelection() @@ -74,13 +75,13 @@ shortcut: isMac() ? '⌫' : 'Del', action: () => onDeleteSelected?.() }, - ...(noteEditorContext?.noteEditor + ...(groupEditorContext?.groupEditor ? [ { - displayName: 'Add note', - icon: StickyNote, - separatorTop: true, - action: handleAddGroupNote + displayName: 'Create group', + icon: Group, + action: handleAddGroup, + disabled: !canCreateGroup } ] : []) diff --git a/frontend/src/lib/components/graph/compoundLayout.ts b/frontend/src/lib/components/graph/compoundLayout.ts index 4bf3ddebda..c47aa62584 100644 --- a/frontend/src/lib/components/graph/compoundLayout.ts +++ b/frontend/src/lib/components/graph/compoundLayout.ts @@ -1,5 +1,6 @@ import { sugiyama, dagStratify, coordCenter, decrossTwoLayer, decrossOpt } from 'd3-dag' import { NODE } from './util' +import { GROUP_HEADER_HEIGHT } from './groupEditor.svelte' type LayoutNode = { id: string @@ -14,7 +15,7 @@ type LayoutConstants = { } type CompoundGroup = { - type: 'branch' | 'loop' + type: 'branch' | 'loop' | 'group' headId: string endId: string branches: { @@ -27,9 +28,12 @@ type LayoutResult = { positions: Map bbox: { width: number; height: number } contentMinX: number + groupDimensions?: Map } const LOOP_INDENT = 25 +export const GROUP_PADDING = 16 +export const GROUP_TOP_PADDING = 32 /** * Detect compound groups from a flat list of node IDs. @@ -83,6 +87,18 @@ function detectGroups( endId: id, branches: [{ labelId: `${baseId}-start`, innerIds }] }) + } else if (baseId.startsWith('group:')) { + // Group pattern: group:{groupId} head + group:{groupId}-end + // Body is everything reachable from head to end + const innerIds = findInnerIds(baseId, id, nodeIds, childrenMap) + if (innerIds.length > 0) { + groups.push({ + type: 'group', + headId: baseId, + endId: id, + branches: [{ labelId: innerIds[0], innerIds: innerIds.slice(1) }] + }) + } } } @@ -230,6 +246,29 @@ function runSugiyama( * 5. Run sugiyama on the simplified graph * 6. Expand wrapper positions back to absolute positions */ +/** + * Build nodeSizes map for sugiyama from nodeExtraSpace. + * Each node's effective height = top + NODE.height + bottom. + */ +function buildNodeSizes( + nodeIds: string[], + constants: LayoutConstants, + nodeExtraSpace?: Map +): Map | undefined { + if (!nodeExtraSpace || nodeExtraSpace.size === 0) return undefined + const sizes = new Map() + for (const id of nodeIds) { + const extra = nodeExtraSpace.get(id) + if (extra && (extra.top > 0 || extra.bottom > 0 || extra.left > 0 || extra.right > 0)) { + sizes.set(id, { + width: constants.nodeWidth + extra.left + extra.right, + height: constants.nodeHeight + extra.top + extra.bottom + }) + } + } + return sizes.size > 0 ? sizes : undefined +} + const MAX_RECURSION_DEPTH = 50 function layoutLevel( @@ -237,7 +276,8 @@ function layoutLevel( allNodes: Map, constants: LayoutConstants, childrenMap: Map, - depth: number = 0 + depth: number = 0, + nodeExtraSpace?: Map ): LayoutResult { const positions = new Map() const nodeIdSet = new Set(nodeIds) @@ -256,8 +296,15 @@ function layoutLevel( const n = allNodes.get(id)! return { id, parentIds: (n.parentIds ?? []).filter((pid) => nodeIdSet.has(pid)) } }) - const result = runSugiyama(flatNodes, constants) + const extraSizes = buildNodeSizes( + flatNodes.map((n) => n.id), + constants, + nodeExtraSpace + ) + const result = runSugiyama(flatNodes, constants, extraSizes) for (const [id, pos] of result.positions) { + const extra = nodeExtraSpace?.get(id) + if (extra) pos.y += extra.top positions.set(id, pos) } return { positions, bbox: { width: result.width, height: result.height }, contentMinX: 0 } @@ -322,7 +369,14 @@ function layoutLevel( const branchNodeIds = [branch.labelId, ...branch.innerIds] // Find sub-groups within this branch - const result = layoutLevel(branchNodeIds, allNodes, constants, childrenMap, depth + 1) + const result = layoutLevel( + branchNodeIds, + allNodes, + constants, + childrenMap, + depth + 1, + nodeExtraSpace + ) branchLayouts.push({ labelId: branch.labelId, @@ -349,6 +403,16 @@ function layoutLevel( maxBranchHeight = Math.max(0, ...branchLayouts.map((bl) => bl.bbox.height)) // head row + branch content + end row wrapperHeight = rowHeight + maxBranchHeight + rowHeight + } else if (group.type === 'group') { + // Group: body is centered with padding on all sides + const bodyWidth = branchLayouts[0]?.bbox.width ?? constants.nodeWidth + const bodyHeight = branchLayouts[0]?.bbox.height ?? 0 + wrapperWidth = Math.max(bodyWidth + GROUP_PADDING * 2, constants.nodeWidth) + maxBranchHeight = bodyHeight + const headExtra = nodeExtraSpace?.get(group.headId) + const groupHeadRow = GROUP_HEADER_HEIGHT + (headExtra?.bottom ?? 0) + GROUP_TOP_PADDING + // head row + body + bottom padding + wrapperHeight = groupHeadRow + bodyHeight + GROUP_PADDING } else { // Loop: body is indented const bodyWidth = branchLayouts[0]?.bbox.width ?? constants.nodeWidth @@ -395,13 +459,30 @@ function layoutLevel( } // Step 5: Run sugiyama on flattened nodes - const sugResult = runSugiyama(flatNodes, constants, wrapperSizes) + // Merge wrapperSizes with nodeExtraSpace-derived sizes for non-group nodes + const extraSizes = buildNodeSizes( + flatNodes.map((n) => n.id), + constants, + nodeExtraSpace + ) + const mergedSizes = new Map() + if (extraSizes) { + for (const [id, size] of extraSizes) mergedSizes.set(id, size) + } + for (const [id, size] of wrapperSizes) mergedSizes.set(id, size) + const sugResult = runSugiyama( + flatNodes, + constants, + mergedSizes.size > 0 ? mergedSizes : undefined + ) // Step 6: Resolve absolute positions // First, set positions for regular (non-group) nodes + // Apply per-node y-offset from nodeExtraSpace so decorations above have room for (const [nid, pos] of sugResult.positions) { if (groupByHeadId.has(nid)) continue // Handle groups separately - positions.set(nid, { x: pos.x, y: pos.y }) + const extra = nodeExtraSpace?.get(nid) + positions.set(nid, { x: pos.x, y: pos.y + (extra?.top ?? 0) }) } // Now expand group wrappers into absolute positions @@ -411,9 +492,15 @@ function layoutLevel( const rowHeight = constants.nodeHeight + constants.gapV const isBranch = gl.group.type === 'branch' + const isGroup = gl.group.type === 'group' // Position the head node at the top-center of the wrapper - positions.set(headId, { x: wrapperPos.x, y: wrapperPos.y }) + // Apply extra top padding so decorations above the head node have room + const headExtra = nodeExtraSpace?.get(headId) + positions.set(headId, { + x: wrapperPos.x, + y: wrapperPos.y + (headExtra?.top ?? 0) + }) if (isBranch) { // Reuse cached branchWidths and totalWidth @@ -441,6 +528,26 @@ function layoutLevel( x: wrapperPos.x, y: wrapperPos.y + rowHeight + maxBranchHeight + constants.gapV }) + } else if (isGroup) { + // Group: body is centered within wrapper (no x offset) + const headExtra = nodeExtraSpace?.get(gl.group.headId) + const groupHeadRow = GROUP_HEADER_HEIGHT + (headExtra?.bottom ?? 0) + GROUP_TOP_PADDING + const bl = gl.branchLayouts[0] + if (bl) { + for (const [innerNodeId, innerPos] of bl.result.positions) { + positions.set(innerNodeId, { + x: wrapperPos.x + innerPos.x, + y: wrapperPos.y + groupHeadRow + innerPos.y + }) + } + } + + // Position end node below body + const bodyHeight = bl?.bbox.height ?? 0 + positions.set(gl.group.endId, { + x: wrapperPos.x, + y: wrapperPos.y + groupHeadRow + bodyHeight + GROUP_PADDING + }) } else { // Loop: position start, body, and end const bl = gl.branchLayouts[0] @@ -463,16 +570,34 @@ function layoutLevel( } } + // Collect group dimensions from this level and child layouts + const groupDimensions = new Map() + for (const [headId, gl] of groupLayouts) { + groupDimensions.set(headId, { width: gl.wrapperWidth, height: gl.wrapperHeight }) + // Propagate child groupDimensions from recursive branch layouts + for (const bl of gl.branchLayouts) { + if (bl.result.groupDimensions) { + for (const [childId, dims] of bl.result.groupDimensions) { + groupDimensions.set(childId, dims) + } + } + } + } + // Compute overall bbox (nodes + group wrapper extents) let minX = Infinity let maxX = -Infinity let minY = Infinity let maxY = -Infinity - for (const pos of positions.values()) { - minX = Math.min(minX, pos.x - constants.nodeWidth / 2) - maxX = Math.max(maxX, pos.x + constants.nodeWidth / 2) - minY = Math.min(minY, pos.y) - maxY = Math.max(maxY, pos.y + constants.nodeHeight) + for (const [nid, pos] of positions) { + // Group end nodes are zero-height markers — skip them + if (nid.startsWith('group:') && nid.endsWith('-end')) continue + const extra = nodeExtraSpace?.get(nid) + minX = Math.min(minX, pos.x - constants.nodeWidth / 2 - (extra?.left ?? 0)) + maxX = Math.max(maxX, pos.x + constants.nodeWidth / 2 + (extra?.right ?? 0)) + // Account for top decoration space above the node + minY = Math.min(minY, pos.y - (extra?.top ?? 0)) + maxY = Math.max(maxY, pos.y + constants.nodeHeight + (extra?.bottom ?? 0)) } // Account for group wrapper extents in bbox (e.g. LOOP_INDENT makes wrappers wider than nodes) for (const [headId, gl] of groupLayouts) { @@ -492,7 +617,12 @@ function layoutLevel( width: Math.max(bboxWidth, constants.nodeWidth), height: Math.max(bboxHeight, 0) } - return { positions, bbox: finalBbox, contentMinX } + return { + positions, + bbox: finalBbox, + contentMinX, + groupDimensions: groupDimensions.size > 0 ? groupDimensions : undefined + } } /** @@ -500,10 +630,16 @@ function layoutLevel( * * Takes the flat list of nodes and edges from graphBuilder and produces * absolute positions that account for compound structure (branches, loops). + * + * nodeExtraSpace: per-node top/bottom/left/right padding that should be allocated in layout. + * After layout, each node's y is shifted down by its top padding so decorations + * (assets, AI tools, group headers) have room above. Left/right padding widens the + * column allocated to the node so neighbors are pushed further away. */ export function compoundLayout( nodes: { id: string; parentIds?: string[] }[], - constants?: Partial + constants?: Partial, + nodeExtraSpace?: Map ): LayoutResult { const c: LayoutConstants = { nodeWidth: constants?.nodeWidth ?? NODE.width, @@ -528,7 +664,7 @@ export function compoundLayout( } const nodeIds = nodes.map((n) => n.id) - const result = layoutLevel(nodeIds, allNodes, c, childrenMap) + const result = layoutLevel(nodeIds, allNodes, c, childrenMap, 0, nodeExtraSpace) // Shift positions so minX=0 (left-aligned). // FlowGraphV2 centers with: xCenter = viewport/2 - bbox.width/2 diff --git a/frontend/src/lib/components/graph/flowStructure.test.ts b/frontend/src/lib/components/graph/flowStructure.test.ts new file mode 100644 index 0000000000..fce5071e80 --- /dev/null +++ b/frontend/src/lib/components/graph/flowStructure.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, vi } from 'vitest' + +// Mock modules that transitively import CSS/Monaco +vi.mock('monaco-editor', () => ({})) +vi.mock('@xyflow/svelte', () => ({})) +vi.mock('./renderers/nodes/AssetNode.svelte', () => ({ + assetDisplaysAsOutputInFlowGraph: () => false +})) +vi.mock('../modulesTest.svelte', () => ({})) + +import type { GraphGroup } from './groupEditor.svelte' +import type { FlowModule } from '$lib/gen' +import { + buildStructureTree, + flattenStructureIds, + deriveGroupsFromStructure, + collectLeafIds, + findInStructure +} from './flowStructure' + +function makeModule(id: string): FlowModule { + return { + id, + value: { type: 'rawscript', content: '', language: 'python3' } as any + } as FlowModule +} + +function makeBranchAll(id: string, branchInnerIds: string[][]): FlowModule { + return { + id, + value: { + type: 'branchall', + branches: branchInnerIds.map((ids) => ({ modules: ids.map((iid) => makeModule(iid)) })) + } as any + } as FlowModule +} + +function makeForloop(id: string, innerIds: string[]): FlowModule { + return { + id, + value: { + type: 'forloopflow', + modules: innerIds.map((iid) => makeModule(iid)), + iterator: { type: 'javascript', expr: '' } + } as any + } as FlowModule +} + +function makeGroup( + id: string, + start_id: string, + end_id: string, + moduleIds: string[] = [] +): GraphGroup { + return { id, start_id, end_id, moduleIds } +} + +describe('buildStructureTree', () => { + const modules = [makeModule('a'), makeModule('b'), makeModule('c')] + + it('builds structure tree for a valid group', () => { + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])] + const result = buildStructureTree(modules, groups) + // Should have a group node + the remaining leaf 'c' + expect(result).toHaveLength(2) + expect(result[0].kind).toBe('group') + expect(result[0].id).toBe('g1') + expect(result[0].branches[0].children).toHaveLength(2) + expect(result[1].kind).toBe('leaf') + expect(result[1].id).toBe('c') + }) + + it('throws on duplicate group IDs', () => { + const groups = [makeGroup('g1', 'a', 'a', ['a']), makeGroup('g1', 'b', 'c', ['b', 'c'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/duplicate group id.*g1/i) + }) + + it('throws on inverted range (start_id after end_id)', () => { + const groups = [makeGroup('g1', 'c', 'a', ['a', 'b', 'c'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/inverted range/i) + }) + + it('throws on partially overlapping groups', () => { + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b']), makeGroup('g2', 'b', 'c', ['b', 'c'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/overlap without nesting/i) + }) + + it('throws when group start_id is a virtual node (Input)', () => { + const groups = [makeGroup('g1', 'Input', 'b', ['a', 'b'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/virtual node/i) + }) + + it('throws when group end_id is a virtual node (Result)', () => { + const groups = [makeGroup('g1', 'a', 'Result', ['a', 'b', 'c'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/virtual node/i) + }) + + it('throws when group references Trigger', () => { + const groups = [makeGroup('g1', 'Trigger', 'c', ['a', 'b', 'c'])] + expect(() => buildStructureTree(modules, groups)).toThrow(/virtual node/i) + }) + + it('allows fully nested groups', () => { + const mods = [makeModule('a'), makeModule('b'), makeModule('c'), makeModule('d')] + const groups = [ + makeGroup('outer', 'a', 'd', ['a', 'b', 'c', 'd']), + makeGroup('inner', 'b', 'c', ['b', 'c']) + ] + const result = buildStructureTree(mods, groups) + expect(result).toHaveLength(1) // outer group contains everything + expect(result[0].kind).toBe('group') + // Inner group should be nested + const outerChildren = result[0].branches[0].children + expect(outerChildren).toHaveLength(3) // a, inner-group, d + expect(outerChildren[1].kind).toBe('group') + expect(outerChildren[1].id).toBe('inner') + }) + + it('handles empty modules', () => { + const result = buildStructureTree([], []) + expect(result).toHaveLength(0) + }) + + it('handles container modules (forloop)', () => { + const mods = [makeForloop('loop', ['x', 'y']), makeModule('c')] + const result = buildStructureTree(mods, []) + expect(result).toHaveLength(2) + expect(result[0].kind).toBe('forloopflow') + expect(result[0].branches).toHaveLength(1) + expect(result[0].branches[0].children).toHaveLength(2) + expect(result[0].branches[0].children[0].id).toBe('x') + }) + + it('handles groups inside containers', () => { + const mods = [makeForloop('loop', ['x', 'y', 'z'])] + const groups = [makeGroup('g1', 'x', 'y', ['x', 'y'])] + const result = buildStructureTree(mods, groups) + expect(result).toHaveLength(1) + expect(result[0].kind).toBe('forloopflow') + const innerChildren = result[0].branches[0].children + expect(innerChildren).toHaveLength(2) // group + z + expect(innerChildren[0].kind).toBe('group') + expect(innerChildren[0].id).toBe('g1') + }) + + it('throws when group spans parallel branches (branchall)', () => { + const mods = [ + makeModule('a'), + makeBranchAll('ba', [ + ['x', 'y'], + ['p', 'q'] + ]), + makeModule('c') + ] + const groups = [makeGroup('g1', 'x', 'q', ['x', 'q'])] + expect(() => buildStructureTree(mods, groups)).toThrow(/could not be resolved/) + }) +}) + +describe('flattenStructureIds', () => { + it('flattens a simple tree', () => { + const modules = [makeModule('a'), makeModule('b'), makeModule('c')] + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])] + const tree = buildStructureTree(modules, groups) + const ids = flattenStructureIds(tree) + expect(ids).toEqual(['a', 'b', 'c']) + }) + + it('flattens nested groups', () => { + const mods = [makeModule('a'), makeModule('b'), makeModule('c'), makeModule('d')] + const groups = [ + makeGroup('outer', 'a', 'd', ['a', 'b', 'c', 'd']), + makeGroup('inner', 'b', 'c', ['b', 'c']) + ] + const tree = buildStructureTree(mods, groups) + const ids = flattenStructureIds(tree) + expect(ids).toEqual(['a', 'b', 'c', 'd']) + }) +}) + +describe('deriveGroupsFromStructure', () => { + it('derives group definitions with correct start/end', () => { + const modules = [makeModule('a'), makeModule('b'), makeModule('c')] + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])] + const tree = buildStructureTree(modules, groups) + const derived = deriveGroupsFromStructure(tree) + expect(derived).toHaveLength(1) + expect(derived[0].start_id).toBe('a') + expect(derived[0].end_id).toBe('b') + }) + + it('derives nested groups', () => { + const mods = [makeModule('a'), makeModule('b'), makeModule('c'), makeModule('d')] + const groups = [ + makeGroup('outer', 'a', 'd', ['a', 'b', 'c', 'd']), + makeGroup('inner', 'b', 'c', ['b', 'c']) + ] + const tree = buildStructureTree(mods, groups) + const derived = deriveGroupsFromStructure(tree) + expect(derived).toHaveLength(2) + expect(derived[0].start_id).toBe('a') + expect(derived[0].end_id).toBe('d') + expect(derived[1].start_id).toBe('b') + expect(derived[1].end_id).toBe('c') + }) +}) + +describe('findInStructure', () => { + it('finds a leaf node', () => { + const modules = [makeModule('a'), makeModule('b')] + const tree = buildStructureTree(modules, []) + const found = findInStructure(tree, 'b') + expect(found).toBeDefined() + expect(found!.index).toBe(1) + }) + + it('finds a node inside a group', () => { + const modules = [makeModule('a'), makeModule('b'), makeModule('c')] + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])] + const tree = buildStructureTree(modules, groups) + const found = findInStructure(tree, 'b') + expect(found).toBeDefined() + expect(found!.index).toBe(1) + // parentChildren should be the group's branch children + expect(found!.parentChildren).toHaveLength(2) + }) + + it('finds a group node by group id', () => { + const modules = [makeModule('a'), makeModule('b')] + const groups = [makeGroup('g1', 'a', 'b', ['a', 'b'])] + const tree = buildStructureTree(modules, groups) + const found = findInStructure(tree, 'g1') + expect(found).toBeDefined() + expect(found!.index).toBe(0) + }) +}) + +describe('collectLeafIds', () => { + it('collects all leaf module IDs including inside containers', () => { + const mods = [makeForloop('loop', ['x', 'y']), makeModule('c')] + const tree = buildStructureTree(mods, []) + const ids = collectLeafIds(tree) + expect(ids).toEqual(['loop', 'x', 'y', 'c']) + }) +}) diff --git a/frontend/src/lib/components/graph/flowStructure.ts b/frontend/src/lib/components/graph/flowStructure.ts new file mode 100644 index 0000000000..e2a725bc85 --- /dev/null +++ b/frontend/src/lib/components/graph/flowStructure.ts @@ -0,0 +1,498 @@ +import type { FlowModule } from '$lib/gen' + +import type { FlowGroup, GraphGroup } from './groupEditor.svelte' +import { getContainerInnerArrays } from './groupEditor.svelte' +import { VIRTUAL_NODE_IDS } from './groupDetectionUtils' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ContainerKind = 'forloopflow' | 'whileloopflow' | 'branchone' | 'branchall' + +export type StructureBranch = { + label?: string + children: FlowStructureNode[] +} + +export type FlowStructureNode = { + /** FlowModule.id for modules, groupKey(g) for groups */ + id: string + kind: 'leaf' | 'group' | ContainerKind + /** Only present when kind === 'group' */ + group?: FlowGroup + /** Only present when kind === 'group' — flat module IDs for step count */ + moduleIds?: string[] + /** Child branches. leaf=[], group=[{children}], container=[{children}, ...] */ + branches: StructureBranch[] +} + +// --------------------------------------------------------------------------- +// Type guards +// --------------------------------------------------------------------------- +// Building the structure tree +// --------------------------------------------------------------------------- + +export function buildStructureTree( + modules: FlowModule[], + groups: GraphGroup[] +): FlowStructureNode[] { + const { items, consumed } = buildStructureTreeRecurse(modules, groups) + const unconsumed = groups.filter((g) => !consumed.has(g.id)) + if (unconsumed.length > 0) { + throw new Error( + `Group(s) ${unconsumed.map((g) => `'${g.id}'`).join(', ')} could not be resolved: ` + + `their start/end nodes do not belong to the same branch` + ) + } + return items +} + +export function moduleToStructureNode(mod: FlowModule): FlowStructureNode { + const innerArrays = getContainerInnerArrays(mod) + if (innerArrays.length === 0) { + return { id: mod.id, kind: 'leaf', branches: [] } + } + + const kind = (mod.value as any).type as ContainerKind + const branches: StructureBranch[] = innerArrays.map(({ get, label }) => ({ + label, + children: [] // filled later by recursion + })) + + return { id: mod.id, kind, branches } +} + +function buildStructureTreeRecurse( + modules: FlowModule[], + groups: GraphGroup[] +): { items: FlowStructureNode[]; consumed: Set } { + if (modules.length === 0) { + return { items: [], consumed: new Set() } + } + + const indexMap = new Map() + for (let i = 0; i < modules.length; i++) { + indexMap.set(modules[i].id, i) + } + + // Reject duplicate group IDs + const seenGroupIds = new Set() + for (const g of groups) { + if (seenGroupIds.has(g.id)) { + throw new Error(`Duplicate group id: '${g.id}'`) + } + seenGroupIds.add(g.id) + } + + // Reject groups referencing virtual nodes + for (const g of groups) { + if (VIRTUAL_NODE_IDS.has(g.start_id) || VIRTUAL_NODE_IDS.has(g.end_id)) { + throw new Error( + `Group '${g.id}' references virtual node: groups cannot include Input, Result, or Trigger` + ) + } + } + + // Partition: groups for this level vs rest + const levelGroups: GraphGroup[] = [] + const otherGroups: GraphGroup[] = [] + for (const g of groups) { + if (indexMap.has(g.start_id) && indexMap.has(g.end_id)) { + const s = indexMap.get(g.start_id)! + const e = indexMap.get(g.end_id)! + if (s > e) { + throw new Error( + `Group '${g.id}' has inverted range: start_id='${g.start_id}' (index ${s}) > end_id='${g.end_id}' (index ${e})` + ) + } + levelGroups.push(g) + } else { + otherGroups.push(g) + } + } + + // Validate no partial overlaps + for (let i = 0; i < levelGroups.length; i++) { + for (let j = i + 1; j < levelGroups.length; j++) { + const a = levelGroups[i] + const b = levelGroups[j] + const aStart = indexMap.get(a.start_id)! + const aEnd = indexMap.get(a.end_id)! + const bStart = indexMap.get(b.start_id)! + const bEnd = indexMap.get(b.end_id)! + + if (aEnd < bStart || bEnd < aStart) continue + if (aStart <= bStart && bEnd <= aEnd) continue + if (bStart <= aStart && aEnd <= bEnd) continue + + throw new Error(`Groups '${a.id}' and '${b.id}' overlap without nesting`) + } + } + + // Build grouped structure for this level + function build( + startIdx: number, + endIdx: number, + availableGroups: GraphGroup[] + ): FlowStructureNode[] { + const result: FlowStructureNode[] = [] + let i = startIdx + while (i <= endIdx) { + const candidates = availableGroups.filter((g) => { + const gStart = indexMap.get(g.start_id)! + const gEnd = indexMap.get(g.end_id)! + return gStart === i && gEnd <= endIdx + }) + candidates.sort((a, b) => { + const spanA = indexMap.get(a.end_id)! - indexMap.get(a.start_id)! + const spanB = indexMap.get(b.end_id)! - indexMap.get(b.start_id)! + return spanB - spanA + }) + + const group = candidates[0] + if (group) { + const gEnd = indexMap.get(group.end_id)! + const remaining = availableGroups.filter((g) => g.id !== group.id) + const innerNodes = build(i, gEnd, remaining) + + const moduleIds: string[] = [] + for (let k = i; k <= gEnd; k++) { + moduleIds.push(modules[k].id) + } + + result.push({ + id: group.id, + kind: 'group', + group: { + summary: group.summary, + note: group.note, + color: group.color, + autocollapse: group.autocollapse, + start_id: group.start_id, + end_id: group.end_id + }, + moduleIds, + branches: [{ children: innerNodes }] + }) + i = gEnd + 1 + } else { + result.push(moduleToStructureNode(modules[i])) + i++ + } + } + return result + } + + const result = build(0, modules.length - 1, levelGroups) + + // Recurse into containers with remaining unconsumed groups + const consumed = new Set(levelGroups.map((g) => g.id)) + let remaining = otherGroups + + function recurseIntoContainers(items: FlowStructureNode[]): void { + for (const item of items) { + if (item.kind === 'group') { + recurseIntoContainers(item.branches[0].children) + continue + } + if (item.branches.length === 0) continue + + // This is a container module — get inner FlowModule arrays and recurse + const modIdx = indexMap.get(item.id) + if (modIdx === undefined) continue + const mod = modules[modIdx] + + const innerArrays = getContainerInnerArrays(mod) + for (let bi = 0; bi < innerArrays.length; bi++) { + const inner = buildStructureTreeRecurse(innerArrays[bi].get(), remaining) + item.branches[bi] = { + label: item.branches[bi]?.label, + children: inner.items + } + for (const id of inner.consumed) consumed.add(id) + remaining = remaining.filter((g) => !inner.consumed.has(g.id)) + } + } + } + recurseIntoContainers(result) + + return { items: result, consumed } +} + +// --------------------------------------------------------------------------- +// Traversal utilities +// --------------------------------------------------------------------------- + +/** Generic DFS over the structure tree */ +export function dfsStructure( + nodes: FlowStructureNode[], + fn: (node: FlowStructureNode, parentArray: FlowStructureNode[]) => void +): void { + for (const node of nodes) { + fn(node, nodes) + for (const branch of node.branches) { + dfsStructure(branch.children, fn) + } + } +} + +/** Flatten to ordered module IDs (groups are transparent) */ +export function flattenStructureIds(nodes: FlowStructureNode[]): string[] { + const ids: string[] = [] + for (const node of nodes) { + if (node.kind === 'group') { + ids.push(...flattenStructureIds(node.branches[0].children)) + } else { + ids.push(node.id) + } + } + return ids +} + +/** Collect leaf module IDs recursively (including inside containers) */ +export function collectLeafIds(nodes: FlowStructureNode[]): string[] { + const ids: string[] = [] + for (const node of nodes) { + if (node.kind === 'group') { + ids.push(...collectLeafIds(node.branches[0].children)) + } else { + ids.push(node.id) + for (const branch of node.branches) { + ids.push(...collectLeafIds(branch.children)) + } + } + } + return ids +} + +// --------------------------------------------------------------------------- +// Finding nodes in the tree +// --------------------------------------------------------------------------- + +export type FindResult = { parentChildren: FlowStructureNode[]; index: number } + +export function findInStructure(nodes: FlowStructureNode[], id: string): FindResult | undefined { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i] + if (node.id === id) return { parentChildren: nodes, index: i } + for (const branch of node.branches) { + const found = findInStructure(branch.children, id) + if (found) return found + } + } + return undefined +} + +/** + * Match a structure node against a graph node ID. + * Handles group head/end IDs (group:X, group:X-end) and collapsed-group:X. + */ +export function matchStructureNode(node: FlowStructureNode, nodeId: string): boolean { + if (node.id === nodeId) return true + if (node.kind === 'group') { + return ( + nodeId === `group:${node.id}` || + nodeId === `group:${node.id}-end` || + nodeId === `collapsed-group:${node.id}` + ) + } + return false +} + +/** + * Find insert index using graph node IDs (handles group:X-end etc.). + * Returns the index OF the matched item (insert before it). + * For group-end nodes, returns index AFTER the group (insert after it). + */ +export function findInsertIndexByNodeId(items: FlowStructureNode[], targetNodeId: string): number { + // group-end: insert after the group + if (targetNodeId.startsWith('group:') && targetNodeId.endsWith('-end')) { + const groupId = targetNodeId.slice('group:'.length, -'-end'.length) + const idx = items.findIndex((n) => n.kind === 'group' && n.id === groupId) + return idx >= 0 ? idx + 1 : items.length + } + // Everything else: insert at the matched item's position + for (let i = 0; i < items.length; i++) { + if (matchStructureNode(items[i], targetNodeId)) return i + } + return items.length +} + +// --------------------------------------------------------------------------- +// Deriving groups from the structure tree +// --------------------------------------------------------------------------- + +export function deriveGroupsFromStructure(nodes: FlowStructureNode[]): FlowGroup[] { + const groups: FlowGroup[] = [] + for (const node of nodes) { + if (node.kind === 'group' && node.group) { + const flatIds = flattenStructureIds(node.branches[0].children) + if (flatIds.length === 0) { + console.warn(`deriveGroupsFromStructure: skipping empty group "${node.id}"`) + continue + } + groups.push({ + ...node.group, + start_id: flatIds[0], + end_id: flatIds[flatIds.length - 1] + }) + // Recurse for nested groups + groups.push(...deriveGroupsFromStructure(node.branches[0].children)) + } else { + for (const branch of node.branches) { + groups.push(...deriveGroupsFromStructure(branch.children)) + } + } + } + return groups +} + +// --------------------------------------------------------------------------- +// Syncing structure back to FlowModule[] +// --------------------------------------------------------------------------- + +/** + * Reconstruct a FlowModule[] from the structure tree, looking up originals + * from moduleMap and patching container inner arrays to match the tree ordering. + */ +export function applyStructureToModules( + nodes: FlowStructureNode[], + moduleMap: Map +): FlowModule[] { + const result: FlowModule[] = [] + for (const node of nodes) { + if (node.kind === 'group') { + // Groups are transparent — splice their children into this level + result.push(...applyStructureToModules(node.branches[0].children, moduleMap)) + } else { + const mod = moduleMap.get(node.id) + if (!mod) continue + + // Patch container inner arrays + if (node.branches.length > 0) { + const innerArrays = getContainerInnerArrays(mod) + for (let bi = 0; bi < innerArrays.length && bi < node.branches.length; bi++) { + innerArrays[bi].set(applyStructureToModules(node.branches[bi].children, moduleMap)) + } + } + + result.push(mod) + } + } + return result +} + +// --------------------------------------------------------------------------- +// Empty groups cleanup +// --------------------------------------------------------------------------- + +/** + * Walk the tree, remove group nodes that have no leaf modules, and return + * the removed groups. Mutates the input array in-place. + * Recurses depth-first so inner groups are cleaned before checking outer ones. + */ +export function removeEmptyGroups(nodes: FlowStructureNode[]): FlowGroup[] { + const removed: FlowGroup[] = [] + for (let i = nodes.length - 1; i >= 0; i--) { + const node = nodes[i] + if (node.kind === 'group' && node.group) { + // Recurse first — inner groups may become empty too + removed.push(...removeEmptyGroups(node.branches[0].children)) + if (flattenStructureIds(node.branches[0].children).length === 0) { + removed.push(node.group) + nodes.splice(i, 1) + } + } else { + for (const branch of node.branches) { + removed.push(...removeEmptyGroups(branch.children)) + } + } + } + return removed +} + +/** Walk the structure tree to compute nesting depth for each group (O(n)). */ +export function computeGroupDepths(tree: FlowStructureNode[]): Record { + const depths: Record = {} + function walk(nodes: FlowStructureNode[], groupDepth: number): void { + for (const node of nodes) { + if (node.kind === 'group') { + depths[node.id] = groupDepth + for (const branch of node.branches) { + walk(branch.children, groupDepth + 1) + } + } else { + for (const branch of node.branches) { + walk(branch.children, groupDepth) + } + } + } + } + walk(tree, 0) + return depths +} + +/** + * Find duplicate groups in the structure tree (same start_id:end_id after mutation). + * Returns the groups that should be removed (keeps the first, removes subsequent duplicates). + */ +export function findDuplicateGroups(nodes: FlowStructureNode[]): FlowGroup[] { + const duplicates: FlowGroup[] = [] + const seen = new Set() + + function walk(items: FlowStructureNode[]): void { + for (const node of items) { + if (node.kind === 'group' && node.group) { + const flatIds = flattenStructureIds(node.branches[0].children) + if (flatIds.length > 0) { + const key = `${flatIds[0]}:${flatIds[flatIds.length - 1]}` + if (seen.has(key)) { + duplicates.push(node.group) + } else { + seen.add(key) + } + } + walk(node.branches[0].children) + } else { + for (const branch of node.branches) { + walk(branch.children) + } + } + } + } + walk(nodes) + return duplicates +} + +/** Remove duplicate groups from the structure tree (keeps first occurrence). */ +export function removeDuplicateGroups(nodes: FlowStructureNode[]): FlowGroup[] { + const removed: FlowGroup[] = [] + const seen = new Set() + + function walk(items: FlowStructureNode[]): void { + for (let i = items.length - 1; i >= 0; i--) { + const node = items[i] + if (node.kind === 'group' && node.group) { + walk(node.branches[0].children) + const flatIds = flattenStructureIds(node.branches[0].children) + if (flatIds.length > 0) { + const key = `${flatIds[0]}:${flatIds[flatIds.length - 1]}` + if (seen.has(key)) { + // Replace group node with its children (ungroup) + removed.push(node.group) + items.splice(i, 1, ...node.branches[0].children) + } else { + seen.add(key) + } + } + } else { + for (const branch of node.branches) { + walk(branch.children) + } + } + } + } + walk(nodes) + return removed +} diff --git a/frontend/src/lib/components/graph/graphBuilder.svelte.ts b/frontend/src/lib/components/graph/graphBuilder.svelte.ts index 192b5c0c34..4fd7641096 100644 --- a/frontend/src/lib/components/graph/graphBuilder.svelte.ts +++ b/frontend/src/lib/components/graph/graphBuilder.svelte.ts @@ -8,6 +8,14 @@ import { getFlowModuleAssets, type AssetWithAltAccessType } from '../assets/lib' import { assetDisplaysAsOutputInFlowGraph } from './renderers/nodes/AssetNode.svelte' import type { ModulesTestStates, ModuleTestState } from '../modulesTest.svelte' import type { ModuleActionInfo } from '$lib/components/flows/flowDiff' +import { + type FlowStructureNode, + collectLeafIds, + findInsertIndexByNodeId, + buildStructureTree +} from './flowStructure' +import { groupKey, type FlowGroup } from './groupEditor.svelte' +import { computeGroupModuleIds } from './groupDetectionUtils' export type InsertKind = | 'script' @@ -62,6 +70,7 @@ export type GraphEventHandlers = { simplifyFlow: (b: boolean) => void expandSubflow: (id: string, path: string) => void minimizeSubflow: (id: string) => void + expandGroup: (groupId: string) => void updateMock: (detail: { mock: FlowModule['mock']; id: string }) => void testUpTo: (id: string) => void editInput: (moduleId: string, key: string) => void @@ -111,6 +120,9 @@ export type FlowNode = | AssetsOverflowedN | AiToolN | NewAiToolN + | CollapsedGroupN + | GroupHeadN + | GroupEndN export type InputN = { type: 'input2' @@ -316,6 +328,48 @@ export type NewAiToolN = { } } +export type CollapsedGroupN = { + type: 'collapsedGroup' + data: { + groupId: string + summary: string | undefined + note: string | undefined + color: string | undefined + autocollapse: boolean | undefined + stepCount: number + modules: FlowModule[] + flowModuleStates: Record | undefined + flowJob: Job | undefined + isOwner: boolean + suspendStatus: Record + showNotes: boolean + editMode: boolean + eventHandlers: GraphEventHandlers + } +} + +export type GroupHeadN = { + type: 'groupHead' + data: { + groupId: string + summary: string | undefined + note: string | undefined + color: string | undefined + autocollapse: boolean | undefined + editMode: boolean + showNotes: boolean + eventHandlers: GraphEventHandlers + wrapperWidth?: number + } +} + +export type GroupEndN = { + type: 'groupEnd' + data: { + groupId: string + } +} + export function topologicalSort( nodes: { id: string; parentIds?: string[] }[] ): { id: string; parentIds?: string[] }[] { @@ -336,22 +390,8 @@ export function topologicalSort( return result.reverse() } -// input2: InputNode, -// module: ModuleNode, -// branchAllStart: BranchAllStart, -// branchAllEnd: BranchAllEndNode, -// forLoopEnd: ForLoopEndNode, -// forLoopStart: ForLoopStartNode, -// result: ResultNode, -// whileLoopStart: ForLoopStartNode, -// whileLoopEnd: ForLoopEndNode, -// branchOneStart: BranchOneStart, -// branchOneEnd: BranchOneEndNode, -// subflowBound: SubflowBound, -// noBranch: NoBranchNode, -// trigger: TriggersNode - export function graphBuilder( + structureTree: FlowStructureNode[], modules: FlowModule[] | undefined, extra: { disableAi: boolean @@ -383,11 +423,9 @@ export function graphBuilder( selectedId: string | undefined, simplifiableFlow: SimplifiableFlow | undefined, flowPathForTriggerNode: string | undefined, - expandedSubflows: Record - // triggerProps?: { - // path?: string - // flowIsSimplifiable?: boolean - // } + expandedSubflows: Record, + showNotes: boolean, + collapsedGroupIds: Set ): { nodes: { [key: string]: NodeLayout } edges: Edge[] @@ -403,7 +441,13 @@ export function graphBuilder( const nodes: NodeLayout[] = [] const edges: Edge[] = [] - function addNode(module: FlowModule) { + // Lookup map from module ID to the original reactive FlowModule objects. + const moduleMap = new Map() + for (const m of getAllModules(modules, failureModule)) { + moduleMap.set(m.id, m) + } + + function addNode(module: FlowModule, extraData?: Record) { const duplicated = nodes.find((n) => n.id === module.id) if (duplicated) { console.log('Duplicated node detected: ', module, duplicated) @@ -424,7 +468,8 @@ export function graphBuilder( isOwner: extra.isOwner, flowJob: extra.flowJob, assets: getFlowModuleAssets(module, extra.additionalAssetsMap), - moduleAction: extra.moduleActions?.[module.id] + moduleAction: extra.moduleActions?.[module.id], + ...extraData }, type: 'module', selectable: true @@ -483,14 +528,20 @@ export function graphBuilder( customId?: string type?: string subModules?: FlowModule[] + currentItems?: FlowStructureNode[] disableMoveIds?: string[] } ) { parents[targetId] = [...(parents[targetId] ?? []), sourceId] - const mods = options?.subModules ?? modules - - let index = mods?.findIndex((m) => m.id === targetId) ?? -1 + let index: number + if (options?.currentItems) { + index = findInsertIndexByNodeId(options.currentItems, targetId) + } else { + const mods = options?.subModules ?? modules + const found = mods?.findIndex((m) => m.id === targetId) ?? -1 + index = found >= 0 ? found : (mods?.length ?? 0) + } const visited = new Set() const recStack = new Set() @@ -514,8 +565,7 @@ export function graphBuilder( simplifiedTriggerView: simplifiableFlow?.simplifiedFlow, disableMoveIds: options?.disableMoveIds, enableTrigger: sourceId === 'Input', - // If the index is -1, it means that the target module is not in the modules array, so we set it to the length of the array - index: index >= 0 ? index : (mods?.length ?? 0), + index, ...extra, insertable: extra.insertable && !options?.disableInsert && prefix == undefined, shouldOffsetInsertBtnDueToAssetNode: nodeIdsWithOutputAssets.has(sourceId) @@ -591,7 +641,7 @@ export function graphBuilder( } function processModules( - modules: FlowModule[], + items: FlowStructureNode[], branch: { rootId: string; branch: number } | undefined, beforeNode: NodeLayout, nextNode: NodeLayout | undefined, @@ -600,31 +650,166 @@ export function graphBuilder( disableMoveIds: string[] = [], parentIndex?: string ) { + // For subflow prefix rewriting, clone modules into moduleMap with prefixed IDs + // (avoid mutating reactive originals which would trigger state_unsafe_mutation in $derived) if (prefix != undefined) { - modules.forEach((m) => { - if (!m['oid']) { - m['oid'] = m.id + items.forEach((item) => { + if (item.kind === 'group') return + const m = moduleMap.get(item.id) + if (m) { + const oid = m['oid'] ?? m.id + const newId = 'subflow:' + prefix + oid + const clone = { ...m, id: newId, oid } as FlowModule & { oid: string } + clone['oid'] = oid + moduleMap.set(newId, clone) + item.id = newId } - m.id = 'subflow:' + prefix + m['oid'] }) } let previousId: string | undefined = undefined - if (modules.length === 0) { + if (items.length === 0) { if (nextNode) { addEdge(beforeNode.id, nextNode.id, branch, prefix, { - subModules: modules, + currentItems: items, disableMoveIds }) } } else { - modules.forEach((module, index) => { + items.forEach((item, index) => { + // --- Group items --- + if (item.kind === 'group') { + const g = item.group! + const gId = item.id + + if (collapsedGroupIds.has(gId)) { + // Collapsed group: single node + const nodeId = `collapsed-group:${gId}` + const leafIds = collectLeafIds(item.branches[0].children) + nodes.push({ + id: nodeId, + data: { + groupId: gId, + summary: g.summary, + note: g.note, + color: g.color, + autocollapse: g.autocollapse, + stepCount: item.moduleIds?.length ?? 0, + modules: leafIds + .map((id) => moduleMap.get(id)) + .filter((m): m is FlowModule => !!m), + flowModuleStates: extra.flowModuleStates, + flowJob: extra.flowJob, + isOwner: extra.isOwner, + suspendStatus: extra.suspendStatus, + showNotes, + editMode: prefix == undefined && extra.editMode, + eventHandlers + }, + type: 'collapsedGroup', + selectable: false + }) + + // Wire: previous → collapsedGroup + if (index > 0 && previousId) { + addEdge(previousId, nodeId, branch, prefix, { + currentItems: items, + disableMoveIds + }) + } + + previousId = nodeId + } else { + // Expanded group: head → recurse → end + const headId = `group:${gId}` + const endId = `group:${gId}-end` + const localDisableMoveIds = [...disableMoveIds, headId] + + const headNode: NodeLayout = { + id: headId, + data: { + groupId: gId, + summary: g.summary, + note: g.note, + color: g.color, + autocollapse: g.autocollapse, + editMode: prefix == undefined && extra.editMode, + showNotes, + eventHandlers + }, + type: 'groupHead', + selectable: false + } + + const endNode: NodeLayout = { + id: endId, + data: { + groupId: gId + }, + type: 'groupEnd', + selectable: false + } + + nodes.push(headNode) + nodes.push(endNode) + + // Wire: previous → headNode + if (index > 0 && previousId) { + addEdge(previousId, headId, branch, prefix, { + currentItems: items, + disableMoveIds + }) + } + + // Recurse inner modules + processModules( + item.branches[0].children, + { rootId: headId, branch: 0 }, + headNode, + endNode, + simplifiedTriggerView, + prefix, + localDisableMoveIds, + parentIndex + ) + + previousId = endId + } + + // Shared first/last edge wiring for groups + if (index === 0) { + addEdge( + beforeNode.id, + collapsedGroupIds.has(gId) ? `collapsed-group:${gId}` : `group:${gId}`, + undefined, + prefix, + { + currentItems: items, + disableMoveIds, + disableInsert: simplifiedTriggerView + } + ) + } + + if (index === items.length - 1 && previousId && nextNode) { + addEdge(previousId, nextNode.id, branch, prefix, { + currentItems: items, + disableMoveIds + }) + } + + return + } + + // --- Regular FlowModule items --- + const module = moduleMap.get(item.id) + if (!module) return const localDisableMoveIds = [...disableMoveIds, module.id] - // Add the edge between the previous node and the current one + // Inter-module edge: connect previous → current (expanded subflows handle their own) if (index > 0 && previousId && expandedSubflows[module.id] == undefined) { addEdge(previousId, module.id, branch, prefix, { - subModules: modules, + currentItems: items, disableMoveIds }) } @@ -700,7 +885,7 @@ export function graphBuilder( ) processModules( - branch.modules, + item.branches[branchIndex]?.children ?? [], { rootId: module.id, branch: branchIndex }, startNode, endNode, @@ -722,7 +907,7 @@ export function graphBuilder( id: `${module.id}-start`, data: { id: module.id, - module: module, + module: moduleMap.get(module.id) ?? module, simplifiedTriggerView, eventHandlers: eventHandlers, editMode: extra.editMode, @@ -759,7 +944,7 @@ export function graphBuilder( const selectedIterIndex = extra.flowModuleStates?.[module.id]?.selectedForloopIndex processModules( - module.value.modules, + item.branches[0]?.children ?? [], { rootId: module.id, branch: 0 }, startNode, endNode, @@ -798,7 +983,7 @@ export function graphBuilder( const selectedIterIndex = extra.flowModuleStates?.[module.id]?.selectedForloopIndex processModules( - module.value.modules, + item.branches[0]?.children ?? [], { rootId: module.id, branch: 0 }, startNode, endNode, @@ -825,21 +1010,6 @@ export function graphBuilder( } nodes.push(endNode) - // // Add default branch - // const defaultBranch: NodeLayout = { - // id: `${module.id}-default`, - // data: { - // offset: 0, - // label: 'Default', - // id: module.id, - // branchIndex: -1, - // eventHandlers: eventHandlers, - // branchOne: true, - // ...extra - // }, - // type: 'noBranch' - // } - const defaultBranch: NodeLayout = { id: `${module.id}-branch-default`, data: { @@ -863,7 +1033,7 @@ export function graphBuilder( }) processModules( - module.value.default, + item.branches[0]?.children ?? [], { rootId: module.id, branch: 0 }, defaultBranch, endNode, @@ -899,7 +1069,7 @@ export function graphBuilder( }) processModules( - branch.modules, + item.branches[branchIndex + 1]?.children ?? [], { rootId: module.id, branch: branchIndex + 1 }, startNode, endNode, @@ -912,9 +1082,9 @@ export function graphBuilder( previousId = endNode.id } else { - let expanded = expandedSubflows[module.id] - if (expanded) { - expanded = $state.snapshot(expanded) + const expandedData = expandedSubflows[module.id] + if (expandedData) { + const expandedMods = $state.snapshot(expandedData.modules) as FlowModule[] const startId = `${module.id}` const idWithoutPrefix = module.id.startsWith('subflow:') ? module.id.substring(8) @@ -936,12 +1106,12 @@ export function graphBuilder( if (previousId) { addEdge(previousId, startNode.id, branch, prefix, { - subModules: modules, + currentItems: items, disableMoveIds }) } else { addEdge(beforeNode.id, startNode.id, undefined, prefix, { - subModules: modules, + currentItems: items, disableMoveIds }) } @@ -962,8 +1132,20 @@ export function graphBuilder( nodes.push(endNode) + // Register expanded subflow modules so prefix rewriting finds + // the inner modules (not the parent flow's modules with same IDs) + for (const em of getAllModules(expandedMods)) { + moduleMap.set(em.id, em) + } + + const expandedGroups = (expandedData.groups ?? []).map((g) => ({ + ...g, + id: groupKey(g), + moduleIds: computeGroupModuleIds(g.start_id, g.end_id, getAllModules(expandedMods)) + })) + processModules( - expanded, + buildStructureTree(expandedMods, expandedGroups), undefined, startNode, endNode, @@ -981,15 +1163,15 @@ export function graphBuilder( if (index === 0 && expandedSubflows[module.id] == undefined) { addEdge(beforeNode.id, module.id, undefined, prefix, { - subModules: modules, + currentItems: items, disableMoveIds, disableInsert: simplifiedTriggerView }) } - if (index === modules.length - 1 && previousId && nextNode) { + if (index === items.length - 1 && previousId && nextNode) { addEdge(previousId, nextNode.id, branch, prefix, { - subModules: modules, + currentItems: items, disableMoveIds }) } @@ -997,10 +1179,12 @@ export function graphBuilder( } } + const topLevelItems = structureTree + if (simplifiableFlow?.simplifiedFlow === true && triggerNode) { - processModules(modules, undefined, triggerNode, undefined, true, undefined) + processModules(topLevelItems, undefined, triggerNode, undefined, true, undefined) } else { - processModules(modules, undefined, inputNode, resultNode, false, undefined) + processModules(topLevelItems, undefined, inputNode, resultNode, false, undefined) } if (failureModule) { diff --git a/frontend/src/lib/components/graph/graphContext.ts b/frontend/src/lib/components/graph/graphContext.ts index 8540982366..a6245769cf 100644 --- a/frontend/src/lib/components/graph/graphContext.ts +++ b/frontend/src/lib/components/graph/graphContext.ts @@ -4,6 +4,7 @@ import type { NoteManager } from './noteManager.svelte' import type { MoveManager } from './moveManager.svelte' import type { Writable } from 'svelte/store' import type { FlowDiffManager } from '../flows/flowDiffManager.svelte' +import type { GroupDisplayState } from './groupEditor.svelte' export type GraphContext = { selectionManager: SelectionManager @@ -14,6 +15,9 @@ export type GraphContext = { clearFlowSelection?: () => void yOffset?: number diffManager: FlowDiffManager + /** Current flow nodes for group validation (set by FlowGraphV2) */ + getFlowNodes?: () => { id: string; parentIds?: string[] }[] + groupDisplayState?: GroupDisplayState } const graphContextKey = 'FlowGraphContext' diff --git a/frontend/src/lib/components/graph/groupDetectionUtils.ts b/frontend/src/lib/components/graph/groupDetectionUtils.ts index e2c81c4d98..8dc6e3d8c2 100644 --- a/frontend/src/lib/components/graph/groupDetectionUtils.ts +++ b/frontend/src/lib/components/graph/groupDetectionUtils.ts @@ -1,7 +1,127 @@ +import { topologicalSort } from './graphBuilder.svelte' + +/** Node IDs synthesized by graphBuilder that are not real FlowModules */ +export const VIRTUAL_NODE_IDS = new Set(['Input', 'Result', 'Trigger']) + type FlowNode = { id: string; parentIds?: string[] } /** - * Use a simple algorithm to complete a group and split it into connected components + * Compute the set of module IDs that belong to a group defined by start_id and end_id. + * Uses the flattened module list (from getAllModules) and slices between start and end. + * Used for collapsed group icons, step count, and moduleToCollapsedGroup mapping. + */ +export function computeGroupModuleIds( + startId: string, + endId: string, + allModules: { id: string }[] +): string[] { + if (startId === endId) { + return allModules.some((m) => m.id === startId) ? [startId] : [] + } + + const startIdx = allModules.findIndex((m) => m.id === startId) + const endIdx = allModules.findIndex((m) => m.id === endId) + + if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) { + if (startIdx > endIdx) { + console.warn( + `computeGroupModuleIds: inverted range for group ${startId}→${endId} (${startIdx} > ${endIdx})` + ) + } + return [] + } + + return allModules.slice(startIdx, endIdx + 1).map((m) => m.id) +} + +/** + * Check whether a set of selected node IDs can form a valid group. + * Normalizes marker IDs (branch/forloop) to parent module IDs, + * then uses topologicalSort to derive start and end boundaries. + */ +export function canFormValidGroup( + selectedIds: string[], + flowNodes: FlowNode[], + excludeIds?: Set +): { valid: true; startId: string; endId: string } | { valid: false } { + if (selectedIds.length === 0) return { valid: false } + + // Normalize marker IDs to parent module IDs. + // -start (forloop head) → parent ID. -end/-branch-* → skip if parent covered, else reject. + const rawSet = new Set(selectedIds) + const normalizedIds: string[] = [] + + for (const id of selectedIds) { + const parentId = id.replace(/-(end|start|branch-.*)$/, '') + if (parentId === id) { + normalizedIds.push(id) + continue + } + if (id.endsWith('-start')) { + normalizedIds.push(parentId) + continue + } + // -end or -branch-*: parent must be covered (directly or via -start) + if (!rawSet.has(parentId) && !rawSet.has(`${parentId}-start`)) { + return { valid: false } + } + } + + if (normalizedIds.length === 0) return { valid: false } + const normalizedSet = new Set(normalizedIds) + + // Topo sort full graph, filter to normalized selection. + // Include raw matches plus all markers (-start, -end, -branch-*) whose parent is selected. + const sorted = topologicalSort(flowNodes) + const selectedSorted = sorted.filter((n) => { + if (normalizedSet.has(n.id)) return true + const parentId = n.id.replace(/-(end|start|branch-.*)$/, '') + return parentId !== n.id && normalizedSet.has(parentId) + }) + + if (selectedSorted.length === 0) return { valid: false } + + // Reject virtual or excluded nodes + if (selectedSorted.some((n) => VIRTUAL_NODE_IDS.has(n.id) || excludeIds?.has(n.id))) { + return { valid: false } + } + + // Topo order is bottom-first: first = bottom (end), last = top (start). + // Use raw IDs for BFS traversal, normalize for the returned group boundaries. + const rawStartId = selectedSorted[selectedSorted.length - 1].id + const rawEndId = selectedSorted[0].id + const startId = rawStartId.replace(/-(end|start|branch-.*)$/, '') + const endId = rawEndId.replace(/-(end|start|branch-.*)$/, '') + + // Verify all selected nodes lie between start and end in the DAG. + // BFS backward from rawEndId to rawStartId to collect reachable nodes. + // Normalize collected IDs so container markers map to their parent module. + const between = new Set() + const queue = [rawEndId] + const visited = new Set() + const parentMap = new Map(flowNodes.map((n) => [n.id, n.parentIds ?? []])) + while (queue.length > 0) { + const cur = queue.shift()! + if (visited.has(cur)) continue + visited.add(cur) + const normalized = cur.replace(/-(end|start|branch-.*)$/, '') + between.add(cur) + between.add(normalized) + if (cur === rawStartId) continue + for (const p of parentMap.get(cur) ?? []) { + queue.push(p) + } + } + if (!normalizedIds.every((id) => between.has(id))) { + return { valid: false } + } + + return { valid: true, startId, endId } +} + +/** + * Legacy utility: complete a group and split it into connected components. + * Still used by NoteEditor for FlowNote group notes (contained_node_ids). */ export function completeAndSplitGroup(groupNodes: string[], flowNodes: FlowNode[]): string[][] { if (groupNodes.length <= 1) { diff --git a/frontend/src/lib/components/graph/groupEditor.svelte.ts b/frontend/src/lib/components/graph/groupEditor.svelte.ts new file mode 100644 index 0000000000..8405390b96 --- /dev/null +++ b/frontend/src/lib/components/graph/groupEditor.svelte.ts @@ -0,0 +1,325 @@ +import type { FlowModule } from '$lib/gen' +import type { StateStore } from '$lib/utils' +import type { ExtendedOpenFlow } from '../flows/types' + +import { canFormValidGroup } from './groupDetectionUtils' +import type { NoteColor } from './noteColors' +import { DEFAULT_GROUP_NOTE_COLOR, getNextAvailableColor } from './noteColors' +import { getContext, setContext } from 'svelte' + +/** + * Type for a flow group (matches the generated type from OpenAPI). + * Members are computed dynamically from all nodes on paths between start_id and end_id. + */ +export type FlowGroup = { + summary?: string + note?: string + autocollapse?: boolean + start_id: string + end_id: string + color?: string +} + +/** Derive a stable key from a group's boundaries. Used as ephemeral ID for graph nodes, runtime state, etc. */ +export function groupKey(g: { start_id: string; end_id: string }): string { + return `${g.start_id}:${g.end_id}` +} + +/** + * Display state for flow groups inside the graph. + * Handles runtime collapse state and note height tracking. + * Similar to NoteManager — instantiated inside FlowGraphV2. + */ +export class GroupDisplayState { + #getGroups: () => FlowGroup[] + #runtimeCollapsedIds = $state>(new Set()) + #runtimeInitialized = $state(false) + #noteHeights = $state>({}) + renderCount = $state(0) + + constructor(getGroups: () => FlowGroup[]) { + this.#getGroups = getGroups + } + + /** Initialize runtime state from autocollapse. Safe to call from event handlers. */ + private ensureRuntimeInitialized(): void { + if (this.#runtimeInitialized) return + const groups = this.#getGroups() + this.#runtimeCollapsedIds = new Set( + groups.filter((g) => g.autocollapse).map((g) => groupKey(g)) + ) + this.#runtimeInitialized = true + } + + /** Check if a group is currently collapsed (runtime). Safe to call from $derived. */ + isRuntimeCollapsed(groupId: string): boolean { + if (!this.#runtimeInitialized) { + return this.#getGroups().find((g) => groupKey(g) === groupId)?.autocollapse ?? false + } + return this.#runtimeCollapsedIds.has(groupId) + } + + /** Toggle runtime collapse (Minimize2 button) */ + toggleRuntimeCollapse(groupId: string): void { + this.ensureRuntimeInitialized() + const next = new Set(this.#runtimeCollapsedIds) + if (next.has(groupId)) next.delete(groupId) + else next.add(groupId) + this.#runtimeCollapsedIds = next + this.render() + } + + /** Expand a group at runtime (CollapsedGroupNode click) */ + expandGroup(groupId: string): void { + this.ensureRuntimeInitialized() + const next = new Set(this.#runtimeCollapsedIds) + next.delete(groupId) + this.#runtimeCollapsedIds = next + this.render() + } + + /** Set note height for a group (used for layout spacing) */ + setNoteHeight(groupId: string, height: number): void { + if (this.#noteHeights[groupId] !== height) { + this.#noteHeights[groupId] = height + this.render() + } + } + + /** Get all note heights */ + getNoteHeights(): Record { + return this.#noteHeights + } + + /** Bump render counter to trigger re-layout */ + render(): void { + this.renderCount++ + } + + /** Remap runtime state when a group's boundaries (and thus its key) change */ + remapGroupKey(oldKey: string, newKey: string): void { + if (this.#runtimeCollapsedIds.has(oldKey)) { + const next = new Set(this.#runtimeCollapsedIds) + next.delete(oldKey) + next.add(newKey) + this.#runtimeCollapsedIds = next + } + if (oldKey in this.#noteHeights) { + this.#noteHeights[newKey] = this.#noteHeights[oldKey] + delete this.#noteHeights[oldKey] + } + } + + /** Get currently collapsed groups for graph builder. Safe to call from $derived. */ + getCollapsedGroups(): FlowGroup[] { + if (!this.#runtimeInitialized) { + return this.#getGroups().filter((g) => g.autocollapse) + } + return this.#getGroups().filter((g) => this.#runtimeCollapsedIds.has(groupKey(g))) + } +} + +/** + * Utility class for editing flow groups via direct flowStore mutations. + * Follows the same pattern as NoteEditor. + */ +export class GroupEditor { + private flowStore: StateStore + + constructor(flowStore: StateStore) { + this.flowStore = flowStore + } + + getGroups(): FlowGroup[] { + return this.flowStore.val.value?.groups || [] + } + + private setGroups(groups: FlowGroup[]): void { + if (this.flowStore.val.value) { + this.flowStore.val.value.groups = groups + } + } + + /** IDs that cannot be part of a group (preprocessor, failure module) */ + getExcludeIds(): Set { + const excludeIds = new Set() + const pp = this.flowStore.val.value?.preprocessor_module?.id + if (pp) excludeIds.add(pp) + const fm = this.flowStore.val.value?.failure_module?.id + if (fm) excludeIds.add(fm) + return excludeIds + } + + /** Check whether the given selection can form a valid group */ + canCreateGroup( + selectedIds: string[], + flowNodes: { id: string; parentIds?: string[] }[] + ): boolean { + const result = canFormValidGroup(selectedIds, flowNodes, this.getExcludeIds()) + if (!result.valid) return false + // Reject if a group with the same boundaries already exists + return !this.getGroups().some((g) => g.start_id === result.startId && g.end_id === result.endId) + } + + /** + * Create a new group from selected node IDs. + * Uses canFormValidGroup to determine start_id and end_id. + * Returns the generated group ID. + */ + createGroup( + moduleIds: string[], + flowNodes: { id: string; parentIds?: string[] }[] + ): string | undefined { + // Filter subflow node IDs (same logic as NoteEditor.createGroupNote) + let filteredIds = [...moduleIds] + const subflowIds: string[] = [] + for (const id of moduleIds) { + if (id.startsWith('subflow:')) { + const match = id.match(/^subflow:([^:]+)/) + if (match) { + subflowIds.push(match[1]) + } + } + } + if (subflowIds.length > 0) { + filteredIds = filteredIds.filter((id) => !subflowIds.includes(id)) + filteredIds = [...filteredIds, ...subflowIds] + } + + const result = canFormValidGroup(filteredIds, flowNodes, this.getExcludeIds()) + if (!result.valid) return undefined + + const groups = this.getGroups() + + // Reject duplicate: a group with the same boundaries already exists + if (groups.some((g) => g.start_id === result.startId && g.end_id === result.endId)) { + return undefined + } + const usedColors = new Set() + for (const group of groups) { + if (group.color) { + usedColors.add(group.color as NoteColor) + } + } + const color = usedColors.size > 0 ? getNextAvailableColor(usedColors) : DEFAULT_GROUP_NOTE_COLOR + + const newGroup: FlowGroup = { + start_id: result.startId, + end_id: result.endId, + color + } + this.setGroups([...groups, newGroup]) + return groupKey(newGroup) + } + + deleteGroup(groupId: string): void { + const groups = this.getGroups() + this.setGroups(groups.filter((g) => groupKey(g) !== groupId)) + } + + updateColor(groupId: string, color: NoteColor): void { + const groups = this.getGroups() + this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, color } : g))) + } + + updateSummary(groupId: string, summary: string): void { + const groups = this.getGroups() + this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, summary } : g))) + } + + updateNote(groupId: string, note: string | undefined): void { + const groups = this.getGroups() + this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, note } : g))) + } + + /** Add a note to a group (sets note to empty string to trigger the placeholder UI) */ + addNote(groupId: string): void { + this.updateNote(groupId, '') + } + + /** Remove a note from a group */ + removeNote(groupId: string): void { + this.updateNote(groupId, undefined) + } + + updateAutocollapse(groupId: string, autocollapse: boolean): void { + const groups = this.getGroups() + this.setGroups(groups.map((g) => (groupKey(g) === groupId ? { ...g, autocollapse } : g))) + } +} + +export type GroupEditorContext = { + groupEditor: GroupEditor + canCreateGroup: StateStore +} + +const CONTEXT_KEY = 'GroupEditorContext' + +export function setGroupEditorContext( + groupEditor: GroupEditor, + canCreateGroup: StateStore +): void { + setContext(CONTEXT_KEY, { groupEditor, canCreateGroup }) +} + +export function getGroupEditorContext(): GroupEditorContext | undefined { + return getContext(CONTEXT_KEY) +} + +/** Height of the group header bar */ +export const GROUP_HEADER_HEIGHT = 22 + +/** Extra margin between the header and the first node */ +export const GROUP_TOP_MARGIN = 30 + +export type GraphGroup = FlowGroup & { + id: string + moduleIds: string[] +} + +export type ContainerInnerArray = { + get: () => FlowModule[] + set: (v: any) => void + label?: string +} + +/** Get inner arrays from a container FlowModule with direct get/set accessors. */ +export function getContainerInnerArrays(mod: FlowModule): ContainerInnerArray[] { + const val = mod.value as any + if (val.type === 'forloopflow' || val.type === 'whileloopflow') { + return [ + { + get: () => val.modules, + set: (v) => { + val.modules = v + } + } + ] + } else if (val.type === 'branchone') { + return [ + { + get: () => val.default, + set: (v) => { + val.default = v + }, + label: 'Default' + }, + ...val.branches.map((b: any, i: number) => ({ + get: () => b.modules, + set: (v: any) => { + b.modules = v + }, + label: b.summary || `Branch ${i + 1}` + })) + ] + } else if (val.type === 'branchall') { + return val.branches.map((b: any, i: number) => ({ + get: () => b.modules, + set: (v: any) => { + b.modules = v + }, + label: b.summary || `Branch ${i + 1}` + })) + } + return [] +} diff --git a/frontend/src/lib/components/graph/groupedModulesProxy.svelte.ts b/frontend/src/lib/components/graph/groupedModulesProxy.svelte.ts new file mode 100644 index 0000000000..6965baf531 --- /dev/null +++ b/frontend/src/lib/components/graph/groupedModulesProxy.svelte.ts @@ -0,0 +1,181 @@ +import { untrack } from 'svelte' +import type { FlowModule } from '$lib/gen' +import { type FlowGroup, type GraphGroup, groupKey } from './groupEditor.svelte' +import type { StateStore } from '$lib/utils' +import { getAllModules } from '../flows/flowExplorer' +import { computeGroupModuleIds } from './groupDetectionUtils' +import { stateSnapshot } from '$lib/svelte5Utils.svelte' +import { + buildStructureTree, + deriveGroupsFromStructure, + applyStructureToModules, + removeEmptyGroups, + findDuplicateGroups, + removeDuplicateGroups, + flattenStructureIds, + type FlowStructureNode +} from './flowStructure' + +export type ExtendedOpenFlow = { + value: { + modules: FlowModule[] + groups?: FlowGroup[] + [key: string]: any + } + [key: string]: any +} + +/** + * Reactive read-only view of the flow structure tree. + * The tree is always derived from flowStore (single source of truth). + * Mutations go through prepareMutation: snapshot → mutate → clean empty groups → commit. + */ +export class GroupedModulesProxy { + #items = $state([]) + #error = $state(undefined) + #flowStore: StateStore + + constructor(flowStore: StateStore) { + this.#flowStore = flowStore + this.rebuild() + + // Rebuild tree whenever store changes (undo/load/mutation) + $effect(() => { + void flowStore.val.value.modules + void flowStore.val.value.groups + untrack(() => this.rebuild()) + }) + } + + /** Reactive access to the structure tree (read-only view) */ + get items(): FlowStructureNode[] { + return this.#items + } + + /** Reactive access to build errors */ + get error(): unknown { + return this.#error + } + + /** + * Prepare a structural mutation without writing to the store yet. + * Returns the list of groups that became empty (already removed from the snapshot) + * and a `commit` function that writes the result to the store. + * + * If no groups were emptied, the caller can commit immediately. + * If groups were emptied, the caller should show a confirmation modal + * and call commit() only on user confirmation. + */ + prepareMutation( + mutate: (tree: FlowStructureNode[]) => void, + opts?: { + extraModules?: FlowModule[] + displayState?: import('./groupEditor.svelte').GroupDisplayState + } + ): { + emptiedGroups: FlowGroup[] + duplicateGroups: FlowGroup[] + commit: (commitOpts?: { removeDuplicates?: boolean }) => void + } { + const snapshot = $state.snapshot(this.#items) as FlowStructureNode[] + mutate(snapshot) + + // Clean up empty groups and collect which ones were removed + const emptiedGroups = removeEmptyGroups(snapshot) + // Detect groups that became duplicates after the mutation + const duplicateGroups = findDuplicateGroups(snapshot) + + const commit = (commitOpts?: { removeDuplicates?: boolean }) => { + if (commitOpts?.removeDuplicates && duplicateGroups.length > 0) { + removeDuplicateGroups(snapshot) + } + + // Remap runtime state for groups whose boundaries shifted + if (opts?.displayState) { + this.#remapChangedGroupKeys(snapshot, opts.displayState) + } + + // Build moduleMap lazily at commit time so it reflects the latest store state + const moduleMap = new Map() + for (const m of getAllModules(this.#flowStore.val.value.modules)) { + moduleMap.set(m.id, m) + } + if (opts?.extraModules) { + for (const m of opts.extraModules) { + moduleMap.set(m.id, m) + } + } + this.#flowStore.val.value.modules = applyStructureToModules(snapshot, moduleMap) + this.#flowStore.val.value.groups = deriveGroupsFromStructure(snapshot) + } + + return { emptiedGroups, duplicateGroups, commit } + } + + /** + * Convenience: prepare + auto-commit. Only use for mutations that cannot + * empty groups (e.g. inserts). Throws if groups are unexpectedly emptied. + * For mutations that may empty groups, use prepareMutation() directly. + */ + applyTreeMutation( + mutate: (tree: FlowStructureNode[]) => void, + opts?: { + extraModules?: FlowModule[] + displayState?: import('./groupEditor.svelte').GroupDisplayState + } + ): void { + const { emptiedGroups, duplicateGroups, commit } = this.prepareMutation(mutate, opts) + if (emptiedGroups.length > 0) { + console.error('applyTreeMutation: unexpected empty groups', emptiedGroups) + } + if (duplicateGroups.length > 0) { + console.error('applyTreeMutation: unexpected duplicate groups', duplicateGroups) + } + commit() + } + + /** Remap runtime state for group nodes whose boundaries shifted after a mutation. */ + #remapChangedGroupKeys( + snapshot: FlowStructureNode[], + displayState: import('./groupEditor.svelte').GroupDisplayState + ): void { + const walk = (nodes: FlowStructureNode[]) => { + for (const node of nodes) { + if (node.kind === 'group') { + const oldKey = node.id + const flatIds = flattenStructureIds(node.branches[0].children) + const newKey = flatIds.length > 0 ? `${flatIds[0]}:${flatIds[flatIds.length - 1]}` : null + if (newKey && oldKey !== newKey) { + displayState.remapGroupKey(oldKey, newKey) + } + walk(node.branches[0].children) + } else { + for (const branch of node.branches) { + walk(branch.children) + } + } + } + } + walk(snapshot) + } + + /** Rebuild from flowStore */ + private rebuild(): void { + const modules = stateSnapshot(this.#flowStore.val.value.modules) as FlowModule[] + const allGroups = this.#flowStore.val.value.groups ?? [] + const allModules = getAllModules(modules) + const graphGroups: GraphGroup[] = allGroups.map((g) => ({ + ...g, + id: groupKey(g), + moduleIds: computeGroupModuleIds(g.start_id, g.end_id, allModules) + })) + try { + this.#items = buildStructureTree(modules, graphGroups) + this.#error = undefined + } catch (e) { + // Intentionally preserve last-known-good #items so the graph + // can still render while the error is surfaced to the user. + this.#error = e + } + } +} diff --git a/frontend/src/lib/components/graph/moveManager.svelte.ts b/frontend/src/lib/components/graph/moveManager.svelte.ts index 605c4b849a..1539cc0802 100644 --- a/frontend/src/lib/components/graph/moveManager.svelte.ts +++ b/frontend/src/lib/components/graph/moveManager.svelte.ts @@ -204,9 +204,6 @@ export class MoveManager { for (const [edgeId, zone] of this.#registeredDropZones) { if (zone.disableMoveIds.includes(draggedId)) continue - // Skip edges adjacent to the dragged node (no-op move) - if (zone.sourceId === draggedId || zone.targetId === draggedId) continue - const dx = Math.abs(flowPos.x - zone.centerX) const dy = Math.abs(flowPos.y - zone.centerY) diff --git a/frontend/src/lib/components/graph/nodeExtraSpace.ts b/frontend/src/lib/components/graph/nodeExtraSpace.ts new file mode 100644 index 0000000000..f51309a60b --- /dev/null +++ b/frontend/src/lib/components/graph/nodeExtraSpace.ts @@ -0,0 +1,153 @@ +import type { FlowNote } from '../../gen' +import type { AssetWithAltAccessType } from '../assets/lib' +import { + assetDisplaysAsInputInFlowGraph, + assetDisplaysAsOutputInFlowGraph, + NODE_WITH_READ_ASSET_Y_OFFSET, + NODE_WITH_WRITE_ASSET_Y_OFFSET +} from './renderers/nodes/AssetNode.svelte' +import { + AI_TOOL_BASE_OFFSET, + AI_TOOL_ROW_OFFSET, + BELOW_ADDITIONAL_OFFSET +} from './renderers/nodes/AIToolNode.svelte' +import { topologicalSort } from './graphBuilder.svelte' +import { GROUP_HEADER_HEIGHT } from './groupEditor.svelte' +import type { GroupDisplayState } from './groupEditor.svelte' +import type { GraphModuleState } from '.' + +type NodeDep = { + id: string + parentIds?: string[] + data?: { assets?: AssetWithAltAccessType[]; module?: any } +} + +type ExtraSpace = { top: number; bottom: number; left: number; right: number } + +const MAX_TOOLS_PER_ROW = 2 + +/** + * Pre-compute extra top/bottom space each node needs for decorations + * (assets, AI tools, group headers, group notes). + */ +export function computeNodeExtraSpace( + graphNodes: NodeDep[], + opts: { + showAssets: boolean + showNotes: boolean + notes: FlowNote[] | undefined + noteTextHeights: Record + groupDisplayState: GroupDisplayState + insertable: boolean + flowModuleStates: Record | undefined + } +): Map | undefined { + const extraSpace = new Map() + + // 1. Assets + if (opts.showAssets) { + for (const node of graphNodes) { + const assets = node.data?.assets ?? [] + if (!assets.length) continue + const hasRead = assets.some(assetDisplaysAsInputInFlowGraph) + const hasWrite = assets.some(assetDisplaysAsOutputInFlowGraph) + if (hasRead || hasWrite) { + const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 } + extraSpace.set(node.id, { + ...prev, + top: prev.top + (hasRead ? NODE_WITH_READ_ASSET_Y_OFFSET : 0), + bottom: prev.bottom + (hasWrite ? NODE_WITH_WRITE_ASSET_Y_OFFSET : 0) + }) + } + } + } + + // 2. AI tools + for (const node of graphNodes) { + const mod = node.data?.module + if (!mod || mod.value?.type !== 'aiagent') continue + + const agentActions = !opts.insertable && opts.flowModuleStates?.[node.id]?.agent_actions + + if (agentActions) { + // Execution mode: tools below + const totalRows = Math.ceil(agentActions.length / MAX_TOOLS_PER_ROW) + const space = AI_TOOL_BASE_OFFSET + AI_TOOL_ROW_OFFSET * totalRows + BELOW_ADDITIONAL_OFFSET + const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 } + extraSpace.set(node.id, { ...prev, bottom: prev.bottom + space }) + } else { + // Edit mode: tools above + const tools = mod.value.tools ?? [] + const totalRows = Math.ceil(tools.length / MAX_TOOLS_PER_ROW) + (opts.insertable ? 1 : 0) + const space = AI_TOOL_BASE_OFFSET + AI_TOOL_ROW_OFFSET * totalRows + const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 } + extraSpace.set(node.id, { ...prev, top: prev.top + space }) + } + } + + // Topological sort (reversed: top-of-graph first) — shared by group notes and group headers + const sortedNodes = topologicalSort(graphNodes).reverse() + + // 3. Group notes (text above topmost node in each group note) + if (opts.showNotes) { + const groupNotes = (opts.notes ?? []).filter((n) => n.type === 'group') + if (groupNotes.length > 0) { + for (const groupNote of groupNotes) { + if (!groupNote.contained_node_ids?.length) continue + const topmostNodeId = sortedNodes.find((node) => + groupNote.contained_node_ids?.includes(node.id) + )?.id + if (topmostNodeId) { + const textHeight = opts.noteTextHeights[groupNote.id] || 60 + const spacing = textHeight + 16 // padding + const prev = extraSpace.get(topmostNodeId) ?? { + top: 0, + bottom: 0, + left: 0, + right: 0 + } + extraSpace.set(topmostNodeId, { + ...prev, + top: Math.max(prev.top, spacing + prev.top) + }) + } + } + } + } + + // 4. Collapsed group nodes are taller than regular nodes (header + module icons) + for (const node of graphNodes) { + if (node.id.startsWith('collapsed-group:')) { + const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 } + extraSpace.set(node.id, { + ...prev, + bottom: prev.bottom + GROUP_HEADER_HEIGHT + }) + } + } + + // 5. Group nodes (expanded heads and collapsed) with notes need extra height + if (opts.showNotes) { + const noteHeights = opts.groupDisplayState.getNoteHeights() + for (const node of graphNodes) { + let groupId: string | undefined + if (node.id.startsWith('group:') && !node.id.endsWith('-end')) { + groupId = node.id.slice('group:'.length) + } else if (node.id.startsWith('collapsed-group:')) { + groupId = node.id.slice('collapsed-group:'.length) + } + if (groupId) { + const noteHeight = noteHeights[groupId] + if (noteHeight && noteHeight > 0) { + const prev = extraSpace.get(node.id) ?? { top: 0, bottom: 0, left: 0, right: 0 } + extraSpace.set(node.id, { + ...prev, + bottom: prev.bottom + noteHeight + }) + } + } + } + } + + return extraSpace.size > 0 ? extraSpace : undefined +} diff --git a/frontend/src/lib/components/graph/noteColors.ts b/frontend/src/lib/components/graph/noteColors.ts index f9024adf7e..2a82ed1f40 100644 --- a/frontend/src/lib/components/graph/noteColors.ts +++ b/frontend/src/lib/components/graph/noteColors.ts @@ -14,6 +14,7 @@ export enum NoteColor { export interface NoteColorConfig { background: string + backgroundLight: string outline: string outlineHover: string text: string @@ -24,70 +25,80 @@ export interface NoteColorConfig { export const NOTE_COLORS: Record = { [NoteColor.YELLOW]: { background: 'bg-yellow-200 dark:bg-yellow-900', - outline: 'outline-yellow-300 dark:outline-yellow-600', + backgroundLight: 'bg-yellow-400/5 dark:bg-yellow-600/5', + outline: 'outline-yellow-200 dark:outline-yellow-900', outlineHover: 'outline-yellow-300/60 dark:outline-yellow-600/60', text: 'text-yellow-900 dark:text-yellow-100', hover: 'hover:bg-yellow-200 dark:hover:bg-yellow-800' }, [NoteColor.BLUE]: { background: 'bg-blue-100 dark:bg-blue-950', - outline: 'outline-blue-300 dark:outline-blue-600', + backgroundLight: 'bg-blue-400/5 dark:bg-blue-600/5', + outline: 'outline-blue-100 dark:outline-blue-950', outlineHover: 'outline-blue-300/60 dark:outline-blue-600/60', text: 'text-blue-900 dark:text-blue-100', hover: 'hover:bg-blue-200 dark:hover:bg-blue-800' }, [NoteColor.GREEN]: { background: 'bg-green-200 dark:bg-green-900', - outline: 'outline-green-300 dark:outline-green-600', + backgroundLight: 'bg-green-400/5 dark:bg-green-600/5', + outline: 'outline-green-200 dark:outline-green-900', outlineHover: 'outline-green-300/60 dark:outline-green-600/60', text: 'text-green-900 dark:text-green-100', hover: 'hover:bg-green-200 dark:hover:bg-green-800' }, [NoteColor.PURPLE]: { background: 'bg-purple-200 dark:bg-purple-900', - outline: 'outline-purple-300 dark:outline-purple-600', + backgroundLight: 'bg-purple-400/5 dark:bg-purple-600/5', + outline: 'outline-purple-200 dark:outline-purple-900', outlineHover: 'outline-purple-300/60 dark:outline-purple-600/60', text: 'text-purple-900 dark:text-purple-100', hover: 'hover:bg-purple-200 dark:hover:bg-purple-800' }, [NoteColor.PINK]: { background: 'bg-pink-200 dark:bg-pink-900', - outline: 'outline-pink-300 dark:outline-pink-600', + backgroundLight: 'bg-pink-400/5 dark:bg-pink-600/5', + outline: 'outline-pink-200 dark:outline-pink-900', outlineHover: 'outline-pink-300/60 dark:outline-pink-600/60', text: 'text-pink-900 dark:text-pink-100', hover: 'hover:bg-pink-200 dark:hover:bg-pink-800' }, [NoteColor.ORANGE]: { background: 'bg-orange-200 dark:bg-orange-900', - outline: 'outline-orange-300 dark:outline-orange-600', + backgroundLight: 'bg-orange-400/5 dark:bg-orange-600/5', + outline: 'outline-orange-200 dark:outline-orange-900', outlineHover: 'outline-orange-300/60 dark:outline-orange-600/60', text: 'text-orange-900 dark:text-orange-100', hover: 'hover:bg-orange-200 dark:hover:bg-orange-800' }, [NoteColor.RED]: { background: 'bg-red-200 dark:bg-red-900', - outline: 'outline-red-300 dark:outline-red-600', + backgroundLight: 'bg-red-400/5 dark:bg-red-600/5', + outline: 'outline-red-200 dark:outline-red-900', outlineHover: 'outline-red-300/60 dark:outline-red-600/60', text: 'text-red-900 dark:text-red-100', hover: 'hover:bg-red-200 dark:hover:bg-red-800' }, [NoteColor.CYAN]: { background: 'bg-cyan-200 dark:bg-cyan-900', - outline: 'outline-cyan-300 dark:outline-cyan-600', + backgroundLight: 'bg-cyan-400/5 dark:bg-cyan-600/5', + outline: 'outline-cyan-200 dark:outline-cyan-900', outlineHover: 'outline-cyan-300/60 dark:outline-cyan-600/60', text: 'text-cyan-900 dark:text-cyan-100', hover: 'hover:bg-cyan-200 dark:hover:bg-cyan-800' }, [NoteColor.LIME]: { background: 'bg-lime-200 dark:bg-lime-900', - outline: 'outline-lime-300 dark:outline-lime-600', + backgroundLight: 'bg-lime-400/5 dark:bg-lime-600/5', + outline: 'outline-lime-200 dark:outline-lime-900', outlineHover: 'outline-lime-300/60 dark:outline-lime-600/60', text: 'text-lime-900 dark:text-lime-100', hover: 'hover:bg-lime-200 dark:hover:bg-lime-800' }, [NoteColor.GRAY]: { background: 'bg-gray-200 dark:bg-gray-800', - outline: 'outline-gray-300 dark:outline-gray-600', + backgroundLight: 'bg-gray-400/5 dark:bg-gray-600/5', + outline: 'outline-gray-200 dark:outline-gray-800', outlineHover: 'outline-gray-300/60 dark:outline-gray-600/60', text: 'text-gray-900 dark:text-gray-100', hover: 'hover:bg-gray-200 dark:hover:bg-gray-700' diff --git a/frontend/src/lib/components/graph/noteUtils.svelte.ts b/frontend/src/lib/components/graph/noteUtils.svelte.ts index 3350e07ce8..bd7fc2ce05 100644 --- a/frontend/src/lib/components/graph/noteUtils.svelte.ts +++ b/frontend/src/lib/components/graph/noteUtils.svelte.ts @@ -19,20 +19,6 @@ export type NodeDep = { export type NoteComputeResult = { noteNodes: (Node & NodeLayout)[] - newNodePositions: Record -} - -export type AIToolSpacingInfo = { - toolNodes: (Node & NodeLayout)[] - toolEdges: any[] - newNodePositions: Record -} - -export interface GroupNoteBounds { - x: number - y: number - width: number - height: number } let computeNoteNodesCache: @@ -283,14 +269,9 @@ export function computeNoteNodes( const allNoteNodes: (Node & NodeLayout)[] = [] - // Build a map of Y positions that need extra spacing for group notes - const yPosMap: Record = {} // Y position -> spacing needed - - // Group notes that need spacing + // Find topmost node per group note for layout calculation const groupNotes = notes.filter((n) => n.type === 'group') - const topMostNodesMap: Record = {} - const sortedNodes = topologicalSort(nodes).reverse() for (const groupNote of groupNotes) { @@ -298,47 +279,12 @@ export function computeNoteNodes( const topmostNodeId = sortedNodes.find((node) => groupNote.contained_node_ids?.includes(node.id) )?.id - const topmostNode = nodes.find((node) => node.id === topmostNodeId) - if (topmostNode) { - const textHeight = noteTextHeights[groupNote.id] || 60 - const spacing = textHeight + 16 // padding - // Mark this Y position as needing spacing - yPosMap[topmostNode.position.y] = Math.max(yPosMap[topmostNode.position.y] || 0, spacing) - topMostNodesMap[groupNote.id] = topmostNode.id + if (topmostNodeId) { + topMostNodesMap[groupNote.id] = topmostNodeId } } } - // Calculate new positions for nodes (offset by group notes) - const sortedNewNodes = nodes - .map((n) => ({ position: { ...n.position }, id: n.id })) - .sort((a, b) => a.position.y - b.position.y) - - let currentYOffset = 0 - let prevYPos = NaN - - for (const node of sortedNewNodes) { - if (node.position.y !== prevYPos) { - // Add spacing for group notes at this Y level - if (yPosMap[node.position.y]) { - currentYOffset += yPosMap[node.position.y] - } - prevYPos = node.position.y - } - node.position.y += currentYOffset - } - - // Create note nodes AFTER calculating adjusted node positions - // For group notes, we need to use the adjusted node positions - const adjustedNodes = sortedNewNodes.map((n) => { - const origNode = nodes.find((orig) => orig.id === n.id) - return { - ...n, - data: origNode?.data, - type: origNode?.type - } - }) - // Calculate all z-indexes at once using hierarchy information const noteZIndexes = calculateAllNoteZIndexes(notes, nodes) @@ -346,11 +292,11 @@ export function computeNoteNodes( const isGroupNote = note.type === 'group' const zIndex = noteZIndexes[note.id] - // Calculate position and size using adjusted node positions for group notes + // Calculate position and size using node positions for group notes const { position, size } = isGroupNote ? calculateGroupNoteLayout( note, - adjustedNodes, + nodes, noteTextHeights[note.id] || 60, topMostNodesMap[note.id] ) @@ -375,13 +321,8 @@ export function computeNoteNodes( allNoteNodes.push(noteNode) } - const newNodePositions: Record = Object.fromEntries( - sortedNewNodes.map((n) => [n.id, n.position]) - ) - const result: NoteComputeResult = { - noteNodes: allNoteNodes, - newNodePositions + noteNodes: allNoteNodes } // Cache the result diff --git a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte index 8f1ca0017f..5fbf084374 100644 --- a/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte +++ b/frontend/src/lib/components/graph/renderers/edges/BaseEdge.svelte @@ -12,11 +12,14 @@ import type { GraphModuleState } from '../../model' import InsertModuleButton from '$lib/components/flows/map/InsertModuleButton.svelte' import { getGraphContext } from '../../graphContext' + import { GROUP_TOP_PADDING } from '$lib/components/graph/compoundLayout' const { useDataflow, showAssets, moveManager } = getGraphContext() let { id, + source, + target, sourceX, sourceY, sourcePosition, @@ -45,6 +48,13 @@ } } = $props() + // Derive group boundary from source/target node IDs + let groupBoundary: 'top' | 'bottom' | undefined = $derived.by(() => { + if (source.startsWith('group:') && !source.endsWith('-end')) return 'top' + if (target.startsWith('group:') && target.endsWith('-end')) return 'bottom' + return undefined + }) + let [edgePath] = $derived( getBezierPath({ sourceX, @@ -75,9 +85,15 @@ ) let centerY = $derived( - sourceY + - 32 + - (data.shouldOffsetInsertBtnDueToAssetNode && $showAssets ? NODE_WITH_WRITE_ASSET_Y_OFFSET : 0) + groupBoundary === 'bottom' + ? targetY + : groupBoundary === 'top' + ? sourceY + GROUP_TOP_PADDING / 2 + : sourceY + + 32 + + (data.shouldOffsetInsertBtnDueToAssetNode && $showAssets + ? NODE_WITH_WRITE_ASSET_Y_OFFSET + : 0) ) let isDragging = $derived(!!moveManager?.dragging) @@ -87,13 +103,13 @@ data?.insertable && draggedId !== undefined && !data.disableMoveIds?.includes(draggedId) && - data.sourceId !== draggedId && - data.targetId !== draggedId + source !== draggedId && + target !== draggedId ) - let isNearestDrop = $derived(isValidDropTarget && moveManager?.nearestDropZone?.edgeId === id) - let isAdjacentToDragged = $derived( - isDragging && (data?.sourceId === draggedId || data?.targetId === draggedId) + let isNearestDrop = $derived( + isValidDropTarget && moveManager?.nearestDropZone?.edgeId === id ? true : false ) + let isAdjacentToDragged = $derived(isDragging && (source === draggedId || target === draggedId)) // Register this edge's drop zone position with the drag manager so proximity // detection uses the actual xyflow-computed position rather than re-deriving it. @@ -161,7 +177,7 @@ {@render dropTargetIndicator(isNearestDrop)}
- {:else if data?.insertable && !$useDataflow && !moveManager?.movingModuleId && !isDragging} + {:else if data?.insertable && !groupBoundary && !$useDataflow && !moveManager?.movingModuleId && !isDragging}
- {#if !(moveManager.movingIds ?? [moveManager.movingModuleId]).some((id) => data.disableMoveIds?.includes(id))} + {#if !(moveManager.movingIds ?? [moveManager.movingModuleId]).some( (id) => data.disableMoveIds?.includes(id) )} - {/if} -
+
+ {/if} {/snippet} diff --git a/frontend/src/lib/components/graph/renderers/triggers/TriggersWrapper.svelte b/frontend/src/lib/components/graph/renderers/triggers/TriggersWrapper.svelte index 2ffa6a8d49..ccc5643981 100644 --- a/frontend/src/lib/components/graph/renderers/triggers/TriggersWrapper.svelte +++ b/frontend/src/lib/components/graph/renderers/triggers/TriggersWrapper.svelte @@ -53,7 +53,7 @@
+
+ {#if hubSyncStatus === 'success'} +
+ + {hubSyncMessage} + +
+ {:else if hubSyncStatus === 'error'} +
+ + {hubSyncMessage} + +
+ {/if} + {/if} + + +{/if} diff --git a/frontend/src/lib/components/runs/JobRunsPreview.svelte b/frontend/src/lib/components/runs/JobRunsPreview.svelte index 62722daac8..7827726891 100644 --- a/frontend/src/lib/components/runs/JobRunsPreview.svelte +++ b/frontend/src/lib/components/runs/JobRunsPreview.svelte @@ -163,6 +163,7 @@ stepResults={getStepResults(job.workflow_as_code_status)} result={(job as any).result} success={(job as any).success !== false} + jobId={job.id} />
{/if} diff --git a/frontend/src/lib/components/scriptEditor/LogPanel.svelte b/frontend/src/lib/components/scriptEditor/LogPanel.svelte index 271e2a765c..82845c4a88 100644 --- a/frontend/src/lib/components/scriptEditor/LogPanel.svelte +++ b/frontend/src/lib/components/scriptEditor/LogPanel.svelte @@ -158,6 +158,7 @@ result={previewJob?.result} success={previewJob?.success !== false} autoExpandResult + jobId={previewJob?.id} />
{:else} diff --git a/frontend/src/lib/components/select/SelectDropdown.svelte b/frontend/src/lib/components/select/SelectDropdown.svelte index 62a990bb46..d9fc2dcf70 100644 --- a/frontend/src/lib/components/select/SelectDropdown.svelte +++ b/frontend/src/lib/components/select/SelectDropdown.svelte @@ -117,7 +117,7 @@ ulClass )} > - {#each processedItems ?? [] as item, itemIndex (item.value)} + {#each processedItems ?? [] as item, itemIndex} {#if (item.__select_group && itemIndex === 0) || processedItems?.[itemIndex - 1]?.__select_group !== item.__select_group}
  • void onReset: () => void hasChanges: boolean - isWorkspaceSettings?: boolean + scope?: 'user' | 'workspace' | 'instance' } let { @@ -22,7 +22,7 @@ onSave, onReset, hasChanges, - isWorkspaceSettings = false + scope = 'user' }: Props = $props() const placeholders: Record = { @@ -63,9 +63,12 @@
    - {#if isWorkspaceSettings} + {#if scope === 'workspace'} Customize the system prompts for each AI mode. These prompts apply to all workspace members. + {:else if scope === 'instance'} + Customize the system prompts for each AI mode. These prompts apply to workspaces using + instance AI defaults. {:else} Customize the system prompts for each AI mode. These prompts are stored locally in your browser and apply in addition to workspace-level prompts. diff --git a/frontend/src/lib/components/triggers/PermissionedAsLine.svelte b/frontend/src/lib/components/triggers/PermissionedAsLine.svelte new file mode 100644 index 0000000000..23723b7833 --- /dev/null +++ b/frontend/src/lib/components/triggers/PermissionedAsLine.svelte @@ -0,0 +1,84 @@ + + +{#if permissionedAs && $workspaceStore} +
    + Permissioned as + {#if canPreserve} + + {#if willChange} + + will change from {permissionedAs} on save + {/if} + {:else} + {permissionedAs} + {#if willChange} + + will change to {effectivePermissionedAs} on save + {/if} + {/if} +
    +{/if} diff --git a/frontend/src/lib/components/triggers/TriggerEditorToolbar.svelte b/frontend/src/lib/components/triggers/TriggerEditorToolbar.svelte index a09491e0ba..52c68aeed9 100644 --- a/frontend/src/lib/components/triggers/TriggerEditorToolbar.svelte +++ b/frontend/src/lib/components/triggers/TriggerEditorToolbar.svelte @@ -118,7 +118,7 @@ {trigger?.isDraft ? 'Deploy' : 'Update'} {#snippet text()} - + {#if !isDeployed} Deploy the runnable to enable trigger creation {:else if cloudDisabled} @@ -127,7 +127,7 @@ Enter a valid config to {trigger?.isDraft ? 'deploy' : 'update'} the trigger {/if} - {/snippet} + {/snippet} {/if}
    diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte index a33f14243b..9e0f58cd6c 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte @@ -20,6 +20,7 @@ import Label from '$lib/components/Label.svelte' import EmailTriggerEditorConfigSection from './EmailTriggerEditorConfigSection.svelte' import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import PermissionedAsLine from '../PermissionedAsLine.svelte' import { getHandlerType, handleConfigChange } from '../utils' import { untrack } from 'svelte' import Tabs from '$lib/components/common/tabs/Tabs.svelte' @@ -75,6 +76,9 @@ let drawer = $state(undefined) let initialConfig: NewEmailTrigger | undefined = undefined let deploymentLoading = $state(false) + let permissionedAs = $state(undefined) + let selectedPermissionedAs = $state(undefined) + let preservePermissionedAs = $state(false) let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler') let errorHandlerSelected: ErrorHandler = $state('slack') @@ -181,6 +185,9 @@ retry = cfg?.retry errorHandlerSelected = getHandlerType(error_handler_path ?? '') mode = cfg?.mode ?? 'enabled' + permissionedAs = cfg?.permissioned_as + selectedPermissionedAs = undefined + preservePermissionedAs = false } async function loadTrigger(defaultConfig?: Partial): Promise { @@ -236,7 +243,9 @@ error_handler_path, error_handler_args, retry, - mode + mode, + permissioned_as: selectedPermissionedAs, + preserve_permissioned_as: preservePermissionedAs || undefined } return nCfg @@ -304,6 +313,15 @@ {/if} {:else} + {#if edit} + { + selectedPermissionedAs = pa + preservePermissionedAs = preserve + }} + /> + {/if}
    {#if mode === 'suspended'} diff --git a/frontend/src/lib/components/triggers/email/utils.ts b/frontend/src/lib/components/triggers/email/utils.ts index 314990b1bb..05c15bcb81 100644 --- a/frontend/src/lib/components/triggers/email/utils.ts +++ b/frontend/src/lib/components/triggers/email/utils.ts @@ -30,7 +30,9 @@ export async function saveEmailTriggerFromCfg( error_handler_path: emailCfg.error_handler_path, error_handler_args: emailCfg.error_handler_path ? emailCfg.error_handler_args : undefined, mode: emailCfg.mode, - retry: emailCfg.retry + retry: emailCfg.retry, + permissioned_as: emailCfg.permissioned_as, + preserve_permissioned_as: emailCfg.preserve_permissioned_as } try { if (edit) { diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte index 6a11d6180b..1c13e74790 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte @@ -22,6 +22,7 @@ import GcpTriggerEditorConfigSection from './GcpTriggerEditorConfigSection.svelte' import { untrack, type Snippet } from 'svelte' import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import PermissionedAsLine from '../PermissionedAsLine.svelte' import { saveGcpTriggerFromCfg } from './utils' import { getHandlerType, handleConfigChange, type Trigger } from '../utils' import { deepEqual } from 'fast-equals' @@ -57,6 +58,9 @@ let subscription_mode: SubscriptionMode = $state('create_update') let initialConfig: Record | undefined = undefined let deploymentLoading = $state(false) + let permissionedAs = $state(undefined) + let selectedPermissionedAs = $state(undefined) + let preservePermissionedAs = $state(false) let base_endpoint = $derived(`${window.location.origin}${base}`) let auto_acknowledge_msg = $state(true) let ack_deadline: number | undefined = $state() @@ -202,6 +206,9 @@ auto_acknowledge_msg = cfg?.auto_acknowledge_msg ?? true ack_deadline = cfg?.ack_deadline errorHandlerSelected = getHandlerType(error_handler_path ?? '') + permissionedAs = cfg?.permissioned_as + selectedPermissionedAs = undefined + preservePermissionedAs = false } async function updateTrigger(): Promise { @@ -246,7 +253,9 @@ error_handler_args, retry, auto_acknowledge_msg, - ack_deadline + ack_deadline, + permissioned_as: selectedPermissionedAs, + preserve_permissioned_as: preservePermissionedAs || undefined } } @@ -369,6 +378,15 @@

    Loading...

    {:else} + {#if edit} + { + selectedPermissionedAs = pa + preservePermissionedAs = preserve + }} + /> + {/if}
    {#if mode === 'suspended'} @@ -454,9 +472,11 @@
    {#snippet header()} - + {/snippet}
    diff --git a/frontend/src/lib/components/triggers/gcp/utils.ts b/frontend/src/lib/components/triggers/gcp/utils.ts index ec8a93942e..b55d4ac50a 100644 --- a/frontend/src/lib/components/triggers/gcp/utils.ts +++ b/frontend/src/lib/components/triggers/gcp/utils.ts @@ -32,6 +32,8 @@ export async function saveGcpTriggerFromCfg( is_flow: cfg.is_flow, auto_acknowledge_msg: cfg.auto_acknowledge_msg, ack_deadline: cfg.ack_deadline, + permissioned_as: cfg.permissioned_as, + preserve_permissioned_as: cfg.preserve_permissioned_as, ...errorHandlerAndRetries } if (edit) { diff --git a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte index 272a0b707c..a0a6a95336 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte @@ -46,6 +46,7 @@ import RouteBodyTransformerOption from './RouteBodyTransformerOption.svelte' import TestingBadge from '../testingBadge.svelte' import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import PermissionedAsLine from '../PermissionedAsLine.svelte' import { getHandlerType, handleConfigChange } from '../utils' import autosize from '$lib/autosize' import { untrack } from 'svelte' @@ -122,6 +123,9 @@ let drawer = $state(undefined) let initialConfig: NewHttpTrigger | undefined = undefined let deploymentLoading = $state(false) + let permissionedAs = $state(undefined) + let selectedPermissionedAs = $state(undefined) + let preservePermissionedAs = $state(false) let optionTabSelected: 'request_options' | 'error_handler' | 'retries' = $state('request_options') let errorHandlerSelected: ErrorHandler = $state('slack') @@ -315,6 +319,9 @@ error_handler_args = cfg?.error_handler_args ?? {} retry = cfg?.retry errorHandlerSelected = getHandlerType(error_handler_path ?? '') + permissionedAs = cfg?.permissioned_as + selectedPermissionedAs = undefined + preservePermissionedAs = false } async function loadTrigger(defaultConfig?: Partial): Promise { @@ -388,7 +395,9 @@ description: routeDescription, error_handler_path, error_handler_args, - retry + retry, + permissioned_as: selectedPermissionedAs, + preserve_permissioned_as: preservePermissionedAs || undefined } return nCfg @@ -481,6 +490,15 @@ {/if} {:else} + {#if edit} + { + selectedPermissionedAs = pa + preservePermissionedAs = preserve + }} + /> + {/if}
    {#if mode === 'suspended'} @@ -697,11 +715,15 @@ {#if !is_static_website}
    {#snippet header()} - + {/snippet}
    diff --git a/frontend/src/lib/components/triggers/http/utils.ts b/frontend/src/lib/components/triggers/http/utils.ts index 1a00cfd081..a9ffca8e7e 100644 --- a/frontend/src/lib/components/triggers/http/utils.ts +++ b/frontend/src/lib/components/triggers/http/utils.ts @@ -61,7 +61,9 @@ export async function saveHttpRouteFromCfg( error_handler_path: routeCfg.error_handler_path, error_handler_args: routeCfg.error_handler_path ? routeCfg.error_handler_args : undefined, retry: routeCfg.retry, - mode: routeCfg.mode + mode: routeCfg.mode, + permissioned_as: routeCfg.permissioned_as, + preserve_permissioned_as: routeCfg.preserve_permissioned_as } try { if (edit) { diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte index 5f68f03705..dbf82de5db 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte @@ -15,6 +15,7 @@ import KafkaTriggersConfigSection from './KafkaTriggersConfigSection.svelte' import { untrack, type Snippet } from 'svelte' import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import PermissionedAsLine from '../PermissionedAsLine.svelte' import { saveKafkaTriggerFromCfg } from './utils' import { getHandlerType, handleConfigChange, type Trigger } from '../utils' import Tabs from '$lib/components/common/tabs/Tabs.svelte' @@ -87,6 +88,9 @@ let autoOffsetReset = $state('latest') let autoCommit = $state(true) let deploymentLoading = $state(false) + let permissionedAs = $state(undefined) + let selectedPermissionedAs = $state(undefined) + let preservePermissionedAs = $state(false) let resetLoading = $state(false) let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler') let errorHandlerSelected: ErrorHandler = $state('slack') @@ -216,6 +220,9 @@ retry = cfg?.retry filters = cfg?.filters ?? [] errorHandlerSelected = getHandlerType(error_handler_path ?? '') + permissionedAs = cfg?.permissioned_as + selectedPermissionedAs = undefined + preservePermissionedAs = false } async function loadTrigger(defaultConfig?: Record): Promise { @@ -246,7 +253,9 @@ extra_perms: extra_perms, error_handler_path, error_handler_args, - retry + retry, + permissioned_as: selectedPermissionedAs, + preserve_permissioned_as: preservePermissionedAs || undefined } } @@ -414,6 +423,15 @@ {/if} {:else} + {#if edit} + { + selectedPermissionedAs = pa + preservePermissionedAs = preserve + }} + /> + {/if}
    {#if description} {@render description()} @@ -531,11 +549,10 @@ Offsets will not be committed automatically. Use wmill.commit_kafka_offsets(trigger_path, topic, partition, offset) - in Python or wmill.commitKafkaOffsets(triggerPath, topic, partition, offset) in TypeScript with the values from the event payload. The consumer collects - all pending commits and commits the highest offset for each topic/partition - pair. + in Python or + wmill.commitKafkaOffsets(triggerPath, topic, partition, offset) in TypeScript + with the values from the event payload. The consumer collects all pending commits and + commits the highest offset for each topic/partition pair. {/if}
    diff --git a/frontend/src/lib/components/triggers/kafka/utils.ts b/frontend/src/lib/components/triggers/kafka/utils.ts index 12ee49ac24..df05709d72 100644 --- a/frontend/src/lib/components/triggers/kafka/utils.ts +++ b/frontend/src/lib/components/triggers/kafka/utils.ts @@ -26,7 +26,9 @@ export async function saveKafkaTriggerFromCfg( filters: cfg.filters ?? [], auto_offset_reset: cfg.auto_offset_reset ?? 'latest', auto_commit: cfg.auto_commit ?? true, - ...errorHandlerAndRetries + ...errorHandlerAndRetries, + permissioned_as: cfg.permissioned_as, + preserve_permissioned_as: cfg.preserve_permissioned_as } try { if (edit) { diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte index df5c7c21e1..7fa941b8ff 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte @@ -24,6 +24,7 @@ import MqttEditorConfigSection from './MqttEditorConfigSection.svelte' import type { Snippet } from 'svelte' import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import PermissionedAsLine from '../PermissionedAsLine.svelte' import { saveMqttTriggerFromCfg } from './utils' import { getHandlerType, handleConfigChange, type Trigger } from '../utils' import Tabs from '$lib/components/common/tabs/Tabs.svelte' @@ -98,6 +99,9 @@ let isValid: boolean = $state(false) let initialConfig: Record | undefined = {} let deploymentLoading = $state(false) + let permissionedAs = $state(undefined) + let selectedPermissionedAs = $state(undefined) + let preservePermissionedAs = $state(false) let errorHandlerSelected: ErrorHandler = $state('slack') let error_handler_path: string | undefined = $state() let error_handler_args: Record = $state({}) @@ -215,6 +219,9 @@ errorHandlerSelected = getHandlerType(error_handler_path ?? '') activateV5Options.topic_alias_maximum = Boolean(v5_config.topic_alias_maximum) activateV5Options.session_expiry_interval = Boolean(v5_config.session_expiry_interval) + permissionedAs = cfg?.permissioned_as + selectedPermissionedAs = undefined + preservePermissionedAs = false } catch (error) { sendUserToast(`Could not load mqtt trigger config: ${error.body}`, true) } @@ -251,7 +258,9 @@ is_flow, error_handler_path, error_handler_args, - retry + retry, + permissioned_as: selectedPermissionedAs, + preserve_permissioned_as: preservePermissionedAs || undefined } } @@ -392,6 +401,15 @@ {/if} {:else} + {#if edit} + { + selectedPermissionedAs = pa + preservePermissionedAs = preserve + }} + /> + {/if}
    {#if description} {@render description()} @@ -471,10 +489,14 @@
    {#snippet header()} - + {/snippet}
    diff --git a/frontend/src/lib/components/triggers/mqtt/utils.ts b/frontend/src/lib/components/triggers/mqtt/utils.ts index 535c4caade..36b79d53a8 100644 --- a/frontend/src/lib/components/triggers/mqtt/utils.ts +++ b/frontend/src/lib/components/triggers/mqtt/utils.ts @@ -27,6 +27,8 @@ export async function saveMqttTriggerFromCfg( script_path: cfg.script_path, is_flow: cfg.is_flow, mode: cfg.mode, + permissioned_as: cfg.permissioned_as, + preserve_permissioned_as: cfg.preserve_permissioned_as, ...errorHandlerAndRetries } try { diff --git a/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte b/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte index 7334f0a62e..b6e28875ba 100644 --- a/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte @@ -11,6 +11,7 @@ import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, emptyString, sendUserToast } from '$lib/utils' import { Button } from '$lib/components/common' + import TextInput from '$lib/components/text_input/TextInput.svelte' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import { Loader2, Save } from 'lucide-svelte' @@ -94,6 +95,7 @@ let initialScriptPath = $state('') let fixedScriptPath = $state('') let isFlow = $state(false) + let summary = $state('') let externalId = $state(null) let can_write = $state(true) let originalConfig = $state | undefined>(undefined) @@ -123,6 +125,7 @@ can_write = true originalConfig = undefined initialConfig = undefined + summary = '' } export function openRecreate(nativeTrigger: ExtendedNativeTrigger) { @@ -146,6 +149,7 @@ can_write = true originalConfig = undefined initialConfig = undefined + summary = nativeTrigger.summary ?? '' } export async function openEdit( @@ -182,6 +186,7 @@ scriptPath = fullTrigger.script_path initialScriptPath = fullTrigger.script_path can_write = canWrite(fullTrigger.script_path, {}, $userStore) + summary = fullTrigger.summary ?? '' externalData = fullTrigger.external_data // Apply default values if provided (for draft triggers) @@ -203,7 +208,8 @@ return { script_path: scriptPath, is_flow: isFlow, - service_config: serviceConfig + service_config: serviceConfig, + summary: summary !== '' ? summary : undefined } } @@ -386,6 +392,22 @@ {/if}
    +
    +
    + +
    +
    + {#if !hideTarget}

    diff --git a/frontend/src/lib/components/triggers/native/NativeTriggerTable.svelte b/frontend/src/lib/components/triggers/native/NativeTriggerTable.svelte index 6314c12e82..8f8b84cd57 100644 --- a/frontend/src/lib/components/triggers/native/NativeTriggerTable.svelte +++ b/frontend/src/lib/components/triggers/native/NativeTriggerTable.svelte @@ -98,20 +98,31 @@ {@html trigger.marked} {:else} - {trigger.script_path} + {trigger.summary || trigger.script_path} {/if}

    + {#if trigger.summary} +
    + {trigger.script_path} +
    + {/if} {#if service === 'google'} {@const triggerType = trigger.service_config?.triggerType} {@const resourceName = trigger.service_config?.resourceName} {@const calendarName = trigger.service_config?.calendarName} -
    +
    {#if triggerType === 'calendar'} Calendar: {calendarName || trigger.service_config?.calendarId || ''} {:else} - Drive: {resourceName ? resourceName : trigger.service_config?.resourceId ? trigger.service_config.resourceId : 'All changes'} + Drive: {resourceName + ? resourceName + : trigger.service_config?.resourceId + ? trigger.service_config.resourceId + : 'All changes'} {/if}
    {/if} diff --git a/frontend/src/lib/components/triggers/native/utils.ts b/frontend/src/lib/components/triggers/native/utils.ts index 34b1221b26..92082882e4 100644 --- a/frontend/src/lib/components/triggers/native/utils.ts +++ b/frontend/src/lib/components/triggers/native/utils.ts @@ -111,7 +111,7 @@ export function validateCommonFields(config: Record): Record(undefined) + let selectedPermissionedAs = $state(undefined) + let preservePermissionedAs = $state(false) let isValid = $state(false) let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler') let errorHandlerSelected: ErrorHandler = $state('slack') @@ -201,6 +205,9 @@ error_handler_args = cfg?.error_handler_args ?? {} retry = cfg?.retry errorHandlerSelected = getHandlerType(error_handler_path ?? '') + permissionedAs = cfg?.permissioned_as + selectedPermissionedAs = undefined + preservePermissionedAs = false } async function loadTrigger(defaultConfig?: Record): Promise { @@ -229,7 +236,9 @@ use_jetstream: natsCfg.use_jetstream, error_handler_path, error_handler_args, - retry + retry, + permissioned_as: selectedPermissionedAs, + preserve_permissioned_as: preservePermissionedAs || undefined } } @@ -376,6 +385,15 @@ {/if} {:else} + {#if edit} + { + selectedPermissionedAs = pa + preservePermissionedAs = preserve + }} + /> + {/if}
    {#if description} {@render description()} diff --git a/frontend/src/lib/components/triggers/nats/utils.ts b/frontend/src/lib/components/triggers/nats/utils.ts index 4376bef354..67336e390b 100644 --- a/frontend/src/lib/components/triggers/nats/utils.ts +++ b/frontend/src/lib/components/triggers/nats/utils.ts @@ -25,6 +25,8 @@ export async function saveNatsTriggerFromCfg( consumer_name: cfg.consumer_name, subjects: cfg.subjects, use_jetstream: cfg.use_jetstream, + permissioned_as: cfg.permissioned_as, + preserve_permissioned_as: cfg.preserve_permissioned_as, ...errorHandlerAndRetries } try { diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte index 5ffe489a22..a4f460d96b 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte @@ -33,6 +33,7 @@ import { base } from '$lib/base' import { untrack, type Snippet } from 'svelte' import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import PermissionedAsLine from '../PermissionedAsLine.svelte' import TestingBadge from '../testingBadge.svelte' import { getHandlerType, handleConfigChange, type Trigger } from '../utils' import TriggerRetriesAndErrorHandler from '../TriggerRetriesAndErrorHandler.svelte' @@ -113,6 +114,9 @@ let basic_mode = $derived(tab === 'basic') let initialConfig: Record | undefined = undefined let deploymentLoading = $state(false) + let permissionedAs = $state(undefined) + let selectedPermissionedAs = $state(undefined) + let preservePermissionedAs = $state(false) let creatingSlot: boolean = $state(false) let creatingPublication: boolean = $state(false) let pg14: boolean = $derived(postgresVersion.startsWith('14')) @@ -318,7 +322,9 @@ error_handler_path, error_handler_args, retry, - mode + mode, + permissioned_as: selectedPermissionedAs, + preserve_permissioned_as: preservePermissionedAs || undefined } return cfg } @@ -339,6 +345,9 @@ retry = cfg?.retry errorHandlerSelected = getHandlerType(error_handler_path ?? '') mode = cfg?.mode ?? 'enabled' + permissionedAs = cfg?.permissioned_as + selectedPermissionedAs = undefined + preservePermissionedAs = false } async function loadTrigger(defaultConfig?: Record): Promise { @@ -553,6 +562,15 @@
    {/if} {:else} + {#if edit} + { + selectedPermissionedAs = pa + preservePermissionedAs = preserve + }} + /> + {/if}
    {#if description} {@render description()} diff --git a/frontend/src/lib/components/triggers/postgres/utils.ts b/frontend/src/lib/components/triggers/postgres/utils.ts index 3c01b53152..dfd69fa3da 100644 --- a/frontend/src/lib/components/triggers/postgres/utils.ts +++ b/frontend/src/lib/components/triggers/postgres/utils.ts @@ -126,6 +126,8 @@ export async function savePostgresTriggerFromCfg( publication_name: config.publication_name, publication: config.publication, mode: config.mode, + permissioned_as: config.permissioned_as, + preserve_permissioned_as: config.preserve_permissioned_as, ...errorHandlerAndRetries } if (edit) { diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 55f825b40c..b8d4ee59f9 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -38,6 +38,7 @@ import { handleConfigChange } from '../utils' import TextInput from '$lib/components/text_input/TextInput.svelte' import { twMerge } from 'tailwind-merge' + import PermissionedAsLine from '../PermissionedAsLine.svelte' let { useDrawer = true, @@ -111,6 +112,9 @@ let isValid = $state(true) let allowSchedule = $derived(isValid && validCRON && script_path != '') let deploymentLoading = $state(false) + let permissionedAs = $state(undefined) + let selectedPermissionedAs = $state(undefined) + let preservePermissionedAs = $state(false) const saveDisabled = $derived( !allowSchedule || @@ -507,6 +511,9 @@ extraPerms = cfg.extra_perms ?? {} can_write = canWrite(cfg.path, cfg.extra_perms, $userStore) tag = cfg.tag + permissionedAs = cfg.permissioned_as + selectedPermissionedAs = undefined + preservePermissionedAs = false loading = false } @@ -605,7 +612,9 @@ paused_until: paused_until, cron_version: cronVersion, extra_perms: extraPerms, - dynamic_skip: dynamicSkipPath + dynamic_skip: dynamicSkipPath, + permissioned_as: selectedPermissionedAs, + preserve_permissioned_as: preservePermissionedAs || undefined } } @@ -682,6 +691,15 @@ {/if} {:else} + {#if edit} + { + selectedPermissionedAs = pa + preservePermissionedAs = preserve + }} + /> + {/if}
    @@ -913,12 +931,16 @@
    {#snippet header()} - + {/snippet} {@render errorHandler()}
    diff --git a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte index 1d9e1d783f..49b0a8182b 100644 --- a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte @@ -20,6 +20,7 @@ import Required from '$lib/components/Required.svelte' import { untrack, type Snippet } from 'svelte' import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import PermissionedAsLine from '../PermissionedAsLine.svelte' import { saveSqsTriggerFromCfg } from './utils' import { getHandlerType, handleConfigChange, type Trigger } from '../utils' import Tabs from '$lib/components/common/tabs/Tabs.svelte' @@ -88,6 +89,9 @@ let isValid = $state(false) let initialConfig: Record | undefined = undefined let deploymentLoading = $state(false) + let permissionedAs = $state(undefined) + let selectedPermissionedAs = $state(undefined) + let preservePermissionedAs = $state(false) let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler') let errorHandlerSelected: ErrorHandler = $state('slack') let error_handler_path: string | undefined = $state() @@ -189,6 +193,9 @@ error_handler_args = cfg?.error_handler_args ?? {} retry = cfg?.retry errorHandlerSelected = getHandlerType(error_handler_path ?? '') + permissionedAs = cfg?.permissioned_as + selectedPermissionedAs = undefined + preservePermissionedAs = false } catch (error) { sendUserToast(`Could not load SQS trigger config: ${error.body}`, true) } @@ -223,7 +230,9 @@ mode, error_handler_path, error_handler_args, - retry + retry, + permissioned_as: selectedPermissionedAs, + preserve_permissioned_as: preservePermissionedAs || undefined } } @@ -362,6 +371,15 @@ {/if} {:else} + {#if edit} + { + selectedPermissionedAs = pa + preservePermissionedAs = preserve + }} + /> + {/if}
    {#if description} {@render description()} diff --git a/frontend/src/lib/components/triggers/sqs/utils.ts b/frontend/src/lib/components/triggers/sqs/utils.ts index 1481639f74..4bca536b2f 100644 --- a/frontend/src/lib/components/triggers/sqs/utils.ts +++ b/frontend/src/lib/components/triggers/sqs/utils.ts @@ -25,6 +25,8 @@ export async function saveSqsTriggerFromCfg( message_attributes: cfg.message_attributes, aws_auth_resource_type: cfg.aws_auth_resource_type, mode: cfg.mode, + permissioned_as: cfg.permissioned_as, + preserve_permissioned_as: cfg.preserve_permissioned_as, ...errorHandlerAndRetries } try { diff --git a/frontend/src/lib/components/triggers/utils.ts b/frontend/src/lib/components/triggers/utils.ts index 863d49e673..0823969f65 100644 --- a/frontend/src/lib/components/triggers/utils.ts +++ b/frontend/src/lib/components/triggers/utils.ts @@ -481,14 +481,15 @@ export function getLightConfig( } else if (triggerType === 'email') { return { local_part: trigger.local_part } } else if (triggerType === 'nextcloud') { - return { event: trigger.service_config?.event ?? trigger.event } + return { event: trigger.service_config?.event ?? trigger.event, summary: trigger.summary } } else if (triggerType === 'google') { return { trigger_type: trigger.service_config?.triggerType ?? trigger.trigger_type, resource_id: trigger.service_config?.resourceId ?? trigger.resource_id, resource_name: trigger.service_config?.resourceName ?? trigger.resource_name, calendar_id: trigger.service_config?.calendarId ?? trigger.calendar_id, - calendar_name: trigger.service_config?.calendarName ?? trigger.calendar_name + calendar_name: trigger.service_config?.calendarName ?? trigger.calendar_name, + summary: trigger.summary } } else { return undefined @@ -524,8 +525,12 @@ export function getTriggerLabel(trigger: Trigger): string { return `${config?.url}` } else if (type === 'email' && config?.local_part) { return `${config?.local_part}` + } else if (type === 'nextcloud' && config?.summary) { + return `${config.summary}` } else if (type === 'nextcloud' && path) { return `${path}` + } else if (type === 'google' && config?.summary) { + return `${config.summary}` } else if (type === 'google' && path) { const triggerType = config?.trigger_type ?? config?.triggerType if (triggerType === 'calendar') { diff --git a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte index 56d4591cd1..2eece07a11 100644 --- a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte @@ -31,6 +31,7 @@ import { untrack, type Snippet } from 'svelte' import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import PermissionedAsLine from '../PermissionedAsLine.svelte' import { saveWebsocketTriggerFromCfg } from './utils' import { getHandlerType, handleConfigChange, type Trigger } from '../utils' import Tabs from '$lib/components/common/tabs/Tabs.svelte' @@ -105,6 +106,9 @@ let showLoading = $state(false) let initialConfig: Record | undefined = undefined let deploymentLoading = $state(false) + let permissionedAs = $state(undefined) + let selectedPermissionedAs = $state(undefined) + let preservePermissionedAs = $state(false) let isValid = $state(false) let optionTabSelected: 'error_handler' | 'retries' = $state('error_handler') let errorHandlerSelected: ErrorHandler = $state('slack') @@ -234,6 +238,9 @@ retry = cfg?.retry errorHandlerSelected = getHandlerType(error_handler_path ?? '') mode = cfg?.mode ?? 'enabled' + permissionedAs = cfg?.permissioned_as + selectedPermissionedAs = undefined + preservePermissionedAs = false } function getSaveCfg() { @@ -250,7 +257,9 @@ error_handler_path, error_handler_args, retry, - mode + mode, + permissioned_as: selectedPermissionedAs, + preserve_permissioned_as: preservePermissionedAs || undefined } } @@ -438,6 +447,15 @@ {/if} {:else} + {#if edit} + { + selectedPermissionedAs = pa + preservePermissionedAs = preserve + }} + /> + {/if}
    {#if mode === 'suspended'} @@ -688,9 +706,11 @@
    {#snippet header()} - 0 } - ]} /> + 0 }]} + /> {/snippet}
    diff --git a/frontend/src/lib/components/triggers/websocket/utils.ts b/frontend/src/lib/components/triggers/websocket/utils.ts index d71a41faa8..5cd27c3b72 100644 --- a/frontend/src/lib/components/triggers/websocket/utils.ts +++ b/frontend/src/lib/components/triggers/websocket/utils.ts @@ -29,7 +29,9 @@ export async function saveWebsocketTriggerFromCfg( url_runnable_args: triggerCfg.url_runnable_args, can_return_message: triggerCfg.can_return_message, can_return_error_result: triggerCfg.can_return_error_result, - ...errorHandlerAndRetries + ...errorHandlerAndRetries, + permissioned_as: triggerCfg.permissioned_as, + preserve_permissioned_as: triggerCfg.preserve_permissioned_as } try { if (edit) { diff --git a/frontend/src/lib/components/workspaceSettings/AISettings.svelte b/frontend/src/lib/components/workspaceSettings/AISettings.svelte index 2ed3432a3c..dd3cc6e0d0 100644 --- a/frontend/src/lib/components/workspaceSettings/AISettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/AISettings.svelte @@ -1,5 +1,12 @@ - +
    - -
    - {#each Object.entries(AI_PROVIDERS) as [provider, details]} -
    -
    - { - if (e.detail) { - aiProviders = { - ...aiProviders, - [provider]: { - resource_path: '', - models: - availableAiModels[provider].length > 0 - ? [availableAiModels[provider][0]] - : [] - } - } - - if (availableAiModels[provider].length > 0 && !defaultModel) { - defaultModel = availableAiModels[provider][0] - } - } else { - aiProviders = Object.fromEntries( - Object.entries(aiProviders).filter(([key]) => key !== provider) - ) - if (defaultModel) { - const currentDefaultModel = Object.values(aiProviders).find( - (p) => defaultModel && p.models.includes(defaultModel) - ) - if (!currentDefaultModel) { - defaultModel = undefined - } - } - if (codeCompletionModel) { - const currentCodeCompletionModel = Object.values(aiProviders).find( - (p) => codeCompletionModel && p.models.includes(codeCompletionModel) - ) - if (!currentCodeCompletionModel) { - codeCompletionModel = undefined - } - } - } - }} - /> - {#if provider === 'anthropic'} - - Recommended - - Anthropic models handle tool calls better than other providers, which makes them a - better choice for AI chat. - - - {/if} -
    - - {#if aiProviders[provider]} -
    -
    - {/if} -
    + + {#key Object.keys(aiProviders).length} + + +
    {/if}
    - + + + + +
    + + {#if promptCount > 0} + ({promptCount} configured) + {/if} + {#if hasPromptsChanges} + Unsaved changes + {/if} +
    +
    + {/if}
    - onDiscard?.()} - saveLabel="Save AI settings" - disabled={!Object.values(aiProviders).every((p) => p.resource_path) || - (codeCompletionModel != undefined && codeCompletionModel.length === 0) || - (Object.keys(aiProviders).length > 0 && !defaultModel)} -/> +{#if showWorkspaceOverrideEditor} + +{/if} diff --git a/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte b/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte index 7f4b4e0f67..e02facb38e 100644 --- a/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte +++ b/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte @@ -11,7 +11,8 @@ VariableService, WorkspaceService, type AIProvider, - type CompletedJob + type CompletedJob, + type GetCopilotInfoResponse } from '$lib/gen' import { validateUsername } from '$lib/utils' import { logoutWithRedirect } from '$lib/logoutKit' @@ -52,6 +53,10 @@ let aiKey = $state('') let codeCompletionEnabled = $state(true) let checking = $state(false) + let createLoading = $state(false) + let aiSetupLoading = $state(false) + let creationStep = $state<'details' | 'ai'>('details') + let createdWorkspaceId: string | undefined = $state(undefined) let workspaceColor: string | undefined = $state(undefined) let colorEnabled = $state(false) @@ -85,6 +90,64 @@ let errorMsgs: string[] = $state([]) let failedSyncJobs: string[] = $state([]) + function getErrorMessage(error: any): string { + return ( + error?.body?.error?.message || + error?.body?.message || + (typeof error?.body === 'string' ? error.body : null) || + error?.message || + 'Unknown error' + ) + } + + function hasEffectiveAi(copilotInfo: GetCopilotInfoResponse): boolean { + return Object.keys(copilotInfo.providers ?? {}).length > 0 + } + + async function finishWorkspaceSetup(workspaceId: string): Promise { + usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) + switchWorkspace(workspaceId) + goto(rd ?? '/') + } + + async function getWorkspaceUsername(workspaceId: string): Promise { + if (!automateUsernameCreation) { + return username + } + + const user = await UserService.whoami({ + workspace: workspaceId + }) + return user.username + } + + async function maybeShowAiSetupStep(workspaceId: string): Promise { + try { + const copilotInfo = await WorkspaceService.getCopilotInfo({ + workspace: workspaceId + }) + + if (hasEffectiveAi(copilotInfo)) { + await finishWorkspaceSetup(workspaceId) + return + } + } catch (error) { + console.error('Failed to check effective AI configuration for new workspace', error) + sendUserToast( + 'Workspace created, but Windmill AI availability could not be verified. You can configure it later in Workspace settings.', + true + ) + await finishWorkspaceSetup(workspaceId) + return + } + + createdWorkspaceId = workspaceId + creationStep = 'ai' + aiKey = '' + codeCompletionEnabled = true + selected = 'openai' + } + async function fetchFailedSyncJobs(jobs: string[]): Promise { let ret: CompletedJob[] = [] for (const job of jobs) { @@ -188,20 +251,22 @@ forkCreationLoading = false sendUserToast(`Successfully forked workspace ${$workspaceStore} as: wm-fork-${id}`) + await finishWorkspaceSetup(prefixed_id) } else { sendUserToast('No workspace selected, cannot fork non-existent workspace', true) } } else { - await createWorkspace() + createLoading = true + try { + const workspaceId = await createWorkspace() + await maybeShowAiSetupStep(workspaceId) + } finally { + createLoading = false + } } - - usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) - switchWorkspace(isFork ? prefixed_id : id) - - goto(rd ?? '/') } - async function createWorkspace(): Promise { + async function createWorkspace(): Promise { await WorkspaceService.createWorkspace({ requestBody: { id, @@ -216,17 +281,23 @@ requestBody: { operator: operatorOnly, invite_all: !isCloudHosted(), auto_add: autoAdd } }) } - if (aiKey != '') { - let actualUsername = username - if (automateUsernameCreation) { - const user = await UserService.whoami({ - workspace: id - }) - actualUsername = user.username - } - let path = `u/${actualUsername}/${selected}_windmill_codegen` + + sendUserToast(`Created workspace id: ${id}`) + return id + } + + async function saveWorkspaceAiSetup(): Promise { + if (!createdWorkspaceId || !aiKey) { + return + } + + aiSetupLoading = true + try { + const actualUsername = await getWorkspaceUsername(createdWorkspaceId) + const path = `u/${actualUsername}/${selected}_windmill_codegen` + await VariableService.createVariable({ - workspace: id, + workspace: createdWorkspaceId, requestBody: { path, value: aiKey, @@ -235,7 +306,7 @@ } }) await ResourceService.createResource({ - workspace: id, + workspace: createdWorkspaceId, requestBody: { path, value: { @@ -245,40 +316,46 @@ } }) await WorkspaceService.editCopilotConfig({ - workspace: id, - requestBody: aiKey - ? { - providers: { - [selected]: { - resource_path: path, - models: [AI_PROVIDERS[selected].defaultModels[0]] - } - }, - default_model: { - model: AI_PROVIDERS[selected].defaultModels[0], - provider: selected - }, - code_completion_model: codeCompletionEnabled - ? { model: AI_PROVIDERS[selected].defaultModels[0], provider: selected } - : undefined + workspace: createdWorkspaceId, + requestBody: { + providers: { + [selected]: { + resource_path: path, + models: [AI_PROVIDERS[selected].defaultModels[0]] } - : {} + }, + default_model: { + model: AI_PROVIDERS[selected].defaultModels[0], + provider: selected + }, + code_completion_model: codeCompletionEnabled + ? { model: AI_PROVIDERS[selected].defaultModels[0], provider: selected } + : undefined + } }) + + sendUserToast('Windmill AI configured') + await finishWorkspaceSetup(createdWorkspaceId) + } catch (error) { + sendUserToast(`Failed to configure Windmill AI: ${getErrorMessage(error)}`, true) + } finally { + aiSetupLoading = false } - - sendUserToast(`Created workspace id: ${id}`) - - usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) - switchWorkspace(id) - - goto(rd ?? '/') } - function handleKeyUp(event: KeyboardEvent) { + function handleCreateKeyUp(event: KeyboardEvent) { const key = event.key if (key === 'Enter') { event.preventDefault() - createWorkspace() + createOrForkWorkspace() + } + } + + function handleAiKeyUp(event: KeyboardEvent) { + const key = event.key + if (key === 'Enter' && aiKey) { + event.preventDefault() + saveWorkspaceAiSetup() } } @@ -329,6 +406,9 @@ let operatorOnly = $state(false) let autoAdd = $state(true) let selected: Exclude = $state('openai') + let modalTitle = $derived( + isFork ? 'Fork Workspace' : creationStep === 'ai' ? 'Set up Windmill AI' : 'New Workspace' + ) run(() => { id = name.toLowerCase().replace(/\s/gi, '-') }) @@ -344,7 +424,7 @@ let domain = $derived($usersWorkspaceStore?.email.split('@')[1]) - +
    {#if isFork}
    @@ -410,88 +490,184 @@ {/if} {/if} - - - - {#if !automateUsernameCreation} + {#if isFork || creationStep === 'details'} + - {/if} - {#if !isFork} -
    + + {#if !automateUsernameCreation} + + {/if} + {#if !isFork} +
    + + + {#if isCloudHosted() && isDomainAllowed == false} +
    {domain} domain not allowed for auto-invite
    + {/if} + + {#if auto_invite} +
    + + {#if isCloudHosted()} + + {/if} + + +
    + {/if} +
    + {/if} + +
    + + {#if !forkCreationLoading} + + {:else} + + {/if} +
    + {:else} +
    + + Windmill AI powers the chat, code generation, flow creation, and code completion. Set + it up now or configure it later in Workspace settings. + + Learn more + + - + {#snippet children({ item })} @@ -517,7 +704,7 @@ type="password" autocomplete="new-password" bind:value={aiKey} - onkeyup={handleKeyUp} + onkeyup={handleAiKeyUp} /> {#if aiKey} -
    +
    {/if}
    -
    - +
    - {/if} -
    - - {#if !forkCreationLoading} + Skip for now + - {:else} - - {/if} -
    +
    + {/if}
    diff --git a/frontend/src/lib/components/workspaceSettings/InstanceFallbackSettings.svelte b/frontend/src/lib/components/workspaceSettings/InstanceFallbackSettings.svelte new file mode 100644 index 0000000000..a44981ccbd --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/InstanceFallbackSettings.svelte @@ -0,0 +1,93 @@ + + +{#if instanceAiSummary} + +
    +

    + This workspace is currently using the instance AI defaults shown below. +

    + +
    + {#each sortedInstanceProviders as providerSummary} +
    +
    + + {getProviderLabel(providerSummary.provider)} + + Instance +
    +
    + {#each providerSummary.models as model} + {model} + {/each} +
    +
    + {/each} +
    + + {#if instanceAiSummary.default_model} +
    + Default chat model: + {instanceAiSummary.default_model.model} + + ({getProviderLabel(instanceAiSummary.default_model.provider)}) + +
    + {/if} + + {#if instanceAiSummary.code_completion_model} +
    + Code completion model: + + {instanceAiSummary.code_completion_model.model} + + + ({getProviderLabel(instanceAiSummary.code_completion_model.provider)}) + +
    + {/if} +
    +
    +{/if} + + +
    +

    + Create workspace-specific AI settings only if this workspace needs to override the active + instance defaults. +

    +
    + +
    +
    +
    diff --git a/frontend/src/lib/logoutRedirect.ts b/frontend/src/lib/logoutRedirect.ts new file mode 100644 index 0000000000..9b09880fd2 --- /dev/null +++ b/frontend/src/lib/logoutRedirect.ts @@ -0,0 +1,23 @@ +import { get } from 'svelte/store' +import { hubBaseUrlStore } from './stores' + +export function isValidLogoutRedirect(url: string): boolean { + if (url.startsWith('/') && !url.startsWith('//')) { + return true + } + try { + const parsed = new URL(url) + const host = parsed.hostname + if (host === 'windmill.dev' || host.endsWith('.windmill.dev')) { + return true + } + const hubBaseUrl = get(hubBaseUrlStore) + try { + const hubHost = new URL(hubBaseUrl).hostname + if (host === hubHost) { + return true + } + } catch {} + } catch {} + return false +} diff --git a/frontend/src/lib/script_helpers.ts b/frontend/src/lib/script_helpers.ts index d05d78f6ef..6865637ba7 100644 --- a/frontend/src/lib/script_helpers.ts +++ b/frontend/src/lib/script_helpers.ts @@ -1601,7 +1601,7 @@ export function canHaveApproval(language: SupportedLanguage | undefined): boolea return false } - return ['python3', 'bun', 'deno'].includes(language) + return ['python3', 'bun'].includes(language) } export function canHaveFailure(language: SupportedLanguage | undefined): boolean { diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index 5bf8f21ac5..5f7e426fe9 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -797,6 +797,7 @@ stepResults={getStepResults(job.workflow_as_code_status)} result={job.result} success={(job as any).success !== false} + jobId={job.id} />
    diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte index d80e7fda85..36f9be8faa 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte @@ -25,13 +25,20 @@ import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte' import SettingCard from '$lib/components/instanceSettings/SettingCard.svelte' import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' + import InstanceAISettings from '$lib/components/instanceSettings/InstanceAISettings.svelte' const settingsSteps = [ { id: 'Core', label: 'Core' }, { id: 'Auth/OAuth/SAML', label: 'Authentication' } ] as const - const wizardStepLabels = [...settingsSteps.map((s) => s.label), 'Root login & Resource Types'] + const AI_STEP_INDEX = settingsSteps.length + + const wizardStepLabels = [ + ...settingsSteps.map((s) => s.label), + 'AI', + 'Root login & Resource Types' + ] const fullStepLabels = ['Settings', 'Root login & Resource Types'] @@ -67,6 +74,7 @@ }) let instanceSettings: InstanceSettings | undefined = $state() + let instanceAiSettings: InstanceAISettings | undefined = $state() function isSettingsStep(step: number): boolean { return step < settingsSteps.length @@ -143,11 +151,14 @@ } } - const emailPattern = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/ + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/ let emailValid = $derived(emailPattern.test(newEmail)) let passwordValid = $derived(newPassword.length >= 2) let accountFormValid = $derived(emailValid && passwordValid) + // --- AI step state --- + let aiHasUnsavedChanges = $state(false) + // --- EE license key warning --- let showLicenseKeyWarning = $state(false) let pendingNextCallback: (() => void) | undefined = $state(undefined) @@ -168,9 +179,20 @@ let authSubTab: 'sso' | 'oauth' | 'scim' = $derived(tabToAuthSubTab[fullTab] ?? 'sso') let yamlMode = $state(false) - function handleNavigate(newTab: string) { - if (newTab === fullTab) return + function isAiStepActive(): boolean { + return ( + (mode === 'wizard' && wizardStep === AI_STEP_INDEX) || + (mode === 'full' && fullStep === 0 && fullTab === 'ai' && !yamlMode) + ) + } + + async function handleNavigate(newTab: string): Promise { + if (newTab === fullTab) return true + if (isAiStepActive() && !((await instanceAiSettings?.persistBeforeExit()) ?? true)) { + return false + } fullTab = newTab + return true } // --- Settings search (full mode) --- @@ -180,7 +202,10 @@ let highlightTimeout: ReturnType | undefined async function handleSearchSelect(item: SearchableSettingItem) { - handleNavigate(item.tabId) + const didNavigate = await handleNavigate(item.tabId) + if (!didNavigate) { + return + } if (item.settingKey) { clearTimeout(scrollTimeout) clearTimeout(highlightTimeout) @@ -202,7 +227,7 @@ }) /** Check if we need to warn about missing EE license key before proceeding */ - function proceedFromCore(callback: () => void) { + async function proceedFromCore(callback: () => void) { const leavingSettings = (mode === 'wizard' && wizardStep === 0) || (mode === 'full' && fullStep === 0) if (leavingSettings && isEeImage() && isLicenseKeyEmpty()) { @@ -210,12 +235,16 @@ showLicenseKeyWarning = true return } - saveAndProceed(callback) + await saveAndProceed(callback) } /** Auto-save dirty settings, then run the callback */ async function saveAndProceed(callback: () => void) { - if (yamlMode) { + if (isAiStepActive()) { + if (!((await instanceAiSettings?.persistBeforeExit()) ?? true)) { + return + } + } else if (yamlMode) { // In YAML mode, sync editor → form, then bulk-save everything if (!instanceSettings?.syncBeforeDiff()) return await instanceSettings.saveSettings() @@ -231,11 +260,14 @@ callback() } - function switchToFullMode() { + async function switchToFullMode() { mode = 'full' } - function switchToWizardMode() { + async function switchToWizardMode() { + if (isAiStepActive() && !((await instanceAiSettings?.persistBeforeExit()) ?? true)) { + return + } yamlMode = false fullStep = 0 mode = 'wizard' @@ -461,6 +493,13 @@ tab={settingsSteps[wizardStep].id} /> {/key} + {:else if wizardStep === AI_STEP_INDEX} + {:else} {@render accountSetupContent()} {/if} @@ -505,19 +544,28 @@ {/if}
    - { - const targetTab = categoryToTabMap[category] - if (targetTab) { - handleNavigate(targetTab) - } - }} - /> + {#if fullTab === 'ai' && !yamlMode} + + {:else} + { + const targetTab = categoryToTabMap[category] + if (targetTab) { + handleNavigate(targetTab) + } + }} + /> + {/if}
    {:else} diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte index 4d1a58c70d..3e7ef57bf6 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte @@ -24,7 +24,11 @@ const email = page.url.searchParams.get('email') ?? '' const password = page.url.searchParams.get('password') ?? '' const error = page.url.searchParams.get('error') ?? undefined - const rd = page.url.searchParams.get('rd') ?? undefined + const rdFromStorage = localStorage.getItem('rd') || undefined + if (rdFromStorage) { + localStorage.removeItem('rd') + } + const rd = page.url.searchParams.get('rd') ?? rdFromStorage let showPassword = false let firstTime = $state(false) @@ -83,7 +87,8 @@ workspace: $workspaceStore! }) if (!emptyString(defaultApp.default_app_path)) { - goto(`/apps/get/${defaultApp.default_app_path}`) + const prefix = defaultApp.default_app_raw ? '/apps_raw/get' : '/apps/get' + goto(`${prefix}/${defaultApp.default_app_path}`) } else { goto(rd ?? '/') } diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte index e328c37f5a..75e31a9a00 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte @@ -2,6 +2,7 @@ import { page } from '$app/state' import CenteredModal from '$lib/components/CenteredModal.svelte' import { clearUser } from '$lib/logout' + import { isValidLogoutRedirect } from '$lib/logoutRedirect' import { userStore } from '$lib/stores' import { onMount } from 'svelte' @@ -29,7 +30,11 @@ return } - window.location.href = rd ?? '/user/login' + if (rd && isValidLogoutRedirect(rd)) { + window.location.href = rd + } else { + window.location.href = '/user/login' + } }) diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte index 0ddfd92914..2cc2a65fbf 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte @@ -151,7 +151,8 @@ workspace: $workspaceStore! }) if (!emptyString(defaultApp.default_app_path)) { - await goto(`/apps/get/${defaultApp.default_app_path}`) + const prefix = defaultApp.default_app_raw ? '/apps_raw/get' : '/apps/get' + await goto(`${prefix}/${defaultApp.default_app_path}`) } else { if (rd?.startsWith('http')) { window.location.href = rd diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index d481346af2..db3a140c0b 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -19,10 +19,12 @@ import { OauthService, WorkspaceService, - ResourceService, SettingService, type AIConfig, - type ErrorHandler + type ErrorHandler, + type GetCopilotSettingsStateResponse, + type InstanceAISummary, + type GetSettingsResponse } from '$lib/gen' import { enterpriseLicense, @@ -60,7 +62,6 @@ convertDucklakeSettingsFromBackend, type DucklakeSettingsType } from '$lib/components/workspaceSettings/DucklakeSettings.svelte' - import { AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte' import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' import CollapseLink from '$lib/components/CollapseLink.svelte' @@ -112,19 +113,12 @@ let publicAppRateLimitPerMinute: number | undefined = $state(undefined) let initialPublicAppRateLimitPerMinute: number | undefined = $state(undefined) - let aiProviders: Exclude = $state({}) - let codeCompletionModel: string | undefined = $state(undefined) - let defaultModel: string | undefined = $state(undefined) - let customPrompts: Record = $state({}) - let maxTokensPerModel: Record = $state({}) - - // Track initial AI config for unsaved changes detection - let initialAiProviders: Exclude = $state({}) - let initialCodeCompletionModel: string | undefined = $state(undefined) - let initialDefaultModel: string | undefined = $state(undefined) - let initialCustomPrompts: Record = $state({}) - let initialMaxTokensPerModel: Record = $state({}) - + let hasInstanceAiConfig = $state(false) + let usesInstanceAiConfig = $state(false) + let instanceAiSummary: InstanceAISummary | undefined = $state(undefined) + let aiInitialConfig: AIConfig | undefined = $state(undefined) + let aiSettingsComponent: AISettings | undefined = $state(undefined) + let hasAiSettingsChanges = $state(false) // Track initial deploy settings for unsaved changes detection let initialWorkspaceToDeployTo: string | undefined = $state(undefined) let initialDeployUiSettings: { @@ -227,14 +221,6 @@ return currentValue !== initialValue }) - // Derived state for checking unsaved changes in AI settings - let hasAiSettingsChanges = $derived.by(() => { - if (tab !== 'ai') return false - const changes = getAiSettingsInitialAndModifiedValues() - if (!changes.savedValue || !changes.modifiedValue) return false - return hasUnsavedChanges(changes.savedValue, changes.modifiedValue) - }) - // Derived state for checking unsaved changes in deployment settings let hasDeploySettingsChanges = $derived.by(() => { if (tab !== 'deploy_to') return false @@ -320,8 +306,6 @@ $page.url.searchParams.get('tab') === 'teams' ? 'teams_commands' : 'slack_commands' ) - let usingOpenaiClientCredentialsOauth = $state(false) - let loadedSettings = $state(false) let oauths: Record = $state({}) @@ -489,7 +473,17 @@ } async function loadSettings(): Promise { - const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! }) + const [settings, copilotSettingsState]: [ + GetSettingsResponse, + GetCopilotSettingsStateResponse + ] = await Promise.all([ + WorkspaceService.getSettings({ + workspace: $workspaceStore! + }), + WorkspaceService.getCopilotSettingsState({ + workspace: $workspaceStore! + }) + ]) slack_team_name = settings.slack_name teams_team_id = settings.teams_team_id teams_team_name = settings.teams_team_name @@ -508,23 +502,10 @@ workspaceToDeployTo = settings.deploy_to webhook = settings.webhook - aiProviders = settings.ai_config?.providers ?? {} - defaultModel = settings.ai_config?.default_model?.model - codeCompletionModel = settings.ai_config?.code_completion_model?.model - customPrompts = settings.ai_config?.custom_prompts ?? {} - maxTokensPerModel = settings.ai_config?.max_tokens_per_model ?? {} - for (const mode of Object.values(AIMode)) { - if (!(mode in customPrompts)) { - customPrompts[mode] = '' - } - } - - // Store initial AI config state for unsaved changes detection - initialAiProviders = clone(aiProviders) - initialDefaultModel = defaultModel - initialCodeCompletionModel = codeCompletionModel - initialCustomPrompts = clone(customPrompts) - initialMaxTokensPerModel = clone(maxTokensPerModel) + aiInitialConfig = settings.ai_config ?? {} + hasInstanceAiConfig = copilotSettingsState.has_instance_ai_config + usesInstanceAiConfig = copilotSettingsState.uses_instance_ai_config + instanceAiSummary = copilotSettingsState.instance_ai_summary const errorHandler = settings.error_handler as | { path?: string; extra_args?: any; muted_on_cancel?: boolean; muted_on_user_path?: boolean } | undefined @@ -600,12 +581,6 @@ // Store initial success handler state for unsaved changes detection initialSuccessHandlerScriptPath = successHandlerScriptPath - // check openai_client_credentials_oauth - usingOpenaiClientCredentialsOauth = await ResourceService.existsResourceType({ - workspace: $workspaceStore!, - path: 'openai_client_credentials_oauth' - }) - loadedSettings = true } @@ -816,36 +791,6 @@ ) } - // Function to check if there are unsaved changes in AI settings - function getAiSettingsInitialAndModifiedValues() { - const savedValue = { - aiProviders: initialAiProviders, - defaultModel: initialDefaultModel, - codeCompletionModel: initialCodeCompletionModel, - customPrompts: initialCustomPrompts, - maxTokensPerModel: initialMaxTokensPerModel - } - - const modifiedValue = { - aiProviders: aiProviders, - defaultModel: defaultModel, - codeCompletionModel: codeCompletionModel, - customPrompts: customPrompts, - maxTokensPerModel: maxTokensPerModel - } - - return { savedValue, modifiedValue } - } - - // Function to discard unsaved AI settings changes - function discardAiSettingsChanges() { - aiProviders = clone(initialAiProviders) - defaultModel = initialDefaultModel - codeCompletionModel = initialCodeCompletionModel - customPrompts = clone(initialCustomPrompts) - maxTokensPerModel = clone(initialMaxTokensPerModel) - } - // Function to check if there are unsaved changes in storage settings function getStorageSettingsInitialAndModifiedValues() { return { @@ -1017,7 +962,9 @@ case 'windmill_data_tables': return dataTableSettingsComponent?.unsavedChanges() ?? { savedValue: {}, modifiedValue: {} } case 'ai': - return getAiSettingsInitialAndModifiedValues() + return hasAiSettingsChanges + ? { savedValue: { changed: false }, modifiedValue: { changed: true } } + : { savedValue: {}, modifiedValue: {} } case 'windmill_lfs': return getStorageSettingsInitialAndModifiedValues() case 'volume_storage': @@ -1059,7 +1006,7 @@ function discardAllChanges() { switch (tab) { case 'ai': - discardAiSettingsChanges() + aiSettingsComponent?.discard() break case 'windmill_lfs': discardStorageSettingsChanges() @@ -1830,21 +1777,19 @@ export async function main( /> {:else if tab == 'ai'} { - // Update initial state after successful save - initialAiProviders = clone(aiProviders) - initialDefaultModel = defaultModel - initialCodeCompletionModel = codeCompletionModel - initialCustomPrompts = clone(customPrompts) - initialMaxTokensPerModel = clone(maxTokensPerModel) + bind:this={aiSettingsComponent} + initialConfig={aiInitialConfig} + bind:hasUnsavedChanges={hasAiSettingsChanges} + {hasInstanceAiConfig} + {usesInstanceAiConfig} + {instanceAiSummary} + onSave={(copilotSettingsState) => { + if (!copilotSettingsState) { + return + } + hasInstanceAiConfig = copilotSettingsState.has_instance_ai_config + usesInstanceAiConfig = copilotSettingsState.uses_instance_ai_config + instanceAiSummary = copilotSettingsState.instance_ai_summary }} /> {:else if tab == 'windmill_data_tables'} diff --git a/frontend/src/routes/approve/[workspace]/[job]/+page.svelte b/frontend/src/routes/approve/[workspace]/[job]/+page.svelte new file mode 100644 index 0000000000..6c9638a948 --- /dev/null +++ b/frontend/src/routes/approve/[workspace]/[job]/+page.svelte @@ -0,0 +1,359 @@ + + + + + + {#if error} +
    + {#if error.includes('logged in') || error.includes('sign in') || error.includes('Not authorized')} +
    + +

    Not Authorized

    +
    +

    {error}

    + + {:else if error.includes('Permission denied') || error.includes('Self-approval')} +
    + +

    Permission denied

    +
    +

    {error}

    + {:else} +
    + +

    Error

    +
    +

    {error}

    + {/if} +
    + {:else if approvalInfo} +
    +
    +

    Approvers

    +
    + {#if approvalInfo.approvers?.length > 0} +
      + {#each approvalInfo.approvers as a} +
    • +

      + {a.approver} + Unique id of approval: {a.resume_id} +

      +
    • + {/each} +
    + {:else} +

    + No current approvers for this step (approval steps can require more than one approval) +

    + {/if} +
    +
    +
    + {#if job && job.raw_flow} + + {/if} +
    +
    + + {#if !completed} +

    + {isWac ? 'Workflow' : 'Flow'} arguments +

    + + {/if} + +
    + +
    + {#if completed} + + The flow is not running anymore. You cannot cancel or resume it. + + {/if} + + {#if approvalInfo.description != undefined} + + {/if} + + {#if hasForm && !completed} + {#if emptyString($enterpriseLicense)} + + {:else} + + {/if} + {/if} + + {#if !completed && approvalInfo.can_approve} +
    + {#if approvalInfo.hide_cancel !== true} + + {:else} +
    + {/if} + +
    + {:else if !completed && !approvalInfo.can_approve} + {#if approvalInfo.user_auth_required && !$userStore} + + {:else} +
    +

    You are not authorized to approve this flow.

    + {#if approvalInfo.approval_conditions?.self_approval_disabled && $userStore && $userStore.email === (job as any)?.email} +

    Self-approval is disabled for this step.

    + {/if} + {#if approvalInfo.approval_conditions?.user_groups_required?.length > 0} +

    Only members of the following groups can approve: {approvalInfo.approval_conditions.user_groups_required.join(', ')}

    + {/if} +
    + {/if} + {:else if completed} + + {/if} + + {#if !completed && isSelfApprovalBypass} +
    + + As an administrator, by resuming or cancelling this stage of the flow, you bypass the + self-approval interdiction. + +
    + {/if} +
    + + + + {#if job && job.raw_flow && !completed} +

    Flow details

    +
    + +
    + {/if} + {:else} +

    Loading...

    + {/if} +
    diff --git a/frontend/src/routes/approve/[workspace]/[job]/[resume]/[hmac]/+page.svelte b/frontend/src/routes/approve/[workspace]/[job]/[resume]/[hmac]/+page.svelte index 5ee2581e7d..340624ba06 100644 --- a/frontend/src/routes/approve/[workspace]/[job]/[resume]/[hmac]/+page.svelte +++ b/frontend/src/routes/approve/[workspace]/[job]/[resume]/[hmac]/+page.svelte @@ -307,6 +307,7 @@ earlyStop={job.raw_flow?.skip_expr !== undefined} cache={job.raw_flow?.cache_ttl !== undefined} modules={job.raw_flow?.modules} + groups={job.raw_flow?.groups} failureModule={job.raw_flow?.failure_module} preprocessorModule={job.raw_flow?.preprocessor_module} notSelectable diff --git a/frontend/src/routes/view_graph/+page.svelte b/frontend/src/routes/view_graph/+page.svelte index 86bbba17cf..7c61be927f 100644 --- a/frontend/src/routes/view_graph/+page.svelte +++ b/frontend/src/routes/view_graph/+page.svelte @@ -4,7 +4,7 @@ import { decodeState } from '$lib/utils' let content = localStorage.getItem('svelvet') - const { modules, failureModule, preprocessorModule, notes } = content + const { modules, failureModule, preprocessorModule, notes, groups } = content ? decodeState(content) : { modules: [], failureModule: undefined, preprocessorModule: undefined } @@ -16,6 +16,7 @@ {failureModule} {preprocessorModule} {notes} + {groups} /> -" + debounce_args_to_accumulate: + type: array + description: Array-type arguments to accumulate across debounced executions + items: + type: string + max_total_debouncing_time: + type: integer + description: Maximum total time in seconds before forced execution + max_total_debounces_amount: + type: integer + description: Maximum number of debounces before forced execution required: - value - id diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index a4fa958a3d..6629cc8034 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.662.0' + ModuleVersion = '1.664.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 6e1bbf495b..8cfc22d3a6 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.662.0" +version = "1.664.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/python-client/wmill/wmill/client.py b/python-client/wmill/wmill/client.py index cdd3342771..d952584e5a 100644 --- a/python-client/wmill/wmill/client.py +++ b/python-client/wmill/wmill/client.py @@ -2463,7 +2463,7 @@ class WorkflowCtx: ) async def _wait_for_approval( - self, timeout: int = 1800, form: dict | None = None + self, timeout: int = 1800, form: dict | None = None, self_approval: bool = True ): key = self._alloc_key("approval") @@ -2479,6 +2479,7 @@ class WorkflowCtx: "key": key, "timeout": timeout, "form": form, + "self_approval_disabled": not self_approval, "steps": [], }) @@ -2762,6 +2763,7 @@ async def sleep(seconds: int): async def wait_for_approval( timeout: int = 1800, form: dict | None = None, + self_approval: bool = True, ) -> dict: """Suspend the workflow and wait for an external approval. @@ -2770,6 +2772,11 @@ async def wait_for_approval( Returns a dict with ``value`` (form data), ``approver``, and ``approved``. + Args: + timeout: Approval timeout in seconds (default 1800). + form: Optional form schema for the approval page. + self_approval: Whether the user who triggered the flow can approve it (default True). + Example:: urls = await step("urls", lambda: get_resume_urls()) @@ -2778,7 +2785,7 @@ async def wait_for_approval( """ ctx: WorkflowCtx | None = _workflow_ctx.get(None) if ctx is not None: - return await ctx._wait_for_approval(timeout=timeout, form=form) + return await ctx._wait_for_approval(timeout=timeout, form=form, self_approval=self_approval) raise RuntimeError("wait_for_approval can only be called inside a @workflow") diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index d22d5b2856..d76e31ded0 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -92,6 +92,7 @@ flow related commands - `--remote` - Use deployed workspace scripts for PathScript steps instead of local files. - `flow generate-locks [flow:file]` - re-generate the lock files of all inline scripts of all updated flows - `--yes` - Skip confirmation prompt + - `--dry-run` - Perform a dry run without making changes - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string) - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account. - `flow new ` - create a new empty flow @@ -134,6 +135,7 @@ Generate metadata (locks, schemas) for all scripts, flows, and apps - `--skip-scripts` - Skip processing scripts - `--skip-flows` - Skip processing flows - `--skip-apps` - Skip processing apps +- `--strict-folder-boundaries` - Only update items inside the specified folder (requires folder argument) - `-i --includes ` - Comma separated patterns to specify which files to include - `-e --excludes ` - Comma separated patterns to specify which files to exclude diff --git a/system_prompts/auto-generated/flow.md b/system_prompts/auto-generated/flow.md index ed3b5db3ad..bcf711d50b 100644 --- a/system_prompts/auto-generated/flow.md +++ b/system_prompts/auto-generated/flow.md @@ -120,4 +120,4 @@ Reference a specific resource using `$res:` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"number","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"number","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"number","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_use":{"type":"boolean","description":"If true, this step's result is deleted after use to save memory"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_images":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of image references for vision-capable models.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'images/photo.jpg' }]\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_use":{"type":"boolean","description":"If true, this step's result is deleted after use to save memory"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_images":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of image references for vision-capable models.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'images/photo.jpg' }]\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file diff --git a/system_prompts/auto-generated/index.d.ts b/system_prompts/auto-generated/index.d.ts index ab649a2090..4f2dd469ac 100644 --- a/system_prompts/auto-generated/index.d.ts +++ b/system_prompts/auto-generated/index.d.ts @@ -1,3 +1,4 @@ export * from './prompts'; export declare function getScriptPrompt(language: string): string; export declare function getFlowPrompt(): string; +export declare function getDatatableSdkReference(): string; diff --git a/system_prompts/auto-generated/index.ts b/system_prompts/auto-generated/index.ts index b283892470..b2c4bf0ec8 100644 --- a/system_prompts/auto-generated/index.ts +++ b/system_prompts/auto-generated/index.ts @@ -37,3 +37,11 @@ export function getFlowPrompt(): string { prompts.OPENFLOW_SCHEMA ].filter(Boolean).join('\n\n'); } + +// Helper to get datatable SDK reference for app mode +export function getDatatableSdkReference(): string { + return [ + prompts.DATATABLE_SDK_TYPESCRIPT, + prompts.DATATABLE_SDK_PYTHON + ].filter(Boolean).join('\n\n'); +} diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 8bab9045c9..6eb870e62b 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -632,7 +632,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. @@ -1336,12 +1336,17 @@ async def sleep(seconds: int) # # Returns a dict with \`\`value\`\` (form data), \`\`approver\`\`, and \`\`approved\`\`. # +# Args: +# timeout: Approval timeout in seconds (default 1800). +# form: Optional form schema for the approval page. +# self_approval: Whether the user who triggered the flow can approve it (default True). +# # Example:: # # urls = await step("urls", lambda: get_resume_urls()) # await step("notify", lambda: send_email(urls["approvalPage"])) # result = await wait_for_approval(timeout=3600) -async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict +async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict # Process items in parallel with optional concurrency control. # @@ -1366,11 +1371,159 @@ async def parallel(items, fn, concurrency: Optional[int] = None) # offset: Message offset to commit (from event['offset']) def commit_kafka_offsets(trigger_path: str, topic: str, partition: int, offset: int) -> None +`; + +export const DATATABLE_SDK_TYPESCRIPT = `## TypeScript Datatable API (windmill-client) + +Import: \`import * as wmill from 'windmill-client'\` + +SQL statement object with query content, arguments, and execution methods +\`\`\`typescript +type SqlStatement = { + /** Raw SQL content with formatted arguments */ + content: string; + + /** Argument values keyed by parameter name */ + args: Record; + + /** + * Execute the SQL query and return results + * @param params - Optional parameters including result collection mode + * @returns Query results based on the result collection mode + */ + fetch( + params?: FetchParams // The union is for auto-completion + ): Promise>; + + /** + * Execute the SQL query and return only the first row + * @param params - Optional parameters + * @returns First row of the query result + */ + fetchOne( + params?: Omit, "resultCollection"> + ): Promise>; + + /** + * Execute the SQL query and return only the first row as a scalar value + * @param params - Optional parameters + * @returns First row of the query result + */ + fetchOneScalar( + params?: Omit< + FetchParams<"last_statement_first_row_scalar">, + "resultCollection" + > + ): Promise>; + + /** + * Execute the SQL query without fetching rows + * @param params - Optional parameters + */ + execute( + params?: Omit, "resultCollection"> + ): Promise; +}; +\`\`\` + +\`\`\`typescript +// Template tag function: sql\`SELECT * FROM table WHERE id = \${id}\`.fetch() +interface DatatableSqlTemplateFunction { + // Tagged template usage: + (strings: TemplateStringsArray, ...values: any[]): SqlStatement; + query(sql: string, ...params: any[]): SqlStatement; +}; +\`\`\` + +Create a SQL template function for PostgreSQL/datatable queries +@param name - Database/datatable name (default: "main") +@returns SQL template function for building parameterized queries +@example +let sql = wmill.datatable() +let name = 'Robin' +let age = 21 +await sql\` + SELECT * FROM friends + WHERE name = \${name} AND age = \${age}::int +\`.fetch() +\`\`\`typescript +function datatable(name: string = "main"): DatatableSqlTemplateFunction +\`\`\` +`; + +export const DATATABLE_SDK_PYTHON = `## Python Datatable API (wmill) + +Import: \`import wmill\` + +# Get a DataTable client for SQL queries. +# +# Args: +# name: Database name (default: "main") +# +# Returns: +# DataTableClient instance +def datatable(name: str = 'main') -> DataTableClient + +# Client for executing SQL queries against Windmill DataTables. +class DataTableClient: + # Initialize DataTableClient. + # + # Args: + # client: Windmill client instance + # name: DataTable name + def __init__(client: Windmill, name: str) + + # Execute a SQL query against the DataTable. + # + # Args: + # sql: SQL query string with $1, $2, etc. placeholders + # *args: Positional arguments to bind to query placeholders + # + # Returns: + # SqlQuery instance for fetching results + def query(sql: str, *args) -> SqlQuery + + +# Query result handler for DataTable and DuckLake queries. +class SqlQuery: + # Initialize SqlQuery. + # + # Args: + # sql: SQL query string + # fetch_fn: Function to execute the query + def __init__(sql: str, fetch_fn) + + # Execute query and fetch results. + # + # Args: + # result_collection: Optional result collection mode + # + # Returns: + # Query results + def fetch(result_collection: str | None = None) + + # Execute query and fetch first row of results. + # + # Returns: + # First row of query results + def fetch_one() + + # Execute query and fetch first row of results. Return result as a scalar value. + # + # Returns: + # First row of query result as a scalar value + def fetch_one_scalar() + + # Execute query and don't return any results. + # + def execute() + + `; export const OPENFLOW_SCHEMA = `## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"number","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"number","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"number","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_use":{"type":"boolean","description":"If true, this step's result is deleted after use to save memory"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_images":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of image references for vision-capable models.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'images/photo.jpg' }]\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`; +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_use":{"type":"boolean","description":"If true, this step's result is deleted after use to save memory"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_images":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of image references for vision-capable models.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'images/photo.jpg' }]\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`; export const CLI_COMMANDS = `# Windmill CLI Commands @@ -1466,6 +1619,7 @@ flow related commands - \`--remote\` - Use deployed workspace scripts for PathScript steps instead of local files. - \`flow generate-locks [flow:file]\` - re-generate the lock files of all inline scripts of all updated flows - \`--yes\` - Skip confirmation prompt + - \`--dry-run\` - Perform a dry run without making changes - \`-i --includes \` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string) - \`-e --excludes \` - Comma separated patterns to specify which file to NOT take into account. - \`flow new \` - create a new empty flow @@ -1508,6 +1662,7 @@ Generate metadata (locks, schemas) for all scripts, flows, and apps - \`--skip-scripts\` - Skip processing scripts - \`--skip-flows\` - Skip processing flows - \`--skip-apps\` - Skip processing apps +- \`--strict-folder-boundaries\` - Only update items inside the specified folder (requires folder argument) - \`-i --includes \` - Comma separated patterns to specify which files to include - \`-e --excludes \` - Comma separated patterns to specify which files to exclude diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index ec776de97e..674c9986b9 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -1605,7 +1605,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. @@ -2309,12 +2309,17 @@ async def sleep(seconds: int) # # Returns a dict with ``value`` (form data), ``approver``, and ``approved``. # +# Args: +# timeout: Approval timeout in seconds (default 1800). +# form: Optional form schema for the approval page. +# self_approval: Whether the user who triggered the flow can approve it (default True). +# # Example:: # # urls = await step("urls", lambda: get_resume_urls()) # await step("notify", lambda: send_email(urls["approvalPage"])) # result = await wait_for_approval(timeout=3600) -async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict +async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict # Process items in parallel with optional concurrency control. # diff --git a/system_prompts/auto-generated/sdks/datatable-python.md b/system_prompts/auto-generated/sdks/datatable-python.md new file mode 100644 index 0000000000..752019a9a3 --- /dev/null +++ b/system_prompts/auto-generated/sdks/datatable-python.md @@ -0,0 +1,68 @@ +## Python Datatable API (wmill) + +Import: `import wmill` + +# Get a DataTable client for SQL queries. +# +# Args: +# name: Database name (default: "main") +# +# Returns: +# DataTableClient instance +def datatable(name: str = 'main') -> DataTableClient + +# Client for executing SQL queries against Windmill DataTables. +class DataTableClient: + # Initialize DataTableClient. + # + # Args: + # client: Windmill client instance + # name: DataTable name + def __init__(client: Windmill, name: str) + + # Execute a SQL query against the DataTable. + # + # Args: + # sql: SQL query string with $1, $2, etc. placeholders + # *args: Positional arguments to bind to query placeholders + # + # Returns: + # SqlQuery instance for fetching results + def query(sql: str, *args) -> SqlQuery + + +# Query result handler for DataTable and DuckLake queries. +class SqlQuery: + # Initialize SqlQuery. + # + # Args: + # sql: SQL query string + # fetch_fn: Function to execute the query + def __init__(sql: str, fetch_fn) + + # Execute query and fetch results. + # + # Args: + # result_collection: Optional result collection mode + # + # Returns: + # Query results + def fetch(result_collection: str | None = None) + + # Execute query and fetch first row of results. + # + # Returns: + # First row of query results + def fetch_one() + + # Execute query and fetch first row of results. Return result as a scalar value. + # + # Returns: + # First row of query result as a scalar value + def fetch_one_scalar() + + # Execute query and don't return any results. + # + def execute() + + diff --git a/system_prompts/auto-generated/sdks/datatable-typescript.md b/system_prompts/auto-generated/sdks/datatable-typescript.md new file mode 100644 index 0000000000..0256515911 --- /dev/null +++ b/system_prompts/auto-generated/sdks/datatable-typescript.md @@ -0,0 +1,76 @@ +## TypeScript Datatable API (windmill-client) + +Import: `import * as wmill from 'windmill-client'` + +SQL statement object with query content, arguments, and execution methods +```typescript +type SqlStatement = { + /** Raw SQL content with formatted arguments */ + content: string; + + /** Argument values keyed by parameter name */ + args: Record; + + /** + * Execute the SQL query and return results + * @param params - Optional parameters including result collection mode + * @returns Query results based on the result collection mode + */ + fetch( + params?: FetchParams // The union is for auto-completion + ): Promise>; + + /** + * Execute the SQL query and return only the first row + * @param params - Optional parameters + * @returns First row of the query result + */ + fetchOne( + params?: Omit, "resultCollection"> + ): Promise>; + + /** + * Execute the SQL query and return only the first row as a scalar value + * @param params - Optional parameters + * @returns First row of the query result + */ + fetchOneScalar( + params?: Omit< + FetchParams<"last_statement_first_row_scalar">, + "resultCollection" + > + ): Promise>; + + /** + * Execute the SQL query without fetching rows + * @param params - Optional parameters + */ + execute( + params?: Omit, "resultCollection"> + ): Promise; +}; +``` + +```typescript +// Template tag function: sql`SELECT * FROM table WHERE id = ${id}`.fetch() +interface DatatableSqlTemplateFunction { + // Tagged template usage: + (strings: TemplateStringsArray, ...values: any[]): SqlStatement; + query(sql: string, ...params: any[]): SqlStatement; +}; +``` + +Create a SQL template function for PostgreSQL/datatable queries +@param name - Database/datatable name (default: "main") +@returns SQL template function for building parameterized queries +@example +let sql = wmill.datatable() +let name = 'Robin' +let age = 21 +await sql` + SELECT * FROM friends + WHERE name = ${name} AND age = ${age}::int +`.fetch() +```typescript +function datatable(name: string = "main"): DatatableSqlTemplateFunction +``` diff --git a/system_prompts/auto-generated/sdks/python.md b/system_prompts/auto-generated/sdks/python.md index 7163e76a4a..241d438f58 100644 --- a/system_prompts/auto-generated/sdks/python.md +++ b/system_prompts/auto-generated/sdks/python.md @@ -648,12 +648,17 @@ async def sleep(seconds: int) # # Returns a dict with ``value`` (form data), ``approver``, and ``approved``. # +# Args: +# timeout: Approval timeout in seconds (default 1800). +# form: Optional form schema for the approval page. +# self_approval: Whether the user who triggered the flow can approve it (default True). +# # Example:: # # urls = await step("urls", lambda: get_resume_urls()) # await step("notify", lambda: send_email(urls["approvalPage"])) # result = await wait_for_approval(timeout=3600) -async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict +async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict # Process items in parallel with optional concurrency control. # diff --git a/system_prompts/auto-generated/sdks/typescript.md b/system_prompts/auto-generated/sdks/typescript.md index f38ba274c1..8d96473313 100644 --- a/system_prompts/auto-generated/sdks/typescript.md +++ b/system_prompts/auto-generated/sdks/typescript.md @@ -481,7 +481,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 1290fb1395..8a9f231fc2 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -97,6 +97,7 @@ flow related commands - `--remote` - Use deployed workspace scripts for PathScript steps instead of local files. - `flow generate-locks [flow:file]` - re-generate the lock files of all inline scripts of all updated flows - `--yes` - Skip confirmation prompt + - `--dry-run` - Perform a dry run without making changes - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string) - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account. - `flow new ` - create a new empty flow @@ -139,6 +140,7 @@ Generate metadata (locks, schemas) for all scripts, flows, and apps - `--skip-scripts` - Skip processing scripts - `--skip-flows` - Skip processing flows - `--skip-apps` - Skip processing apps +- `--strict-folder-boundaries` - Only update items inside the specified folder (requires folder argument) - `-i --includes ` - Comma separated patterns to specify which files to include - `-e --excludes ` - Comma separated patterns to specify which files to exclude diff --git a/system_prompts/auto-generated/skills/write-flow/SKILL.md b/system_prompts/auto-generated/skills/write-flow/SKILL.md index 39552915b7..17a94194d8 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -125,4 +125,4 @@ Reference a specific resource using `$res:` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"number","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"number","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"number","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_use":{"type":"boolean","description":"If true, this step's result is deleted after use to save memory"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_images":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of image references for vision-capable models.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'images/photo.jpg' }]\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \"yellow\", \"#ffff00\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_use":{"type":"boolean","description":"If true, this step's result is deleted after use to save memory"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\n"},"user_images":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of image references for vision-capable models.\nFormat: Array<{ bucket: string, key: string }> - S3 object references\nExample: [{ bucket: 'my-bucket', key: 'images/photo.jpg' }]\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} \ No newline at end of file diff --git a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md index ba40a2d624..b4db20ae80 100644 --- a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md @@ -610,7 +610,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. diff --git a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md index cdd015863a..ecf7fe2103 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -608,7 +608,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. diff --git a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md index fddae85f6e..563d01ed48 100644 --- a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md @@ -614,7 +614,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. diff --git a/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md b/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md index 4687be55e4..1d52290283 100644 --- a/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-nativets/SKILL.md @@ -575,7 +575,7 @@ workflow(fn: (...args: any[]) => Promise): void * await step("notify", () => sendEmail(urls.approvalPage)); * const { value, approver } = await waitForApproval({ timeout: 3600 }); */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> +waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> /** * Process items in parallel with optional concurrency control. diff --git a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md index c860ee696c..e6aa3b848c 100644 --- a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md @@ -783,12 +783,17 @@ async def sleep(seconds: int) # # Returns a dict with ``value`` (form data), ``approver``, and ``approved``. # +# Args: +# timeout: Approval timeout in seconds (default 1800). +# form: Optional form schema for the approval page. +# self_approval: Whether the user who triggered the flow can approve it (default True). +# # Example:: # # urls = await step("urls", lambda: get_resume_urls()) # await step("notify", lambda: send_email(urls["approvalPage"])) # result = await wait_for_approval(timeout=3600) -async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict +async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict # Process items in parallel with optional concurrency control. # diff --git a/system_prompts/generate.py b/system_prompts/generate.py index cf57a3cecb..034c94e20d 100644 --- a/system_prompts/generate.py +++ b/system_prompts/generate.py @@ -655,6 +655,202 @@ def generate_schema_files(cli_schemas: dict[str, dict]) -> dict[str, str]: return schema_yaml_content +# ============================================================================= +# Datatable SDK Extraction +# ============================================================================= + + +TS_SQL_UTILS_PATH = TS_SDK_DIR / "sqlUtils.ts" + + +def extract_datatable_ts_sdk() -> str: + """Extract datatable-specific type definitions from TypeScript SDK (sqlUtils.ts). + + Reads the source file and extracts the public API surface: + - SqlStatement type (fetch, fetchOne, fetchOneScalar, execute methods) + - DatatableSqlTemplateFunction interface (template tag + query method) + - datatable() function signature + """ + if not TS_SQL_UTILS_PATH.exists(): + print(f" Warning: sqlUtils.ts not found at {TS_SQL_UTILS_PATH}") + return '' + + content = TS_SQL_UTILS_PATH.read_text() + + md = "## TypeScript Datatable API (windmill-client)\n\n" + md += "Import: `import * as wmill from 'windmill-client'`\n\n" + + # Extract exported type/interface/function definitions from sqlUtils.ts + # We use extract_balanced to handle nested braces correctly + + # 1. Extract SqlStatement type + match = re.search(r'(\/\*\*(?:[^*]|\*(?!\/))*\*\/\s*)?export\s+type\s+SqlStatement\s*=\s*', content) + if match: + jsdoc_raw = match.group(1) + brace_start = content.index('{', match.end() - 1) + body, end = extract_balanced(content, brace_start, '{', '}') + if end != -1: + if jsdoc_raw: + md += clean_jsdoc(jsdoc_raw) + "\n" + md += "```typescript\n" + md += f"type SqlStatement = {{\n{_indent_body(body)}\n}};\n" + md += "```\n\n" + + # 2. Extract DatatableSqlTemplateFunction interface + match = re.search( + r'(\/\*\*(?:[^*]|\*(?!\/))*\*\/\s*)?export\s+interface\s+DatatableSqlTemplateFunction\s+extends\s+SqlTemplateFunction\s*', + content + ) + if match: + brace_start = content.index('{', match.end() - 1) + body, end = extract_balanced(content, brace_start, '{', '}') + if end != -1: + md += "```typescript\n" + md += "// Template tag function: sql`SELECT * FROM table WHERE id = ${id}`.fetch()\n" + md += f"interface DatatableSqlTemplateFunction {{\n" + md += f" // Tagged template usage:\n" + md += f" (strings: TemplateStringsArray, ...values: any[]): SqlStatement;\n" + md += f"{_indent_body(body)}\n" + md += "};\n" + md += "```\n\n" + + # 3. Extract datatable() function + match = re.search( + r'(\/\*\*(?:[^*]|\*(?!\/))*\*\/\s*)?export\s+function\s+datatable\s*\(([^)]*)\)\s*:\s*(\S+)', + content + ) + if match: + jsdoc_raw, params, return_type = match.groups() + if jsdoc_raw: + md += clean_jsdoc(jsdoc_raw) + "\n" + md += "```typescript\n" + md += f"function datatable({params.strip()}): {return_type}\n" + md += "```\n" + + return md + + +def extract_datatable_py_sdk(py_content: str) -> str: + """Extract datatable-specific class/function definitions from Python SDK. + + Uses Python AST to extract: + - datatable() function + - DataTableClient class with query() method + - SqlQuery class with fetch(), fetch_one(), fetch_one_scalar(), execute() methods + """ + if not py_content: + return '' + + try: + tree = ast.parse(py_content) + except SyntaxError as e: + print(f" Warning: Could not parse Python SDK for datatable extraction: {e}") + return '' + + md = "## Python Datatable API (wmill)\n\n" + md += "Import: `import wmill`\n\n" + + # Target classes and the top-level datatable function + target_classes = {'DataTableClient', 'SqlQuery'} + + # 1. Extract datatable() top-level function + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == 'datatable': + docstring = ast.get_docstring(node) or '' + params = _format_py_params(node) + return_ann = f" -> {ast.unparse(node.returns)}" if node.returns else '' + if docstring: + for line in docstring.split('\n'): + md += f"# {line}\n" + md += f"def datatable({params}){return_ann}\n\n" + break + + # 2. Extract target classes with their public methods + for node in tree.body: + if isinstance(node, ast.ClassDef) and node.name in target_classes: + class_doc = ast.get_docstring(node) or '' + if class_doc: + for line in class_doc.split('\n'): + md += f"# {line}\n" + md += f"class {node.name}:\n" + + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + if item.name.startswith('_') and item.name != '__init__': + continue + docstring = ast.get_docstring(item) or '' + params = _format_py_params(item, skip_self=True) + return_ann = f" -> {ast.unparse(item.returns)}" if item.returns else '' + async_prefix = 'async ' if isinstance(item, ast.AsyncFunctionDef) else '' + if docstring: + for line in docstring.split('\n'): + md += f" # {line}\n" + md += f" {async_prefix}def {item.name}({params}){return_ann}\n\n" + + md += "\n" + + return md + + +def _format_py_params(node: ast.FunctionDef, skip_self: bool = False) -> str: + """Format function parameters from AST node.""" + params = [] + args = node.args + num_defaults = len(args.defaults) + num_args = len(args.args) + + for i, arg in enumerate(args.args): + if skip_self and arg.arg == 'self': + continue + param_str = arg.arg + if arg.annotation: + param_str += f": {ast.unparse(arg.annotation)}" + default_idx = i - (num_args - num_defaults) + if default_idx >= 0: + default = args.defaults[default_idx] + param_str += f" = {ast.unparse(default)}" + params.append(param_str) + + if args.vararg: + vararg_str = f"*{args.vararg.arg}" + if args.vararg.annotation: + vararg_str += f": {ast.unparse(args.vararg.annotation)}" + params.append(vararg_str) + + for i, arg in enumerate(args.kwonlyargs): + param_str = arg.arg + if arg.annotation: + param_str += f": {ast.unparse(arg.annotation)}" + if args.kw_defaults[i]: + param_str += f" = {ast.unparse(args.kw_defaults[i])}" + params.append(param_str) + + if args.kwarg: + kwarg_str = f"**{args.kwarg.arg}" + if args.kwarg.annotation: + kwarg_str += f": {ast.unparse(args.kwarg.annotation)}" + params.append(kwarg_str) + + return ', '.join(params) + + +def _indent_body(body: str) -> str: + """Clean and re-indent a type body for readable output.""" + lines = body.strip().split('\n') + result = [] + for line in lines: + stripped = line.strip() + if stripped: + # Keep JSDoc comments and method signatures with consistent indentation + if not stripped.startswith('//') and not stripped.startswith('/*') and not stripped.startswith('*'): + result.append(f" {stripped}") + else: + result.append(f" {stripped}") + else: + result.append('') + return '\n'.join(result) + + # ============================================================================= # Skill Generation # ============================================================================= @@ -947,6 +1143,13 @@ def main(): (OUTPUT_SDKS_DIR / "python.md").write_text(py_sdk_md) print(f" Found {len(py_functions)} functions, {len(py_classes)} classes") + # Extract datatable-specific SDK docs (for app mode system prompt) + print("Extracting datatable SDK docs...") + datatable_ts_md = extract_datatable_ts_sdk() + datatable_py_md = extract_datatable_py_sdk(py_content) + (OUTPUT_SDKS_DIR / "datatable-typescript.md").write_text(datatable_ts_md) + (OUTPUT_SDKS_DIR / "datatable-python.md").write_text(datatable_py_md) + # Read base prompts print("Assembling complete prompts...") base_dir = SCRIPT_DIR / "base" @@ -1009,6 +1212,10 @@ def main(): 'SDK_TYPESCRIPT': ts_sdk_md, 'SDK_PYTHON': py_sdk_md, + # Datatable-specific SDK docs (for app mode) + 'DATATABLE_SDK_TYPESCRIPT': datatable_ts_md, + 'DATATABLE_SDK_PYTHON': datatable_py_md, + # Schema (raw YAML content) 'OPENFLOW_SCHEMA': openflow_content, @@ -1077,6 +1284,14 @@ export function getFlowPrompt(): string { prompts.OPENFLOW_SCHEMA ].filter(Boolean).join('\\n\\n'); } + +// Helper to get datatable SDK reference for app mode +export function getDatatableSdkReference(): string { + return [ + prompts.DATATABLE_SDK_TYPESCRIPT, + prompts.DATATABLE_SDK_PYTHON + ].filter(Boolean).join('\\n\\n'); +} """ (OUTPUT_GENERATED_DIR / "index.ts").write_text(index_content) diff --git a/typescript-client/client.ts b/typescript-client/client.ts index 49087a8b60..1aded4a720 100644 --- a/typescript-client/client.ts +++ b/typescript-client/client.ts @@ -1577,6 +1577,7 @@ export class WorkflowCtx { _waitForApproval(options?: { timeout?: number; form?: object; + selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> { const key = this._allocKey("approval"); @@ -1597,6 +1598,7 @@ export class WorkflowCtx { key, timeout: options?.timeout ?? 1800, form: options?.form, + self_approval_disabled: !(options?.selfApproval ?? true), steps: [], }); } @@ -1842,6 +1844,7 @@ export function workflow(fn: (...args: any[]) => Promise) { export function waitForApproval(options?: { timeout?: number; form?: object; + selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> { const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx"); if (!ctx) { diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index d1f9ea1b1a..9632ec19a2 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.662.0", + "version": "1.664.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 8a37e00e0c..d4023c7e69 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.662.0", + "version": "1.664.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index fd3cab66e3..694b27ca91 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.662.0 +1.664.0