Compare commits

...
Author SHA1 Message Date
Ruben Fiszelandrubenfiszel 6060ac3adc chore(main): release 1.664.0 (#8498)
* chore(main): release 1.664.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-03-24 21:40:26 +00:00
Ruben FiszelandClaude Opus 4.6 d578e40101 feat: add selfApproval option to WAC + inline approval buttons (#8440)
* feat: add selfApproval option to WAC waitForApproval + inline approval buttons

Add self-approval configuration to WAC workflows and inline
approve/reject buttons in WorkflowTimeline.

- TS SDK: add selfApproval option to waitForApproval()
- Python SDK: add self_approval param to wait_for_approval()
- Backend: store approval_conditions in flow_status for WAC,
  enforce self-approval checks on resume endpoints
- Frontend: show Approve/Reject buttons in timeline with form
  support (EE), gated by user permissions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: revert sqlx query change + regenerate system prompts

- Revert get_suspended_flow_info to use original sqlx::query_as!
  with COALESCE to avoid sqlx offline cache mismatch in CI
- Detect WAC by checking if FlowStatus parsing fails + suspend > 0
- Re-fetch flow_status column separately for WAC approval conditions
- Regenerate auto-generated system prompt files for SDK changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: use resume URLs for WAC inline approval buttons

- Backend generates HMAC-signed resume/cancel URLs when creating
  WAC approval, stores them in timeline entry and approval meta
- Frontend uses anonymous resume endpoint (like classic flows)
  with fallback to resumeSuspendedFlowAsOwner for admins
- Buttons show for everyone when URLs are present; server-side
  self_approval_disabled check enforces restrictions
- Show warning for admins/owners when self-approval is disabled
- selfApproval: false requires EE (errors at dispatch on CE)
- self_approval_disabled check moved outside user_auth_required
  gate so it works independently
- WAC detection no longer requires task import

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add resume_suspended and approval_info endpoints

- New approval_token DB table for token-based approval access
- New POST /jobs_u/flow/resume_suspended/{job_id} endpoint:
  - OptAuthed: works with login or approval_token
  - Checks approval_conditions (self_approval, groups, auth)
  - Admins/owners bypass rules
- New GET /jobs_u/flow/approval_info/{job_id} endpoint:
  - Returns form, rules, can_approve status
- HMAC anonymous endpoint now bypasses all approval_conditions
  (secret = full capability)
- getResumeUrls approvalPage URL now uses token format
- WAC approval dispatch generates and stores approval tokens
- Mark resumeSuspendedFlowAsOwner as legacy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: simplify frontend to use resume_suspended endpoint

- OpenAPI spec updated with resume_suspended and approval_info endpoints
- WorkflowTimeline: removed URL parsing, now calls single
  resumeSuspended endpoint for both approve and reject
- Buttons show for any logged-in user viewing the job (backend
  enforces authorization rules)
- Kept self-approval warning for admins

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: stateless approval tokens, new approval page, FlowStatusWaitingForEvents update

- Replace DB-stored approval tokens with stateless HMAC derivation:
  token = HMAC(workspace_key, job_id + "approval_token")
  Verifiable without DB lookup, not reversible to resume secret
- Drop approval_token migration (no DB table needed)
- FlowStatusWaitingForEvents: use resumeSuspended endpoint instead
  of URL parsing + resumeSuspendedFlowAsOwner
- New approval page route /approve/{ws}/{job}?token= that uses
  approval_info and resume_suspended endpoints
- Old approval page route kept for back-compat

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: match old approval page content in new approval page

- Add FlowMetadata, JobArgs, FlowGraphV2, DisplayResult
- Add approvers with tooltips, flow arguments section
- Add admin self-approval bypass warning
- Add "Open run details" link
- Fetch full job alongside approval_info for all UI data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: filter _MODULES from args, show 'workflow' for WAC approvals

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: remove deno template from approval/prompt SuspendDrawer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: approval page form display + hide deno from approval script picker

- Fix form schema rendering on new approval page by wrapping flat
  WAC form schemas in { properties, order } for SchemaForm
- Hide deno from the approval step language picker in flow editor

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove deno from canHaveApproval in script_helpers.ts

The insert menu uses canHaveApproval() from script_helpers.ts via
FlowInputsQuick, not the displayLang function in FlowInputs.svelte.
Revert the unnecessary FlowInputs.svelte change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: return form schema and description in approval_info for classic flows

The approval_info endpoint was returning None for form_schema on
classic flows. Now fetches raw_flow to get suspend.resume_form
schema, hide_cancel, and the step's completed result for description.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: inline Login component on approval page instead of redirect

Show the Login component directly on the approval page when
authentication is required. On successful login, reloads user
and approval info without navigating away.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: show resume buttons for all users, not just owners

The resume_suspended endpoint handles authorization server-side,
so the frontend should always show the buttons. Remove isOwner
gate and the "cannot resume" message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent layout shift on resume by removing spinner from cancel button

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent resume button expansion by using disabled instead of loading

The loading prop adds a Loader2 spinner that expands the button width.
Use disabled={loading} instead to prevent layout shift.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: approval page login redirects back with full page reload

Set rd to the full URL (starts with http) so Login.redirectUser()
uses window.location.href instead of goto(), triggering a full page
reload after login. This ensures the approval page re-fetches data
as an authenticated user.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: fetch flow definition from flow_version when raw_flow is null

Deployed flows don't store raw_flow on the job. Fall back to
flow_version table using runnable_id to get suspend settings
(form schema, hide_cancel) for the approval_info endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: show specific reasons when user cannot approve

Display whether denial is due to self-approval being disabled,
required group membership, or both.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: support both nested and flat form schema in waitForApproval

Users can now pass either:
  waitForApproval({ form: { schema: { name: { type: "string" } } } })
or:
  waitForApproval({ form: { name: { type: "string" } } })

Both WorkflowTimeline and approval page handle both formats.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: convert sqlx query macros to non-macro for CI offline cache

Replace sqlx::query! and sqlx::query_scalar! with sqlx::query and
sqlx::query_as to avoid SQLX_OFFLINE cache misses in CI.
Also remove unused LogIn import from approval page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: suppress dead code warning + unused isOwner variable

- Add #[allow(dead_code)] to without_flow method (CI -D warnings)
- Rename isOwner to _isOwner in FlowStatusWaitingForEvents (unused)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: security and robustness fixes from PR review

- Add workspace_id verification in resume_suspended to prevent
  cross-workspace approval (#3)
- Fix token leakage: use relative path for login redirect instead
  of full URL with token (#4)
- Handle getJob failure independently from approval_info so the
  page works for unauthenticated users (#7)
- Clear error state on successful data load (#13)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback — shared token gen, rand resume_id, UX

- Move generate_approval_token to windmill-common::variables (shared
  between windmill-api and windmill-worker, eliminates duplicate HMAC)
- Use rand::random::<u32>() for resume_id instead of DefaultHasher
- Stop polling after approve/reject on approval page
- Add cancelLoading state to WorkflowTimeline Reject button

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:22:35 +00:00
centdixandClaude Opus 4.6 db5e03610d feat: add instance-level AI settings (#8453)
* feat: add instance-level AI settings with workspace fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add AI step to onboarding setup wizard

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: thread workspace prop through resource editor and disable chat offset

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Revert "fix: thread workspace prop through resource editor and disable chat offset"

This reverts commit 9fea9cc0c239f6432d1fef1487c45e74ab752e21.

* fix: set workspace store and disable chat offset during AI setup step

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: thread workspace and disableChatOffset props through resource editors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: populate workspace and user stores for AI step path component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: initialize AI clients for test key during onboarding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: extract AI config state into InstanceAISettings component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: move AI config state ownership into AISettings component

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Persist instance AI settings before navigation

* Reload effective workspace AI state after save

* Scope AI key tests to the rendered workspace

* Add post-create AI onboarding for new workspaces

* Unify instance AI settings header

* Fix instance AI drawer offset on workspace selection

* Add instance AI fallback settings behavior

* Update sqlx metadata

* Update sqlx metadata

* Clarify active instance AI in workspace settings

* Refresh workspace AI state after instance AI save

* Declare instance AI summary in API schema

* Normalize empty instance AI config handling

* Clean up workspace AI settings UI

* Unify AI config provider checks

* Split AI settings metadata from effective config

* Propagate instance AI cache invalidation across servers

* Fix AI settings dirty state tracking

* Update sqlx metadata

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 19:18:36 +00:00
a26a2e8092 defense in depth against SQL injection in folder, oauth, and SCIM queries (#8496)
* fix: use bind parameters for folder owner in jsonb_set queries

Replace format! string interpolation of owner into jsonb_set path
with proper $N bind parameters to prevent potential SQL injection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref.txt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to faeaa43bbe2ba4804f80b828b85fd4d6daef096c

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

Previous ee-repo-ref: 0d4444cb5825fa43629d856cc8565cc052512d4c

New ee-repo-ref: faeaa43bbe2ba4804f80b828b85fd4d6daef096c

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-03-24 16:48:56 +00:00
GuilhemandClaude Opus 4.6 81eb446eee feat: flow group nodes with collapsible groups (#8075)
* feat: add flow group nodes core infrastructure

Add group data model (start_id/end_id boundary pairs), GroupEditor for
CRUD operations, groupDetectionUtils for membership computation and
validation, GroupedModulesProxy for reactive sync, and compound layout
support. Update openflow.openapi.yaml with group schema.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add group UI components and rendering

Add GroupOverlay with bounding box and z-ordering, GroupHeader with
StepCountTab and ellipsis menu, GroupNodeCard, GroupNoteArea for inline
markdown notes, CollapsedGroupNode/CollapsedSubflowNode for collapsed
rendering, GroupEndNode/GroupHeadNode boundary markers, and group
actions in NodeContextMenu and SelectionBoundingBox.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: integrate groups into flow graph, builder, and existing components

Wire group support into FlowGraphV2 (overlays, collapsed rendering,
group-aware layout), graphBuilder (GroupedModule tree, container
collapse/expand, group boundary nodes), BaseEdge (drop targets for
group operations), ModuleNode (collapsed container rendering), and
flow map components (schema item grouping). Remove SubflowBound in
favor of CollapsedSubflowNode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove banned $bindable(default) pattern and dead ternary

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: decouple collapse state from grouped module tree

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: pass groups prop to FlowGraphV2 and use GroupDisplayState via graphContext

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove group membership system, compute nesting depth from visual bounds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: simplify GroupOverlay bounds, remove unused headerY and showNotes prop

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: populate innerNodeIds for expanded subflow overlay

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove expanded subflow overlay feature for separate PR

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: flatten groups in getContainerModules to prevent crash on collapsed containers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add drag-to-move support for group nodes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: derive group boundaries from expanded membership to prevent splitting existing groups

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: catch group validation errors and display as flow graph alert

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add unit tests for group validation in buildGroupedModules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: reject virtual nodes (Input, Result, Trigger) from groups

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add virtual node rejection tests for buildGroupedModules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: exclude preprocessor and failure module from groups

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: disable Create group button when preprocessor is selected

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: reject selection entirely when it contains excluded nodes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove unnecessary excludeIds from buildGroupedModules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove debug console.log from FlowGraphV2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use cross-browser CSS grid trick for group summary input auto-sizing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: hide group boundary edges and reformat GroupNoteArea

Hide edges between group header and first node, and between last node
and group-end, keeping them in the DOM but visually hidden.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: stop FlowGraphV2 from reading groups via groupEditorContext

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: show module previews with status, selection, and suspend popover in collapsed groups

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: extract collapsible implicit containers to separate branch

Remove collapse/expand functionality for implicit containers (forloops,
while loops, branches) from this branch. Backed up as
collapsible-implicit-containers-backup for later rebase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: use original reactive modules for graph node data instead of proxy snapshots

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent node loss when moving into forloop inside a group

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: replace GroupedModule proxy with structure-only FlowStructureNode tree

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use "group-" prefix for group IDs instead of "note-"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update group boundaries when renaming a module ID

When a module at a group boundary (start_id or end_id) is renamed,
the group definitions now get updated before the reactive rebuild,
preventing stale references that would break the flow structure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update graph layout when removing a group note

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add opaque background behind test run button to prevent see-through

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: detect and reject duplicate group IDs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: simplify group creation validation with early marker normalization

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use $state.raw in MiniFlowGraph to avoid xyflow performance warning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: address code review feedback

- Revert backend traverse_modules change (not part of this feature)
- Use Map for node lookup in GroupOverlay (O(1) vs O(n) per group)
- Extract computeNodeExtraSpace to nodeExtraSpace.ts for testability

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: address PR review feedback

- Compute group depths from structure tree O(n) instead of O(n²) bounds comparison
- Remove unnecessary $derived(groups) in GroupOverlay
- Remove unused collapsed field from container types in OpenAPI spec
- Use NODE.width constant in GroupNodeCard instead of hardcoded 275px
- Add comment explaining intentional stale preservation in rebuild()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: preserve flow groups during dependency job re-serialization

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve Svelte state_referenced_locally warnings in GroupHeader and FlowGraphV2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: show subflow groups when expanding a subflow in the graph

- Store both modules and groups when expanding a subflow
- Pass groups to buildStructureTree so group nodes render
- Include subflow groups in overlay rendering and collapse tracking
- Clone modules for prefix rewriting to avoid state_unsafe_mutation
- Register expanded subflow modules in moduleMap before prefix rewriting
- Disable group editing in expanded subflows and read-only views

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: restore accidentally removed code from main

- Restore subflowBound selection handling in selectionUtils
- Restore comments in SelectionBoundingBox
- Restore deletable={false} in FirstStepInputs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove redundant adjacency check from MoveManager

The disableMoveIds check already prevents all invalid drop targets,
making the adjacencySourceId/adjacencyTargetId fields unnecessary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate auto-generated files after OpenAPI schema change

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate cli skills after main merge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: include groups in view_graph localStorage state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: centralize canCreateGroup and replace group note with group creation

- Add canCreateGroup StateStore to GroupEditorContext, computed in FlowGraphV2
- Replace "Create group note" with "Create group" in FlowSelectionPanel
- Remove "Add note" from selection bounding box dropdown
- Remove unused NodeContextMenu component
- Wire createGroup through FlowModuleSchemaMap → FlowGraphV2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: reject groups spanning parallel branches and surface ill-formed group errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate auto-generated files after main merge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: ensure modules appears before groups in YAML export

Svelte 5's $state proxy registers groups as a tracked property before
it's explicitly set, causing it to appear before modules in Object.keys
iteration. Reorder the value object at export time for readable YAML.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: address second round of PR review feedback

- Add comment explaining duplicateMultiple bypasses structure tree
- Add warning log for inverted ranges in computeGroupModuleIds
- Use NODE.width constant in CollapsedGroupNode instead of hardcoded 275px
- Simplify redundant condition in getGroupsEmptiedBy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove stored group ID, derive ephemeral key from start_id:end_id

Groups no longer store an `id` field. Instead, a `groupKey(g)` helper
derives an ephemeral key from `${start_id}:${end_id}` at read time.
This simplifies the schema while preserving all runtime functionality.

When boundaries shift (module deletion), runtime state (collapse,
note heights) is remapped to the new key via GroupDisplayState.remapGroupKey.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add note button, save/cancel hints, and rename collapsed_by_default to autocollapse

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: propagate selection from collapsed group badges to external listeners

Pass eventHandlers to GroupModuleIcons so clicking a module badge
calls both selectionManager.selectId (visual highlight) and
eventHandlers.select (side panel propagation via onSelect).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate auto-generated files after main merge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: hide In/Out popovers and actions during click-to-move

Replace isDragging with isMoving derived that covers both drag-move
and click-move states, disabling popovers, delete button, and test
run button during any move operation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:47:33 +00:00
Ruben Fiszel 8cfaa91d43 update cli freshness 2026-03-24 16:01:18 +00:00
Alexander PetricandClaude Opus 4.6 bdfd5d5726 fix: add GIT_SSL_CAINFO to tracing proxy env vars (#8502)
Git uses libcurl with GnuTLS on Debian, which doesn't read
SSL_CERT_FILE or CURL_CA_BUNDLE for CA trust. When the OTEL tracing
proxy is enabled, git clone fails with "certificate signer not trusted"
because it can't verify the proxy's MITM certificate.

Adding GIT_SSL_CAINFO pointing to the proxy CA cert fixes this.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 16:00:32 +00:00
Diego Imbert 2048a36376 Fix select key bug (#8499) 2026-03-24 15:42:16 +00:00
Ruben FiszelandClaude Opus 4.5 3c34d19813 escape env var values in nativets/bun JS string interpolation (#8500)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-24 15:41:39 +00:00
Ruben FiszelandClaude Opus 4.5 7f27d996ac fix: create parent dirs and accept 'python' alias in script bootstrap (#8497)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-24 15:16:10 +00:00
Ruben Fiszelandrubenfiszel 6d63d9973d chore(main): release 1.663.0 (#8465)
* chore(main): release 1.663.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-03-24 13:31:06 +00:00
Rogelio Alcala Ortiz 23df390b17 allow modern email TLDs in superadmin setup form (#8472) 2026-03-24 13:27:41 +00:00
hugocasaandClaude Opus 4.6 5089a45881 feat: add summary field for native triggers (#8476)
* feat: add summary field for native triggers (nextcloud, google)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add nullable to NativeTriggerData summary in openapi spec

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: include summary in native trigger search index

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:27:09 +00:00
hugocasaandClaude Opus 4.6 f035b538bb feat: surface permissioned_as selector in trigger editor UI (#8475)
* feat: surface permissioned_as selector in trigger editor UI

Add OnBehalfOfSelector to TriggerEditorToolbar so users can see and
control who a trigger runs as. Admins/deployers can preserve the
current permissioned_as or pick a custom user; non-admins see the
current value but options are disabled.

Applies to all trigger types: schedule, kafka, http, websocket,
postgres, nats, mqtt, sqs, gcp, and email.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: move permissioned_as selector from toolbar to config area

Move OnBehalfOfSelector out of TriggerEditorToolbar (too cluttered)
into a new PermissionedAsLine component rendered at the top of each
trigger editor's config body. Lighter footprint, same functionality.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: show explicit warning when saving will change permissioned_as

Use an Alert (warning/info) to clearly show who the trigger currently
runs as and whether saving will change it. Non-admin users see a
warning that it will switch to them. Admins see the OnBehalfOfSelector
to preserve or pick a custom user.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: make permissioned_as line subtle instead of big alert box

Replace the Alert component with a small inline text line using
text-2xs. Shows warning arrow + yellow text only when saving will
actually change the permissioned_as.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: split permissioned_as display for admin vs non-admin

Admins see just "Permissioned as" label + the OnBehalfOfSelector
(no duplicate username). Non-admins see the plain text line with
warning arrow when it will change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: show warning for admins too when permissioned_as will change

Admins now see a yellow warning next to the selector when their
choice differs from the current permissioned_as value.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use consistent warning text for permissioned_as change

Both admin and non-admin warnings now say
"will change to <user> on save" instead of using an arrow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: bold permission strings in permissioned_as warnings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: bold the non-editable permissioned_as value too

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove mono font from non-editable permissioned_as value

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add consistent bottom margin to permissioned_as line

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: consistent spacing for permissioned_as line

Move PermissionedAsLine outside the gap-8 div in schedule editor
and increase margin to mb-4 for consistent spacing across all
trigger types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:26:56 +00:00
hugocasaandClaude Opus 4.6 47c0c363f4 fix: clean up stale dependency map entries for renamed scripts (#8492)
* fix: clean up stale dependency map entries for renamed scripts

When a script is renamed, trigger_dependents_to_recompute_dependencies()
could find the archived script at the old path and create a dependency
job for it. This job would process the old code and recreate stale
dependency_map entries, causing incorrect deployment warnings.

Add `AND archived = false` to the script lookup query so that renamed
(archived) scripts at old paths trigger clear_map_for_item() cleanup
instead of spawning dependency jobs for obsolete code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: also filter archived flows in trigger_dependents

Apply the same archived check to the flow lookup query. The flow table
has an archived column, so when a flow is renamed/archived its
flow_version rows would still be found. Join against the flow table
and filter archived = false to trigger cleanup instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* revert: remove unnecessary flow archived check

Flow renames delete the old flow row and INSERT a new one at the new
path (for FK constraints on flow_version). There is no archived flow
row left behind, so the original query is already correct for flows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:25:07 +00:00
Ruben FiszelandClaude Opus 4.6 54f5a19377 fix: prevent SQL injection in job query parameters (#8494)
Replace unsafe string interpolation (format!("'{}'", t)) with
sql_builder::quote() in SQL query construction. The tags parameter in
count_completed_jobs_detail was directly interpolated without escaping,
allowing authenticated users to inject arbitrary SQL via the query string.

Also hardens LIKE clauses, JSON operators, and JOIN conditions across
query.rs and variables.rs that used manual .replace("'", "''") instead
of the crate's quote() function, and converts format-interpolated bind
values to parameterized queries where possible.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:23:43 +00:00
174 changed files with 7837 additions and 1905 deletions
+40
View File
@@ -1,5 +1,45 @@
# Changelog
## [1.664.0](https://github.com/windmill-labs/windmill/compare/v1.663.0...v1.664.0) (2026-03-24)
### Features
* add instance-level AI settings ([#8453](https://github.com/windmill-labs/windmill/issues/8453)) ([db5e036](https://github.com/windmill-labs/windmill/commit/db5e03610da325288d53afdbca94b9cbfc7ceace))
* add selfApproval option to WAC + inline approval buttons ([#8440](https://github.com/windmill-labs/windmill/issues/8440)) ([d578e40](https://github.com/windmill-labs/windmill/commit/d578e40101a838d3dffda14157cf72ee4d5a93c0))
* flow group nodes with collapsible groups ([#8075](https://github.com/windmill-labs/windmill/issues/8075)) ([81eb446](https://github.com/windmill-labs/windmill/commit/81eb446eee359f44374b81320690e5345fd08c15))
### Bug Fixes
* add GIT_SSL_CAINFO to tracing proxy env vars ([#8502](https://github.com/windmill-labs/windmill/issues/8502)) ([bdfd5d5](https://github.com/windmill-labs/windmill/commit/bdfd5d57261a4bb760fc57ad41ee56aff9b9c0af))
* create parent dirs and accept 'python' alias in script bootstrap ([#8497](https://github.com/windmill-labs/windmill/issues/8497)) ([7f27d99](https://github.com/windmill-labs/windmill/commit/7f27d996accb3c3b471d1c50df397867d89c738a))
## [1.663.0](https://github.com/windmill-labs/windmill/compare/v1.662.0...v1.663.0) (2026-03-24)
### Features
* add summary field for native triggers ([#8476](https://github.com/windmill-labs/windmill/issues/8476)) ([5089a45](https://github.com/windmill-labs/windmill/commit/5089a458819abbc6f241bc354bebb91520bd1a52))
* add typed request body to OpenAPI spec generation ([#8481](https://github.com/windmill-labs/windmill/issues/8481)) ([37ebaf4](https://github.com/windmill-labs/windmill/commit/37ebaf4d0ac342703498733f97778a552f979f6a))
* **cli:** better stale scripts detection [#3](https://github.com/windmill-labs/windmill/issues/3) ([#8480](https://github.com/windmill-labs/windmill/issues/8480)) ([9643006](https://github.com/windmill-labs/windmill/commit/9643006f1e90b991b334bb58caf62301bc26d09d))
* Debounce node ([#8324](https://github.com/windmill-labs/windmill/issues/8324)) ([5d1c54d](https://github.com/windmill-labs/windmill/commit/5d1c54d9b33d6ff6f2c98481a2740d1e7629cdfa))
* surface permissioned_as selector in trigger editor UI ([#8475](https://github.com/windmill-labs/windmill/issues/8475)) ([f035b53](https://github.com/windmill-labs/windmill/commit/f035b538bbd786445526339f88be8f33a3628105))
### Bug Fixes
* clean up stale dependency map entries for renamed scripts ([#8492](https://github.com/windmill-labs/windmill/issues/8492)) ([47c0c36](https://github.com/windmill-labs/windmill/commit/47c0c363f4fc1d9af7efd07ea172e32989ce50d2))
* **cli:** add Svelte 5 event delegation guidance and safe push to raw-app skill ([#8466](https://github.com/windmill-labs/windmill/issues/8466)) ([911df95](https://github.com/windmill-labs/windmill/commit/911df958e78d2dab9823dfa7d7e5c9824fc2d565))
* Fix worker panic when job_isolation changed to unshare at runtime ([#8490](https://github.com/windmill-labs/windmill/issues/8490)) ([cbe47c0](https://github.com/windmill-labs/windmill/commit/cbe47c0b6c22f79452d020777e481ee26970f25b))
* improve SQS retries ([3c8d351](https://github.com/windmill-labs/windmill/commit/3c8d351c9722a089133871019d27cf3bc3cdc159))
* Move database manager SQL queries to backend ([#8306](https://github.com/windmill-labs/windmill/issues/8306)) ([aa30fd2](https://github.com/windmill-labs/windmill/commit/aa30fd252dcf40233d191c43a6293fb9feabf010))
* prevent SQL injection in job query parameters ([#8494](https://github.com/windmill-labs/windmill/issues/8494)) ([54f5a19](https://github.com/windmill-labs/windmill/commit/54f5a19377e9df712e18f85f896e21b1776981ed))
* respect NO_COLOR env variable for stdout log output ([#8483](https://github.com/windmill-labs/windmill/issues/8483)) ([f329ee7](https://github.com/windmill-labs/windmill/commit/f329ee7aaefbae0ad344743c40825440a936bd30))
* show effective isolation level on workers page ([#8491](https://github.com/windmill-labs/windmill/issues/8491)) ([37886ed](https://github.com/windmill-labs/windmill/commit/37886edda1443293806a9b1b810196b72e076b12))
* skip debounce arg accumulation when batch table is empty (CE) ([#8485](https://github.com/windmill-labs/windmill/issues/8485)) ([010753c](https://github.com/windmill-labs/windmill/commit/010753c73ac85237af50acadf9c08567b1bc993c))
* stop_after_if with empty error_message prevents flow from stopping ([#8464](https://github.com/windmill-labs/windmill/issues/8464)) ([1503bf9](https://github.com/windmill-labs/windmill/commit/1503bf948e3340b8a6933d71885f8f2cb8dc1867))
## [1.662.0](https://github.com/windmill-labs/windmill/compare/v1.661.0...v1.662.0) (2026-03-20)
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO native_trigger (\n external_id,\n workspace_id,\n service_name,\n script_path,\n is_flow,\n webhook_token_hash,\n service_config\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7\n )\n ON CONFLICT (external_id, workspace_id, service_name)\n DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, error = NULL, updated_at = NOW()\n ",
"query": "\n INSERT INTO native_trigger (\n external_id,\n workspace_id,\n service_name,\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n summary\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8\n )\n ON CONFLICT (external_id, workspace_id, service_name)\n DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, summary = $8, error = NULL, updated_at = NOW()\n ",
"describe": {
"columns": [],
"parameters": {
@@ -21,10 +21,11 @@
"Varchar",
"Bool",
"Varchar",
"Jsonb"
"Jsonb",
"Varchar"
]
},
"nullable": []
},
"hash": "6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7"
"hash": "1048d1c95270ce1f36c02bce31a2bc8a88935c613bd213b7156299811377db8e"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n ",
"query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at,\n summary\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n ",
"describe": {
"columns": [
{
@@ -62,6 +62,11 @@
"ordinal": 9,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 10,
"name": "summary",
"type_info": "Varchar"
}
],
"parameters": {
@@ -91,8 +96,9 @@
true,
true,
false,
false
false,
true
]
},
"hash": "bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb"
"hash": "15014ce696cf2af4f719a537a4e3ca5b322cc130a35a91f8b8854f5ebdf25ad2"
}
@@ -15,7 +15,7 @@
]
},
"nullable": [
null
true
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND script_path = $3\n AND is_flow = $4\n LIMIT 1\n ",
"query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at,\n summary\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND script_path = $3\n AND is_flow = $4\n LIMIT 1\n ",
"describe": {
"columns": [
{
@@ -62,6 +62,11 @@
"ordinal": 9,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 10,
"name": "summary",
"type_info": "Varchar"
}
],
"parameters": {
@@ -92,8 +97,9 @@
true,
true,
false,
false
false,
true
]
},
"hash": "1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce"
"hash": "6dafcc89668fb0e5740f23264b515b1724c36031da39d53ae6c329a479bdf8aa"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value FROM global_settings WHERE name = 'ai_config'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "975099ff6b07718ea94bcb5f84a4414c59199964cee53ef2ee6b35a78cf0c49a"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT hash FROM script WHERE path = $1 AND workspace_id = $2 AND deleted = false ORDER BY created_at DESC LIMIT 1",
"query": "SELECT hash FROM script WHERE path = $1 AND workspace_id = $2 AND deleted = false AND archived = false ORDER BY created_at DESC LIMIT 1",
"describe": {
"columns": [
{
@@ -19,5 +19,5 @@
false
]
},
"hash": "d5661c7557cf3a8dee7cf799cd364d21d38edb827d2c08b0ca7d72311b78d574"
"hash": "a32d7ba43745226fd65328475731526e0b20ea6eeafeb937eb01cdc2cdfcb859"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n )\n LIMIT $3\n OFFSET $4\n ",
"query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at,\n nt.summary\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n )\n LIMIT $3\n OFFSET $4\n ",
"describe": {
"columns": [
{
@@ -62,6 +62,11 @@
"ordinal": 9,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 10,
"name": "summary",
"type_info": "Varchar"
}
],
"parameters": {
@@ -94,8 +99,9 @@
true,
true,
false,
false
false,
true
]
},
"hash": "a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e"
"hash": "b0775af41a9b54cce040bf37cae770e63dab12a1383a0855d105c061e4e4ca48"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE native_trigger\n SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4, error = NULL, updated_at = NOW()\n WHERE\n workspace_id = $5\n AND service_name = $6\n AND external_id = $7\n ",
"query": "\n UPDATE native_trigger\n SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4, summary = $8, error = NULL, updated_at = NOW()\n WHERE\n workspace_id = $5\n AND service_name = $6\n AND external_id = $7\n ",
"describe": {
"columns": [],
"parameters": {
@@ -21,10 +21,11 @@
}
}
},
"Text"
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e"
"hash": "bf224f6441c36187f1402f9f01bfe15bb9edfa1dc9052f8a829e486b7334d708"
}
+134 -137
View File
@@ -234,9 +234,9 @@ dependencies = [
[[package]]
name = "arc-swap"
version = "1.8.2"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5"
checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6"
dependencies = [
"rustversion",
]
@@ -2536,12 +2536,6 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]]
name = "convert_case"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
[[package]]
name = "convert_case"
version = "0.6.0"
@@ -4887,19 +4881,6 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "derive_more"
version = "0.99.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
dependencies = [
"convert_case 0.4.0",
"proc-macro2",
"quote",
"rustc_version 0.4.1",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "1.0.0"
@@ -4927,6 +4908,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"unicode-xid",
]
[[package]]
@@ -7419,14 +7401,15 @@ dependencies = [
[[package]]
name = "ipconfig"
version = "0.3.2"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f"
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
dependencies = [
"socket2 0.5.10",
"socket2 0.6.3",
"widestring",
"windows-sys 0.48.0",
"winreg",
"windows-registry",
"windows-result 0.4.1",
"windows-sys 0.61.2",
]
[[package]]
@@ -7446,9 +7429,9 @@ dependencies = [
[[package]]
name = "iri-string"
version = "0.7.10"
version = "0.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb"
dependencies = [
"memchr",
"serde",
@@ -7538,7 +7521,7 @@ dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys",
"jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"walkdir",
@@ -7547,9 +7530,31 @@ dependencies = [
[[package]]
name = "jni-sys"
version = "0.3.0"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
dependencies = [
"jni-sys 0.4.1",
]
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.117",
]
[[package]]
name = "jobserver"
@@ -8059,9 +8064,9 @@ dependencies = [
[[package]]
name = "libredox"
version = "0.1.14"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
dependencies = [
"bitflags 2.9.4",
"libc",
@@ -8308,9 +8313,9 @@ dependencies = [
[[package]]
name = "malachite"
version = "0.4.18"
version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a6ecab92657eb234bfe98abd0b17920772c6b14ce69256950142e2eb36d000b"
checksum = "2fbdf9cb251732db30a7200ebb6ae5d22fe8e11397364416617d2c2cf0c51cb5"
dependencies = [
"malachite-base",
"malachite-nz",
@@ -8331,11 +8336,11 @@ dependencies = [
[[package]]
name = "malachite-bigint"
version = "0.2.0"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17703a19c80bbdd0b7919f0f104f3b0597f7de4fc4e90a477c15366a5ba03faa"
checksum = "d149aaa2965d70381709d9df4c7ee1fc0de1c614a4efc2ee356f5e43d68749f8"
dependencies = [
"derive_more 0.99.20",
"derive_more 1.0.0",
"malachite",
"num-integer",
"num-traits",
@@ -8610,9 +8615,9 @@ dependencies = [
[[package]]
name = "moka"
version = "0.12.14"
version = "0.12.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b"
checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046"
dependencies = [
"async-lock",
"crossbeam-channel",
@@ -8826,7 +8831,7 @@ version = "0.5.0+25.2.9519653"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
dependencies = [
"jni-sys",
"jni-sys 0.3.1",
]
[[package]]
@@ -11663,7 +11668,7 @@ dependencies = [
"once_cell",
"ring 0.17.14",
"rustls-pki-types",
"rustls-webpki 0.103.9",
"rustls-webpki 0.103.10",
"subtle",
"zeroize",
]
@@ -11747,7 +11752,7 @@ dependencies = [
"rustls 0.23.35",
"rustls-native-certs 0.8.3",
"rustls-platform-verifier-android",
"rustls-webpki 0.103.9",
"rustls-webpki 0.103.10",
"security-framework 3.6.0",
"security-framework-sys",
"webpki-root-certs 1.0.6",
@@ -11795,9 +11800,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.9"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"aws-lc-rs",
"ring 0.17.14",
@@ -13877,12 +13882,12 @@ dependencies = [
[[package]]
name = "terminal_size"
version = "0.4.3"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix 1.1.4",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -14468,9 +14473,9 @@ dependencies = [
[[package]]
name = "toml_parser"
version = "1.0.10+spec-1.1.0"
version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420"
checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
dependencies = [
"winnow 1.0.0",
]
@@ -15040,9 +15045,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b"
[[package]]
name = "unicode-width"
@@ -15742,7 +15747,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15818,7 +15823,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15831,7 +15836,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"argon2",
@@ -15972,7 +15977,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15995,7 +16000,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16008,7 +16013,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16034,7 +16039,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -16044,7 +16049,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16061,7 +16066,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -16084,7 +16089,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16107,7 +16112,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16123,7 +16128,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16143,7 +16148,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16163,7 +16168,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16177,7 +16182,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"async-nats",
@@ -16205,7 +16210,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16230,7 +16235,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"axum 0.7.9",
"flate2",
@@ -16248,7 +16253,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16260,6 +16265,7 @@ dependencies = [
"serde_json",
"serde_yml",
"sqlx",
"tracing",
"url",
"windmill-api-auth",
"windmill-common",
@@ -16269,7 +16275,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16289,7 +16295,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16319,7 +16325,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16346,7 +16352,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"lazy_static",
"serde",
@@ -16358,7 +16364,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"argon2",
"axum 0.7.9",
@@ -16381,7 +16387,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16395,7 +16401,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16426,7 +16432,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"chrono",
"lazy_static",
@@ -16440,7 +16446,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16459,7 +16465,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"aes-gcm",
"anyhow",
@@ -16559,7 +16565,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -16578,7 +16584,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"regex",
"serde",
@@ -16593,7 +16599,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -16617,7 +16623,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"futures",
@@ -16634,7 +16640,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -16650,7 +16656,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16671,7 +16677,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16702,7 +16708,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"async-oauth2",
@@ -16726,7 +16732,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"async-stream",
@@ -16760,7 +16766,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"futures",
@@ -16778,7 +16784,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -16787,7 +16793,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16799,7 +16805,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16811,7 +16817,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"gosyn",
@@ -16823,7 +16829,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16835,7 +16841,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16847,7 +16853,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -16858,7 +16864,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16869,7 +16875,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16881,7 +16887,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16892,7 +16898,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16914,7 +16920,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16928,7 +16934,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16945,7 +16951,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16958,7 +16964,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"serde",
@@ -16970,7 +16976,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16988,7 +16994,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -17004,7 +17010,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -17020,7 +17026,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"serde",
@@ -17031,7 +17037,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -17068,7 +17074,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"const_format",
@@ -17106,7 +17112,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -17117,7 +17123,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -17146,7 +17152,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -17169,7 +17175,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17202,7 +17208,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17222,7 +17228,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17256,7 +17262,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17291,7 +17297,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17314,7 +17320,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17338,7 +17344,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"async-nats",
@@ -17362,7 +17368,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17397,7 +17403,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17425,7 +17431,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17448,7 +17454,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"bitflags 2.9.4",
@@ -17467,7 +17473,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -17495,6 +17501,7 @@ dependencies = [
"gcp_auth",
"git-version",
"hex",
"hmac",
"hudsucker",
"hyper-http-proxy",
"hyper-tls",
@@ -17574,7 +17581,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.662.0"
version = "1.664.0"
dependencies = [
"bytes",
"futures",
@@ -18187,16 +18194,6 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8"
[[package]]
name = "winreg"
version = "0.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
dependencies = [
"cfg-if",
"windows-sys 0.48.0",
]
[[package]]
name = "winsafe"
version = "0.0.19"
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.662.0"
version = "1.664.0"
authors.workspace = true
edition.workspace = true
@@ -82,7 +82,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal"]
[workspace.package]
version = "1.662.0"
version = "1.664.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
c04f3851c03758662e4936ff4b6e71bc56dbae7e
faeaa43bbe2ba4804f80b828b85fd4d6daef096c
@@ -0,0 +1 @@
ALTER TABLE native_trigger DROP COLUMN IF EXISTS summary;
@@ -0,0 +1 @@
ALTER TABLE native_trigger ADD COLUMN summary VARCHAR(1000);
+7 -2
View File
@@ -36,9 +36,10 @@ use windmill_common::ee_oss::{
use windmill_common::{
agent_workers::AgentConfig,
ai_cache::bump_instance_ai_config_revision,
global_settings::{
APP_WORKSPACED_ROUTE_SETTING, AUDIT_LOG_RETENTION_DAYS_SETTING, BASE_URL_SETTING,
BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING,
AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUDIT_LOG_RETENTION_DAYS_SETTING,
BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING,
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS,
@@ -1813,6 +1814,10 @@ async fn process_notify_event(
tracing::error!(error = %e, "Could not reload app workspaced route setting");
}
}
AI_CONFIG_SETTING => {
tracing::info!("AI config setting changed, bumping instance AI cache revision");
bump_instance_ai_config_revision();
}
OTEL_SETTING => {
tracing::info!("OTEL setting changed, restarting");
send_delayed_killpill(tx, 4, "OTEL setting change").await;
+15 -9
View File
@@ -18,7 +18,6 @@ use regex::Regex;
use windmill_api_auth::{check_scopes, ApiAuthed, AuthCache, Tokened};
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
use windmill_audit::ActionKind;
use windmill_common::{error::Error, webhook::{WebhookMessage, WebhookShared}, workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}};
use windmill_common::DB;
use windmill_common::{
db::UserDB,
@@ -26,6 +25,11 @@ use windmill_common::{
users::username_to_permissioned_as,
utils::{not_found_if_none, paginate, Pagination},
};
use windmill_common::{
error::Error,
webhook::{WebhookMessage, WebhookShared},
workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult},
};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Postgres, Transaction};
@@ -716,13 +720,14 @@ async fn add_owner(
.await?;
validate_owner(&owner)?;
sqlx::query(&format!(
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), \
true) WHERE name = $2 AND workspace_id = $3 RETURNING extra_perms"
))
sqlx::query(
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, array[$4]::text[], to_jsonb($1), \
true) WHERE name = $2 AND workspace_id = $3 RETURNING extra_perms",
)
.bind(true)
.bind(&name)
.bind(&w_id)
.bind(&owner)
.fetch_optional(&mut *tx)
.await?;
@@ -787,14 +792,15 @@ async fn remove_owner(
}
if let Some(write) = write {
let old_write = sqlx::query_scalar::<_, Option<bool>>(&format!(
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), \
true) FROM (SELECT (extra_perms->>'{owner}')::boolean as old_val FROM folder WHERE name = $2 AND workspace_id = $3) old \
let old_write = sqlx::query_scalar::<_, Option<bool>>(
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, array[$4]::text[], to_jsonb($1), \
true) FROM (SELECT (extra_perms->>$4)::boolean as old_val FROM folder WHERE name = $2 AND workspace_id = $3) old \
WHERE name = $2 AND workspace_id = $3 RETURNING old.old_val"
))
)
.bind(write)
.bind(&name)
.bind(&w_id)
.bind(&owner)
.fetch_optional(&mut *tx)
.await?
.flatten();
@@ -422,6 +422,7 @@ async fn test_delete_integration_full_cascade(db: Pool<Postgres>) -> anyhow::Res
"ext-1",
&trigger_config,
json!({"triggerType": "drive"}),
None,
)
.await?;
@@ -511,6 +512,7 @@ async fn test_cleanup_preserves_triggers(db: Pool<Postgres>) -> anyhow::Result<(
"ext-1",
&trigger_config,
json!({"triggerType": "drive"}),
None,
)
.await?;
@@ -82,12 +82,10 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
assert_eq!(resp.status(), 200);
// --- allowed_domain_auto_invite ---
let resp = authed(client().get(format!(
"{global_base}/allowed_domain_auto_invite"
)))
.send()
.await
.unwrap();
let resp = authed(client().get(format!("{global_base}/allowed_domain_auto_invite")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
resp.json::<bool>().await?;
@@ -213,12 +211,10 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
resp.json::<Vec<String>>().await?;
// --- get_dependents (empty, no dependencies exist) ---
let resp = authed(client().get(format!(
"{base}/get_dependents/u/test-user/nonexistent"
)))
.send()
.await
.unwrap();
let resp = authed(client().get(format!("{base}/get_dependents/u/test-user/nonexistent")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let dependents = resp.json::<Vec<serde_json::Value>>().await?;
assert!(dependents.is_empty());
@@ -425,13 +421,11 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
);
// --- edit_large_file_storage_config ---
let resp = authed(client().post(format!(
"{base}/edit_large_file_storage_config"
)))
.json(&json!({"large_file_storage": null}))
.send()
.await
.unwrap();
let resp = authed(client().post(format!("{base}/edit_large_file_storage_config")))
.json(&json!({"large_file_storage": null}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
@@ -532,9 +526,7 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
.unwrap();
let invites = resp.json::<Vec<serde_json::Value>>().await?;
assert!(
invites
.iter()
.any(|i| i["email"] == "invited@example.com"),
invites.iter().any(|i| i["email"] == "invited@example.com"),
"invite not found: {:?}",
invites
);
@@ -549,12 +541,7 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
201,
"delete_invite: {}",
resp.text().await?
);
assert_eq!(resp.status(), 201, "delete_invite: {}", resp.text().await?);
// ===== Critical alerts (EE-gated, returns 404 in OSS) =====
@@ -624,12 +611,7 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"create_fork: {}",
resp.text().await?
);
assert_eq!(resp.status(), 200, "create_fork: {}", resp.text().await?);
// verify fork exists
let resp = authed(client().post(format!("{global_base}/exists")))
@@ -702,13 +684,122 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
assert_eq!(resp.json::<bool>().await?, false);
// --- create_workspace_require_superadmin ---
let resp = authed(client().get(format!(
"{global_base}/create_workspace_require_superadmin"
)))
.send()
.await
.unwrap();
let resp = authed(client().get(format!("{global_base}/create_workspace_require_superadmin")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_get_copilot_settings_state_reports_instance_ai_fallback_flags(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
let instance_ai_config = json!({
"providers": {
"openai": {
"resource_path": "u/test-user/openai_instance",
"models": ["gpt-4o-mini"]
}
}
});
let workspace_ai_config = json!({
"providers": {
"anthropic": {
"resource_path": "u/test-user/anthropic_workspace",
"models": ["claude-3-5-haiku-latest"]
}
}
});
sqlx::query("UPDATE workspace_settings SET ai_config = NULL WHERE workspace_id = $1")
.bind("test-workspace")
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO global_settings (name, value) VALUES ($1, $2) \
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
)
.bind("ai_config")
.bind(instance_ai_config)
.execute(&db)
.await?;
let resp = authed(client().get(format!("{base}/get_copilot_settings_state")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let settings = resp.json::<serde_json::Value>().await?;
assert_eq!(settings["has_instance_ai_config"], true);
assert_eq!(settings["uses_instance_ai_config"], true);
assert_eq!(
settings["instance_ai_summary"]["providers"][0]["provider"],
"openai"
);
assert_eq!(
settings["instance_ai_summary"]["providers"][0]["models"][0],
"gpt-4o-mini"
);
sqlx::query("UPDATE workspace_settings SET ai_config = $1 WHERE workspace_id = $2")
.bind(workspace_ai_config)
.bind("test-workspace")
.execute(&db)
.await?;
let resp = authed(client().get(format!("{base}/get_copilot_settings_state")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let settings = resp.json::<serde_json::Value>().await?;
assert_eq!(settings["has_instance_ai_config"], true);
assert_eq!(settings["uses_instance_ai_config"], false);
assert_eq!(
settings["instance_ai_summary"]["providers"][0]["provider"],
"openai"
);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_get_copilot_info_ignores_empty_instance_ai_row(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
sqlx::query("UPDATE workspace_settings SET ai_config = NULL WHERE workspace_id = $1")
.bind("test-workspace")
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO global_settings (name, value) VALUES ($1, $2) \
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
)
.bind("ai_config")
.bind(json!({}))
.execute(&db)
.await?;
let resp = authed(client().get(format!("{base}/get_copilot_info")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let settings = resp.json::<serde_json::Value>().await?;
assert!(settings["providers"].is_null());
Ok(())
}
+24 -28
View File
@@ -50,11 +50,11 @@ pub fn filter_list_queue_query(
.values
.iter()
.map(|v| {
let p = v.replace("*", "%").replace("'", "''");
let p = v.replace("*", "%");
if w.negated {
format!("v2_job_queue.worker NOT LIKE '{p}'")
format!("v2_job_queue.worker NOT LIKE {}", quote(&p))
} else {
format!("v2_job_queue.worker LIKE '{p}'")
format!("v2_job_queue.worker LIKE {}", quote(&p))
}
})
.collect();
@@ -77,11 +77,11 @@ pub fn filter_list_queue_query(
.values
.iter()
.map(|v| {
let e = v.replace("'", "''");
let p = format!("{}%", v);
if ps.negated {
format!("runnable_path NOT LIKE '{e}%'")
format!("runnable_path NOT LIKE {}", quote(&p))
} else {
format!("runnable_path LIKE '{e}%'")
format!("runnable_path LIKE {}", quote(&p))
}
})
.collect();
@@ -123,11 +123,11 @@ pub fn filter_list_queue_query(
.values
.iter()
.map(|v| {
let p = v.replace("*", "%").replace("'", "''");
let p = v.replace("*", "%");
if t.negated {
format!("v2_job.tag NOT LIKE '{p}'")
format!("v2_job.tag NOT LIKE {}", quote(&p))
} else {
format!("v2_job.tag LIKE '{p}'")
format!("v2_job.tag LIKE {}", quote(&p))
}
})
.collect();
@@ -287,14 +287,14 @@ pub fn filter_list_completed_query(
.values
.iter()
.map(|v| {
let p = v.replace("*", "%").replace("'", "''");
let p = v.replace("*", "%");
if label.negated {
format!(
"NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE '{p}')"
"NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE {})", quote(&p)
)
} else {
format!(
"EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE '{p}')"
"EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE {})", quote(&p)
)
}
})
@@ -308,14 +308,14 @@ pub fn filter_list_completed_query(
let clauses: Vec<_> = label
.values
.iter()
.map(|v| format!("NOT (result->'wm_labels' ? '{}')", v.replace("'", "''")))
.map(|v| format!("NOT (result->'wm_labels' ? {})", quote(v)))
.collect();
sqlb.and_where(format!("({})", clauses.join(" AND ")));
} else {
let clauses: Vec<_> = label
.values
.iter()
.map(|v| format!("result->'wm_labels' ? '{}'", v.replace("'", "''")))
.map(|v| format!("result->'wm_labels' ? {}", quote(v)))
.collect();
sqlb.and_where("result ? 'wm_labels'");
sqlb.and_where(format!("({})", clauses.join(" OR ")));
@@ -329,11 +329,11 @@ pub fn filter_list_completed_query(
.values
.iter()
.map(|v| {
let p = v.replace("*", "%").replace("'", "''");
let p = v.replace("*", "%");
if worker.negated {
format!("v2_job_completed.worker NOT LIKE '{p}'")
format!("v2_job_completed.worker NOT LIKE {}", quote(&p))
} else {
format!("v2_job_completed.worker LIKE '{p}'")
format!("v2_job_completed.worker LIKE {}", quote(&p))
}
})
.collect();
@@ -366,11 +366,11 @@ pub fn filter_list_completed_query(
.values
.iter()
.map(|v| {
let e = v.replace("'", "''");
let p = format!("{}%", v);
if ps.negated {
format!("runnable_path NOT LIKE '{e}%'")
format!("runnable_path NOT LIKE {}", quote(&p))
} else {
format!("runnable_path LIKE '{e}%'")
format!("runnable_path LIKE {}", quote(&p))
}
})
.collect();
@@ -400,11 +400,11 @@ pub fn filter_list_completed_query(
.values
.iter()
.map(|v| {
let p = v.replace("*", "%").replace("'", "''");
let p = v.replace("*", "%");
if t.negated {
format!("v2_job.tag NOT LIKE '{p}'")
format!("v2_job.tag NOT LIKE {}", quote(&p))
} else {
format!("v2_job.tag LIKE '{p}'")
format!("v2_job.tag LIKE {}", quote(&p))
}
})
.collect();
@@ -449,11 +449,7 @@ pub fn filter_list_completed_query(
}
if let Some(dt) = &lq.created_or_started_after {
let ts = dt.to_rfc3339();
sqlb.and_where(format!(
"(created_at >= '{}' OR started_at >= '{}')",
ts.replace("'", "''"),
ts.replace("'", "''")
));
sqlb.and_where("(created_at >= ? OR started_at >= ?)".bind(&ts).bind(&ts));
}
if let Some(dt) = &lq.created_before {
+15 -1
View File
@@ -38,11 +38,12 @@ use windmill_common::ee_oss::{send_critical_alert, CriticalAlertKind, CriticalEr
#[cfg(all(feature = "private", feature = "enterprise"))]
use windmill_common::secret_backend::{SecretMigrationReport, VaultSettings};
use windmill_common::{
ai_cache::bump_instance_ai_config_revision,
email_oss::send_email_plain_text,
error::{self, JsonResult, Result},
get_database_url,
global_settings::{
APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING,
EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
WS_BASE_URL_SETTING,
@@ -284,6 +285,7 @@ pub async fn set_global_setting_internal(
key: String,
value: serde_json::Value,
) -> error::Result<()> {
let should_bump_instance_ai_revision = key == AI_CONFIG_SETTING;
let value = if key == "retention_period_secs" {
instance_config::clamp_retention_period(value)
} else {
@@ -325,6 +327,10 @@ pub async fn set_global_setting_internal(
}
};
if should_bump_instance_ai_revision {
bump_instance_ai_config_revision();
}
Ok(())
}
@@ -471,6 +477,10 @@ async fn set_instance_config(
let current_map = current.global_settings.to_settings_map();
let settings_diff =
instance_config::diff_global_settings(&current_map, &desired_map, ApplyMode::Merge);
let ai_config_changed = settings_diff
.upserts
.iter()
.any(|(key, _)| key == AI_CONFIG_SETTING);
for (key, value) in &settings_diff.upserts {
run_setting_pre_write_hook(&db, key, value).await?;
@@ -479,6 +489,10 @@ async fn set_instance_config(
instance_config::apply_settings_diff(&db, &settings_diff)
.await
.map_err(|e| error::Error::internal_err(e.to_string()))?;
if ai_config_changed {
bump_instance_ai_config_revision();
}
}
if !desired.worker_configs.is_empty() {
@@ -82,6 +82,10 @@ pub fn workspaced_service() -> Router {
.route("/get_dependents/*imported_path", get(get_dependents))
.route("/get_dependents_amounts", post(get_dependents_amounts))
.route("/get_settings", get(get_settings))
.route(
"/get_copilot_settings_state",
get(get_copilot_settings_state),
)
.route("/get_deploy_to", get(get_deploy_to))
.route("/edit_slack_command", post(edit_slack_command))
.route(
@@ -257,6 +261,35 @@ pub struct WorkspaceSettings {
pub public_app_execution_limit_per_minute: Option<i32>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct CopilotSettingsState {
pub has_instance_ai_config: bool,
pub uses_instance_ai_config: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub instance_ai_summary: Option<InstanceAISummary>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct InstanceAIProviderSummary {
pub provider: String,
pub models: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct InstanceAIModelSummary {
pub provider: String,
pub model: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct InstanceAISummary {
pub providers: Vec<InstanceAIProviderSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_model: Option<InstanceAIModelSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_completion_model: Option<InstanceAIModelSummary>,
}
/// #[derive(sqlx::Type, Serialize, Deserialize, Debug)]
// #[sqlx(type_name = "WORKSPACE_KEY_KIND", rename_all = "lowercase")]
// pub enum WorkspaceKeyKind {
@@ -608,15 +641,106 @@ async fn get_settings(
.await
.map_err(|e| Error::internal_err(format!("getting settings: {e:#}")))?;
let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?;
tx.commit().await?;
let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?;
if !authed.is_admin {
settings.slack_oauth_client_secret = None;
}
Ok(Json(settings))
}
async fn get_copilot_settings_state(
authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<CopilotSettingsState> {
let mut tx = user_db.begin(&authed).await?;
let workspace_ai_config = sqlx::query_scalar!(
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_optional(&mut *tx)
.await
.map_err(|e| Error::internal_err(format!("getting workspace ai settings: {e:#}")))?;
let workspace_ai_config = not_found_if_none(workspace_ai_config, "workspace settings", &w_id)?;
let instance_ai_config: Option<serde_json::Value> =
sqlx::query_scalar("SELECT value FROM global_settings WHERE name = 'ai_config'")
.fetch_optional(&mut *tx)
.await
.map_err(|e| Error::internal_err(format!("getting instance ai settings: {e:#}")))?;
tx.commit().await?;
Ok(Json(build_copilot_settings_state(
has_ai_providers(workspace_ai_config.as_ref()),
instance_ai_config.as_ref(),
)))
}
pub fn has_ai_providers(config: Option<&serde_json::Value>) -> bool {
config
.and_then(|value| value.get("providers"))
.and_then(|providers| providers.as_object())
.map(|providers| !providers.is_empty())
.unwrap_or(false)
}
pub fn build_copilot_settings_state(
has_workspace_ai_config: bool,
instance_ai_config: Option<&serde_json::Value>,
) -> CopilotSettingsState {
let has_instance_ai_config = has_ai_providers(instance_ai_config);
CopilotSettingsState {
has_instance_ai_config,
uses_instance_ai_config: !has_workspace_ai_config && has_instance_ai_config,
instance_ai_summary: build_instance_ai_summary(instance_ai_config),
}
}
pub fn build_instance_ai_summary(config: Option<&serde_json::Value>) -> Option<InstanceAISummary> {
let config = config?;
if !has_ai_providers(Some(config)) {
return None;
}
let providers = config.get("providers")?.as_object()?;
let mut provider_summaries = providers
.iter()
.map(|(provider, provider_config)| InstanceAIProviderSummary {
provider: provider.clone(),
models: provider_config
.get("models")
.and_then(|models| models.as_array())
.map(|models| {
models
.iter()
.filter_map(|model| model.as_str().map(ToOwned::to_owned))
.collect::<Vec<_>>()
})
.unwrap_or_default(),
})
.collect::<Vec<_>>();
provider_summaries.sort_by(|left, right| left.provider.cmp(&right.provider));
Some(InstanceAISummary {
providers: provider_summaries,
default_model: extract_instance_ai_model_summary(config, "default_model"),
code_completion_model: extract_instance_ai_model_summary(config, "code_completion_model"),
})
}
fn extract_instance_ai_model_summary(
config: &serde_json::Value,
key: &str,
) -> Option<InstanceAIModelSummary> {
let model_config = config.get(key)?.as_object()?;
Some(InstanceAIModelSummary {
provider: model_config.get("provider")?.as_str()?.to_owned(),
model: model_config.get("model")?.as_str()?.to_owned(),
})
}
#[derive(Serialize)]
struct DeployTo {
deploy_to: Option<String>,
+205 -3
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.662.0
version: 1.664.0
title: Windmill API
contact:
@@ -3191,9 +3191,49 @@ paths:
"200":
description: status
content:
text/plain:
application/json:
schema:
type: string
type: object
properties:
effective_ai_config:
$ref: "#/components/schemas/AIConfig"
has_instance_ai_config:
type: boolean
uses_instance_ai_config:
type: boolean
instance_ai_summary:
$ref: "#/components/schemas/InstanceAISummary"
required:
- effective_ai_config
- has_instance_ai_config
- uses_instance_ai_config
/w/{workspace}/workspaces/get_copilot_settings_state:
get:
summary: get copilot settings state
operationId: getCopilotSettingsState
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: status
content:
application/json:
schema:
type: object
properties:
has_instance_ai_config:
type: boolean
uses_instance_ai_config:
type: boolean
instance_ai_summary:
$ref: "#/components/schemas/InstanceAISummary"
required:
- has_instance_ai_config
- uses_instance_ai_config
/w/{workspace}/workspaces/get_copilot_info:
get:
@@ -11048,6 +11088,129 @@ paths:
"200":
description: Interactive slack approval message sent successfully
/w/{workspace}/jobs_u/flow/resume_suspended/{job_id}:
post:
summary: resume or cancel a suspended flow/WAC job
description: >
Resume or cancel a suspended flow/WAC job. Uses approval rules to
determine authorization. Either a valid approval_token or an
authenticated session is required.
operationId: resumeSuspended
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: job_id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
payload:
description: payload to send to the resumed job
approval_token:
type: string
description: approval token for unauthenticated access
approved:
type: boolean
description: whether to approve (true) or cancel (false) the job
default: true
responses:
"201":
description: job resumed
content:
text/plain:
schema:
type: string
/w/{workspace}/jobs_u/flow/approval_info/{job_id}:
get:
summary: get approval info for a suspended flow/WAC job
description: >
Get approval info for a suspended flow/WAC job. Returns form schema,
approval rules, and whether the current user can approve. Either a
valid token query parameter or an authenticated session is required.
operationId: getApprovalInfo
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: job_id
in: path
required: true
schema:
type: string
format: uuid
- name: token
in: query
required: false
schema:
type: string
description: approval token for unauthenticated access
responses:
"200":
description: approval info
content:
application/json:
schema:
type: object
required:
- flow_id
- can_approve
- user_auth_required
- approvers
properties:
flow_id:
type: string
format: uuid
form_schema:
description: form schema for the approval step
description:
description: description of the approval step
approval_conditions:
type: object
properties:
user_auth_required:
type: boolean
user_groups_required:
type: array
items:
type: string
self_approval_disabled:
type: boolean
required:
- user_auth_required
- user_groups_required
- self_approval_disabled
can_approve:
type: boolean
description: whether the current user/token holder can approve
user_auth_required:
type: boolean
description: whether user authentication is required to approve
hide_cancel:
type: boolean
description: whether to hide the cancel button in the UI
approvers:
type: array
items:
type: object
required:
- resume_id
- approver
properties:
resume_id:
type: integer
approver:
type: string
/w/{workspace}/jobs_u/resume/{id}/{resume_id}/{signature}:
get:
summary: resume a job for a suspended flow
@@ -18792,6 +18955,33 @@ components:
minimum: 1
maximum: 2000000
InstanceAIProviderSummary:
type: object
properties:
provider:
$ref: "#/components/schemas/AIProvider"
models:
type: array
items:
type: string
required:
- provider
- models
InstanceAISummary:
type: object
properties:
providers:
type: array
items:
$ref: "#/components/schemas/InstanceAIProviderSummary"
default_model:
$ref: "#/components/schemas/AIProviderModel"
code_completion_model:
$ref: "#/components/schemas/AIProviderModel"
required:
- providers
Alert:
type: object
properties:
@@ -24592,6 +24782,10 @@ components:
type: string
nullable: true
description: Error message if the trigger is in an error state
summary:
type: string
nullable: true
description: Short summary to be displayed when listed
required:
- external_id
- workspace_id
@@ -24626,6 +24820,10 @@ components:
type: string
nullable: true
description: Error message if the trigger is in an error state
summary:
type: string
nullable: true
description: Short summary to be displayed when listed
external_data:
type: object
description: Configuration data from the external service
@@ -24718,6 +24916,10 @@ components:
type: object
description: Service-specific configuration (e.g., event types, filters)
additionalProperties: true
summary:
type: string
nullable: true
description: Short summary to be displayed when listed
required:
- script_path
- is_flow
+169 -44
View File
@@ -16,6 +16,7 @@ use serde_json::{json, value::RawValue};
use std::collections::HashMap;
use std::time::Duration;
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::ai_cache::current_instance_ai_config_revision;
use windmill_common::ai_providers::{
empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel,
};
@@ -127,6 +128,10 @@ lazy_static::lazy_static! {
};
}
pub(crate) fn invalidate_ai_request_cache_for_workspace(workspace_id: &str) {
AI_REQUEST_CACHE.retain(|(cached_workspace_id, _), _| cached_workspace_id != workspace_id);
}
#[derive(Deserialize, Debug)]
struct AIOAuthResource {
client_id: String,
@@ -373,8 +378,7 @@ impl AIRequestConfig {
let is_azure = provider.is_azure_openai(base_url);
let is_anthropic = matches!(provider, AIProvider::Anthropic);
let is_anthropic_vertex =
is_anthropic && self.platform == AIPlatform::GoogleVertexAi;
let is_anthropic_vertex = is_anthropic && self.platform == AIPlatform::GoogleVertexAi;
let is_anthropic_sdk = headers.get("X-Anthropic-SDK").is_some();
let is_google_ai = matches!(provider, AIProvider::GoogleAI);
@@ -483,18 +487,27 @@ impl AIRequestConfig {
pub struct ExpiringAIRequestConfig {
config: AIRequestConfig,
expires_at: std::time::Instant,
instance_ai_config_revision: Option<u64>,
}
impl ExpiringAIRequestConfig {
fn new(config: AIRequestConfig) -> Self {
Self { config, expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60) }
fn new(config: AIRequestConfig, instance_ai_config_revision: Option<u64>) -> Self {
Self {
config,
expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60),
instance_ai_config_revision,
}
}
fn is_expired(&self) -> bool {
self.expires_at < std::time::Instant::now()
|| self
.instance_ai_config_revision
.is_some_and(|revision| revision != current_instance_ai_config_revision())
}
}
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct AIConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub providers: Option<HashMap<AIProvider, ProviderConfig>>,
@@ -508,6 +521,14 @@ pub struct AIConfig {
pub max_tokens_per_model: Option<HashMap<String, i32>>,
}
impl AIConfig {
pub fn has_providers(&self) -> bool {
self.providers
.as_ref()
.is_some_and(|providers| !providers.is_empty())
}
}
/// Anthropic API version for Google Vertex AI
const ANTHROPIC_VERSION_VERTEX: &str = "vertex-2023-10-16";
@@ -762,47 +783,76 @@ async fn proxy(
request_cache.config
}
_ => {
let (resource_path, save_to_cache) = if let Some(resource_path) = forced_resource_path {
// forced resource path
(resource_path, false)
} else {
let ai_config = sqlx::query_scalar!(
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?;
let (resource_path, save_to_cache, resource_workspace, instance_ai_config_revision) =
if let Some(resource_path) = forced_resource_path {
// forced resource path
(resource_path, false, w_id.clone(), None)
} else {
let workspace_ai_config = sqlx::query_scalar!(
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?;
if ai_config.is_none() {
return Err(Error::internal_err(
"AI resource not configured".to_string(),
));
}
let (ai_config_value, resource_workspace, instance_ai_config_revision) = {
let ws_has_config = workspace_ai_config
.as_ref()
.and_then(|v| serde_json::from_value::<AIConfig>(v.clone()).ok())
.is_some_and(|config| config.has_providers());
let mut ai_config = serde_json::from_value::<AIConfig>(ai_config.unwrap())
.map_err(|e| Error::BadRequest(e.to_string()))?;
if ws_has_config {
(workspace_ai_config.unwrap(), w_id.clone(), None)
} else {
let instance_config = sqlx::query_scalar!(
"SELECT value FROM global_settings WHERE name = 'ai_config'"
)
.fetch_optional(&db)
.await?;
let provider_config = ai_config
.providers
.as_mut()
.map(|providers| providers.remove(&provider))
.flatten()
.ok_or_else(|| {
Error::BadRequest(format!("Provider {:?} not configured", provider))
})?;
match instance_config {
Some(config) => (
config,
"admins".to_string(),
Some(current_instance_ai_config_revision()),
),
None => {
return Err(Error::internal_err(
"AI resource not configured".to_string(),
));
}
}
}
};
if provider_config.resource_path.is_empty() {
return Err(Error::BadRequest("Resource path is empty".to_string()));
}
let mut ai_config = serde_json::from_value::<AIConfig>(ai_config_value)
.map_err(|e| Error::BadRequest(e.to_string()))?;
(provider_config.resource_path, true)
};
let provider_config = ai_config
.providers
.as_mut()
.and_then(|providers| providers.remove(&provider))
.ok_or_else(|| {
Error::BadRequest(format!("Provider {:?} not configured", provider))
})?;
let resource= sqlx::query_scalar!(
"SELECT value as \"value: sqlx::types::Json<Box<RawValue>>\" FROM resource WHERE path = $1 AND workspace_id = $2",
&resource_path,
&w_id
if provider_config.resource_path.is_empty() {
return Err(Error::BadRequest("Resource path is empty".to_string()));
}
(
provider_config.resource_path,
true,
resource_workspace,
instance_ai_config_revision,
)
};
let resource = sqlx::query_scalar::<_, Option<sqlx::types::Json<Box<RawValue>>>>(
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
)
.bind(&resource_path)
.bind(&resource_workspace)
.fetch_optional(&db)
.await?
.ok_or_else(|| Error::NotFound(format!("Could not find the resource {}, update the resource path in the workspace settings", resource_path)))?
@@ -811,11 +861,15 @@ async fn proxy(
let resource = serde_json::from_str::<AIResource>(resource.0.get())
.map_err(|e| Error::BadRequest(e.to_string()))?;
let request_config = AIRequestConfig::new(&provider, &db, &w_id, resource).await?;
let request_config =
AIRequestConfig::new(&provider, &db, &resource_workspace, resource).await?;
if save_to_cache {
AI_REQUEST_CACHE.insert(
(w_id.clone(), provider.clone()),
ExpiringAIRequestConfig::new(request_config.clone()),
ExpiringAIRequestConfig::new(
request_config.clone(),
instance_ai_config_revision,
),
);
}
request_config
@@ -858,9 +912,7 @@ async fn proxy(
"chat/completions" => {
crate::google::handle_google_ai_chat(&body, api_key, base_url, is_vertex).await
}
"models" => {
crate::google::handle_google_ai_models(api_key, base_url, is_vertex).await
}
"models" => crate::google::handle_google_ai_models(api_key, base_url, is_vertex).await,
_ => Err(Error::BadRequest(format!(
"Unsupported Google AI path: {}",
ai_path
@@ -1005,3 +1057,76 @@ async fn proxy(
};
Ok((status_code, headers, body))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{LazyLock, Mutex};
use windmill_common::ai_cache::bump_instance_ai_config_revision;
use windmill_common::ai_providers::AIPlatform;
static TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
fn sample_request_config() -> AIRequestConfig {
AIRequestConfig {
base_url: "https://example.com".to_string(),
api_key: None,
access_token: None,
organization_id: None,
user: None,
region: None,
aws_access_key_id: None,
aws_secret_access_key: None,
aws_session_token: None,
platform: AIPlatform::Standard,
enable_1m_context: false,
custom_headers: HashMap::new(),
}
}
#[test]
fn invalidates_all_cached_providers_for_workspace() {
let _guard = TEST_LOCK.lock().unwrap();
AI_REQUEST_CACHE.clear();
AI_REQUEST_CACHE.insert(
("workspace-a".to_string(), AIProvider::OpenAI),
ExpiringAIRequestConfig::new(sample_request_config(), None),
);
AI_REQUEST_CACHE.insert(
("workspace-a".to_string(), AIProvider::Anthropic),
ExpiringAIRequestConfig::new(sample_request_config(), None),
);
AI_REQUEST_CACHE.insert(
("workspace-b".to_string(), AIProvider::OpenAI),
ExpiringAIRequestConfig::new(sample_request_config(), None),
);
invalidate_ai_request_cache_for_workspace("workspace-a");
assert!(AI_REQUEST_CACHE
.get(&("workspace-a".to_string(), AIProvider::OpenAI))
.is_none());
assert!(AI_REQUEST_CACHE
.get(&("workspace-a".to_string(), AIProvider::Anthropic))
.is_none());
assert!(AI_REQUEST_CACHE
.get(&("workspace-b".to_string(), AIProvider::OpenAI))
.is_some());
}
#[test]
fn instance_backed_cache_entries_expire_when_revision_changes() {
let _guard = TEST_LOCK.lock().unwrap();
AI_REQUEST_CACHE.clear();
let cached = ExpiringAIRequestConfig::new(
sample_request_config(),
Some(current_instance_ai_config_revision()),
);
assert!(!cached.is_expired());
bump_instance_ai_config_revision();
assert!(cached.is_expired());
}
}
+488 -47
View File
@@ -103,7 +103,7 @@ use windmill_common::{
cache,
db::UserDB,
error::{self, to_anyhow, Error},
flow_status::{Approval, FlowStatus, FlowStatusModule},
flow_status::{Approval, ApprovalConditions, FlowStatus, FlowStatusModule},
flows::{add_virtual_items_if_necessary, resolve_maybe_value, FlowValue},
jobs::{script_path_to_payload, CompletedJob, JobKind, JobPayload, QueuedJob, RawCode},
oauth2::HmacSha256,
@@ -401,6 +401,8 @@ pub fn workspace_unauthed_service() -> Router {
post(cancel_persistent_script_api),
)
.route("/queue/force_cancel/:id", post(force_cancel))
.route("/flow/resume_suspended/:job_id", post(resume_suspended))
.route("/flow/approval_info/:job_id", get(get_approval_info))
}
pub fn global_root_service() -> Router {
@@ -1058,6 +1060,7 @@ impl<'a> GetQuery<'a> {
Self { with_code: false, ..self }
}
#[allow(dead_code)]
fn without_flow(self) -> Self {
Self { with_flow: false, ..self }
}
@@ -2027,10 +2030,7 @@ async fn count_completed_jobs_detail(
if let Some(tags) = query.tags {
sqlb.and_where_in(
"v2_job.tag",
&tags
.split(",")
.map(|t| format!("'{}'", t))
.collect::<Vec<_>>(),
&tags.split(",").map(|t| quote(t)).collect::<Vec<_>>(),
);
}
@@ -2184,7 +2184,7 @@ pub async fn resume_suspended_flow_as_owner(
) -> error::Result<StatusCode> {
let mut tx = db.begin().await?;
let (flow, job_id) = get_suspended_flow_info(flow_id, &mut tx).await?;
let (flow, job_id, is_wac) = get_suspended_flow_info(flow_id, &mut tx).await?;
let flow_path = flow.script_path.as_deref().unwrap_or_else(|| "");
require_owner_of_path(&authed, flow_path)?;
@@ -2192,10 +2192,17 @@ pub async fn resume_suspended_flow_as_owner(
// Check approval conditions (self-approval, required groups, etc.)
if let Some(ref flow_status_value) = flow.flow_status {
if let Ok(flow_status) = serde_json::from_value::<FlowStatus>(flow_status_value.clone()) {
let trigger_email = flow.email.as_deref().unwrap_or("");
conditionally_require_authed_user(Some(authed.clone()), flow_status, trigger_email)?;
}
let trigger_email = flow.email.as_deref().unwrap_or("");
let ac = serde_json::from_value::<FlowStatus>(flow_status_value.clone())
.ok()
.and_then(|fs| fs.approval_conditions)
.or_else(|| {
// WAC flows store approval_conditions directly in flow_status JSONB
flow_status_value
.get("approval_conditions")
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok())
});
conditionally_require_authed_user(Some(authed.clone()), ac, trigger_email)?;
}
let value = value.unwrap_or(serde_json::Value::Null);
@@ -2211,12 +2218,426 @@ pub async fn resume_suspended_flow_as_owner(
)
.await?;
resume_immediately_if_relevant(flow, job_id, &mut tx).await?;
if is_wac {
// WAC: directly decrement suspend counter
if flow.suspend > 0 {
sqlx::query!(
"UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1",
flow.id,
)
.execute(&mut *tx)
.await?;
}
} else {
resume_immediately_if_relevant(flow, job_id, &mut tx).await?;
}
tx.commit().await?;
Ok(StatusCode::CREATED)
}
// --- New approval system endpoints ---
use windmill_common::variables::generate_approval_token;
/// Verify an approval token against the workspace key + job_id.
async fn validate_approval_token(
db: &DB,
token: &str,
job_id: Uuid,
workspace_id: &str,
) -> error::Result<()> {
let expected = generate_approval_token(workspace_id, job_id, db).await?;
if token != expected {
return Err(Error::NotAuthorized("Invalid approval token".to_string()));
}
Ok(())
}
#[derive(Deserialize)]
struct ResumeSuspendedBody {
payload: Option<serde_json::Value>,
approval_token: Option<String>,
approved: Option<bool>,
}
async fn resume_suspended(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Json(body): Json<ResumeSuspendedBody>,
) -> error::Result<StatusCode> {
let approved = body.approved.unwrap_or(true);
let value = body.payload.unwrap_or(serde_json::Value::Null);
// Determine if we have a valid authed user or token
let has_token = if let Some(ref token) = body.approval_token {
validate_approval_token(&db, token, job_id, &w_id)
.await
.is_ok()
} else {
false
};
if opt_authed.is_none() && !has_token {
return Err(Error::NotAuthorized(
"Must be logged in or provide a valid approval token".to_string(),
));
}
let mut tx = db.begin().await?;
// Resolve the suspended flow (works for both WAC and classic flows)
let (flow, resume_job_id, is_wac) = get_suspended_flow_info(job_id, &mut tx).await?;
// Verify the job belongs to this workspace
let job_workspace: Option<String> =
sqlx::query_scalar("SELECT workspace_id FROM v2_job WHERE id = $1")
.bind(&flow.id)
.fetch_optional(&mut *tx)
.await?;
if job_workspace.as_deref() != Some(w_id.as_str()) {
return Err(Error::NotFound(
"Job not found in this workspace".to_string(),
));
}
// Check approval conditions
let approval_conditions = if is_wac {
flow.flow_status
.as_ref()
.and_then(|v| v.get("approval_conditions"))
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok())
} else {
flow.flow_status
.as_ref()
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok())
.and_then(|fs| fs.approval_conditions)
};
if let Some(ref ac) = approval_conditions {
if ac.user_auth_required && opt_authed.is_none() {
return Err(Error::NotAuthorized(
"This approval requires a logged-in user. Please sign in.".to_string(),
));
}
}
// If logged in, check authorization rules
if let Some(ref authed) = opt_authed {
let is_admin = authed.is_admin;
let is_owner = flow
.script_path
.as_deref()
.map(|p| require_owner_of_path(authed, p).is_ok())
.unwrap_or(false);
if !is_admin && !is_owner {
let trigger_email = flow.email.as_deref().unwrap_or("");
conditionally_require_authed_user(
Some(authed.clone()),
approval_conditions.clone(),
trigger_email,
)?;
}
} else if !has_token {
return Err(Error::NotAuthorized(
"Must be logged in or provide a valid approval token".to_string(),
));
}
// Generate a unique resume_id
let resume_id: u32 = rand::random();
// Check for duplicate
let exists: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM resume_job WHERE id = $1)")
.bind(Uuid::from_u128(resume_job_id.as_u128() ^ resume_id as u128))
.fetch_one(&mut *tx)
.await?;
if exists {
return Err(Error::BadRequest("Resume request already sent".to_string()));
}
let approver_value = opt_authed.as_ref().map(|a| a.username.clone());
insert_resume_job(
resume_id,
resume_job_id,
&flow,
value,
approver_value.clone(),
approved,
&mut tx,
)
.await?;
if !approved {
sqlx::query("UPDATE v2_job_queue SET suspend = 0 WHERE id = $1")
.bind(&flow.id)
.execute(&mut *tx)
.await?;
} else if is_wac {
if flow.suspend > 0 {
sqlx::query("UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1")
.bind(&flow.id)
.execute(&mut *tx)
.await?;
}
} else {
resume_immediately_if_relevant(flow, resume_job_id, &mut tx).await?;
}
let approver = approver_value.unwrap_or_else(|| "anonymous".to_string());
let audit_author = if let Some(ref authed) = opt_authed {
AuditAuthor::from(authed)
} else {
AuditAuthor {
email: approver.clone(),
username: approver.clone(),
username_override: None,
token_prefix: None,
}
};
audit_log(
&mut *tx,
&audit_author,
"jobs.suspend_resume",
ActionKind::Update,
&w_id,
Some(
&serde_json::json!({
"approved": approved,
"job_id": job_id,
"details": if approved {
format!("Approved by {}", &approver)
} else {
format!("Cancelled by {}", &approver)
}
})
.to_string(),
),
None,
)
.await?;
tx.commit().await?;
Ok(StatusCode::CREATED)
}
#[derive(Deserialize)]
struct ApprovalInfoQuery {
token: Option<String>,
}
#[derive(Serialize)]
struct ApprovalInfo {
flow_id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
form_schema: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
approval_conditions: Option<ApprovalConditions>,
can_approve: bool,
user_auth_required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
hide_cancel: Option<bool>,
approvers: Vec<Approval>,
}
async fn get_approval_info(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Query(query): Query<ApprovalInfoQuery>,
) -> error::Result<Json<ApprovalInfo>> {
// Validate access: either logged in or valid token
let has_token = if let Some(ref token) = query.token {
validate_approval_token(&db, token, job_id, &w_id)
.await
.is_ok()
} else {
false
};
if opt_authed.is_none() && !has_token {
return Err(Error::NotAuthorized(
"Must be logged in or provide a valid approval token".to_string(),
));
}
// Fetch job info
#[derive(sqlx::FromRow)]
struct ApprovalJobRow {
id: Uuid,
script_path: Option<String>,
email: String,
flow_status: Option<serde_json::Value>,
workflow_as_code_status: Option<serde_json::Value>,
}
let row = sqlx::query_as::<_, ApprovalJobRow>(
"SELECT j.id, j.runnable_path as script_path, j.permissioned_as_email as email,
s.flow_status, s.workflow_as_code_status
FROM v2_job j
LEFT JOIN v2_job_status s ON s.id = j.id
WHERE j.id = $1 AND j.workspace_id = $2",
)
.bind(&job_id)
.bind(&w_id)
.fetch_optional(&db)
.await?
.ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?;
let is_wac = row.workflow_as_code_status.is_some();
// Extract approval info based on WAC vs classic flow
let (form_schema, description, approval_conditions, hide_cancel) = if is_wac {
let approval_meta = row
.workflow_as_code_status
.as_ref()
.and_then(|v| v.get("_approval"));
let form = approval_meta.and_then(|m| m.get("form").cloned());
let ac = row
.flow_status
.as_ref()
.and_then(|v| v.get("approval_conditions"))
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok());
(form, None, ac, None)
} else {
let fs = row
.flow_status
.as_ref()
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok());
let ac = fs.as_ref().and_then(|s| s.approval_conditions.clone());
// For classic flows, form/description come from the flow definition and step result
let approval_step = fs.as_ref().map(|s| (s.step as usize).saturating_sub(1));
// Fetch flow definition to get suspend settings (form schema, hide_cancel).
// Try raw_flow on the job first, fall back to flow_version for deployed flows.
let raw_flow: Option<FlowValue> = {
let from_job: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT raw_flow FROM v2_job WHERE id = $1 AND workspace_id = $2",
)
.bind(&job_id)
.bind(&w_id)
.fetch_optional(&db)
.await?
.flatten();
if let Some(v) = from_job {
serde_json::from_value(v).ok()
} else {
// Deployed flow: fetch from flow_version using runnable_id
let from_version: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT fv.value FROM v2_job j JOIN flow_version fv ON fv.id = j.runnable_id \
WHERE j.id = $1 AND j.workspace_id = $2",
)
.bind(&job_id)
.bind(&w_id)
.fetch_optional(&db)
.await?
.flatten();
from_version.and_then(|v| serde_json::from_value(v).ok())
}
};
let suspend_module = raw_flow
.as_ref()
.and_then(|rf| approval_step.and_then(|s| rf.modules.get(s)));
let suspend_settings = suspend_module.and_then(|m| m.suspend.as_ref());
let form = suspend_settings
.and_then(|s| s.resume_form.as_ref())
.map(|rf| serde_json::json!(rf));
let hc = suspend_settings.map(|s| s.hide_cancel.unwrap_or(false));
// Fetch description and default_args from the step's completed job result
let step_job_id = fs
.as_ref()
.and_then(|s| approval_step.and_then(|step| s.modules.get(step)))
.and_then(|m| m.job());
let (desc, _default_args) = if let Some(sjid) = step_job_id {
let result: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT result FROM v2_job_completed WHERE id = $1 AND workspace_id = $2",
)
.bind(sjid)
.bind(&w_id)
.fetch_optional(&db)
.await?
.flatten();
let desc = result.as_ref().and_then(|r| r.get("description").cloned());
let da = result.as_ref().and_then(|r| r.get("default_args").cloned());
(desc, da)
} else {
(None, None)
};
(form, desc, ac, hc)
};
let user_auth_required = approval_conditions
.as_ref()
.map(|ac| ac.user_auth_required)
.unwrap_or(false);
// Determine if current user can approve
let can_approve = if let Some(ref authed) = opt_authed {
if authed.is_admin {
true
} else {
let is_owner = row
.script_path
.as_deref()
.map(|p| require_owner_of_path(authed, p).is_ok())
.unwrap_or(false);
if is_owner {
true
} else {
let trigger_email = row.email.as_str();
conditionally_require_authed_user(
Some(authed.clone()),
approval_conditions.clone(),
trigger_email,
)
.is_ok()
}
}
} else {
// Not logged in — can approve only if no auth required
!user_auth_required
};
// Get existing approvers
let approvers: Vec<Approval> = sqlx::query_as::<_, (i32, Option<String>)>(
"SELECT resume_id, approver FROM resume_job WHERE flow = $1",
)
.bind(&job_id)
.fetch_all(&db)
.await?
.into_iter()
.map(|(rid, approver)| Approval {
resume_id: rid as u16,
approver: approver.unwrap_or_else(|| "anonymous".to_string()),
})
.collect();
Ok(Json(ApprovalInfo {
flow_id: row.id,
form_schema,
description,
approval_conditions,
can_approve,
user_auth_required,
hide_cancel,
approvers,
}))
}
// --- End new approval system endpoints ---
pub async fn resume_suspended_job(
authed: Option<ApiAuthed>,
opt_tokened: OptTokened,
@@ -2258,26 +2679,8 @@ async fn resume_suspended_job_internal(
// Get flow info - works for step-level, flow-level, and WAC approval
let (flow_info, is_flow_level, is_wac) = get_flow_info_for_resume(job_id, &db).await?;
// For step-level resumes, verify user auth and flow status
// For flow-level resumes (pre-approvals), the flow might not be at a suspended step yet
// For WAC approvals, skip flow status checks (there is no flow)
if !is_flow_level && !is_wac {
let parent_flow = GetQuery::new()
.without_logs()
.without_code()
.without_flow()
.fetch(&db, &flow_info.id, &w_id)
.await?;
let flow_status = parent_flow
.flow_status()
.ok_or_else(|| anyhow::anyhow!("unable to find the flow status in the flow job"))?;
let trigger_email = match &parent_flow {
Job::CompletedJob(job) => &job.email,
Job::QueuedJob(job) => &job.email,
};
conditionally_require_authed_user(authed.clone(), flow_status, trigger_email)?;
}
// HMAC secret = full capability. Skip approval_conditions checks.
// Authorization rules are enforced by the new resume_suspended endpoint instead.
let exists = sqlx::query_scalar!(
r#"
@@ -2543,7 +2946,7 @@ async fn get_flow_info_for_resume(job_id: Uuid, db: &DB) -> error::Result<(FlowI
async fn get_suspended_flow_info<'c>(
job_id: Uuid,
tx: &mut Transaction<'c, Postgres>,
) -> error::Result<(FlowInfo, Uuid)> {
) -> error::Result<(FlowInfo, Uuid, bool)> {
let flow = sqlx::query_as!(
FlowInfo,
r#"
@@ -2556,7 +2959,9 @@ async fn get_suspended_flow_info<'c>(
.fetch_optional(&mut **tx)
.await?
.ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?;
let job_id = flow
// Try to extract step job_id from FlowStatus modules (classic flow path)
let step_job_id = flow
.flow_status
.as_ref()
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok())
@@ -2565,8 +2970,31 @@ async fn get_suspended_flow_info<'c>(
_ => None,
});
if let Some(job_id) = job_id {
Ok((flow, job_id))
if let Some(step_job_id) = step_job_id {
// Classic flow
Ok((flow, step_job_id, false))
} else if flow.suspend > 0 {
// WAC approval: no FlowStatus modules, but the job is suspended
// The flow_status here comes from COALESCE(flow_status, workflow_as_code_status),
// so for WAC it may contain approval_conditions from flow_status column
// or the WAC checkpoint from workflow_as_code_status column.
// We need the approval_conditions which are in flow_status column.
// Re-fetch just flow_status (without COALESCE fallback) for the auth check.
let flow_status_only: Option<serde_json::Value> =
sqlx::query_scalar("SELECT flow_status FROM v2_job_status WHERE id = $1")
.bind(&job_id)
.fetch_optional(&mut **tx)
.await?
.flatten();
let flow = FlowInfo {
id: flow.id,
flow_status: flow_status_only,
suspend: flow.suspend,
script_path: flow.script_path,
email: flow.email,
};
Ok((flow, job_id, true))
} else {
Err(anyhow::anyhow!("the flow is not in a suspended state anymore").into())
}
@@ -2643,7 +3071,11 @@ pub async fn get_suspended_job_flow(
Job::CompletedJob(job) => &job.email,
Job::QueuedJob(job) => &job.email,
};
conditionally_require_authed_user(authed.clone(), flow_status.clone(), trigger_email)?;
conditionally_require_authed_user(
authed.clone(),
flow_status.approval_conditions.clone(),
trigger_email,
)?;
let approvers_from_status = match flow_module_status {
FlowStatusModule::Success { approvers, .. } => approvers.to_owned(),
@@ -2684,16 +3116,25 @@ pub async fn get_suspended_job_flow(
fn conditionally_require_authed_user(
_authed: Option<ApiAuthed>,
flow_status: FlowStatus,
approval_conditions_opt: Option<ApprovalConditions>,
_trigger_email: &str,
) -> error::Result<()> {
let approval_conditions_opt = flow_status.approval_conditions;
if approval_conditions_opt.is_none() {
return Ok(());
}
let approval_conditions = approval_conditions_opt.unwrap();
// Check self-approval independently of user_auth_required
if approval_conditions.self_approval_disabled {
if let Some(ref authed) = _authed {
if !authed.is_admin && authed.email.eq(_trigger_email) {
return Err(Error::PermissionDenied(
"Self-approval is disabled for this flow step".to_string(),
));
}
}
}
if approval_conditions.user_auth_required {
{
#[cfg(not(feature = "enterprise"))]
@@ -2711,13 +3152,6 @@ fn conditionally_require_authed_user(
let authed = _authed.unwrap();
if !authed.is_admin {
if approval_conditions.self_approval_disabled && authed.email.eq(_trigger_email)
{
return Err(Error::PermissionDenied(
"Self-approval is disabled for this flow step".to_string(),
));
}
if !approval_conditions.user_groups_required.is_empty() {
#[cfg(feature = "enterprise")]
{
@@ -2863,11 +3297,18 @@ pub async fn get_resume_urls_internal(
.map(|x| format!("?approver={}", encode(x)))
.unwrap_or_else(String::new);
// Generate approval token for the new approval page URL.
// The token targets the parent flow/WAC job for proper resolution.
let approval_target_id = get_flow_id_for_job(&db, job_id)
.await
.unwrap_or(target_job_id);
let approval_token = generate_approval_token(&w_id, approval_target_id, &db).await?;
let base_url_str = BASE_URL.read().await.clone();
let base_url = base_url_str.as_str();
let res = ResumeUrls {
approvalPage: format!(
"{base_url}/approve/{w_id}/{target_job_id}/{resume_id}/{signature}{approver_query}"
"{base_url}/approve/{w_id}/{approval_target_id}?token={approval_token}"
),
cancel: build_resume_url(
"cancel",
+51 -22
View File
@@ -8,8 +8,9 @@
// Re-export everything from windmill-api-workspaces
pub use windmill_api_workspaces::workspaces::*;
use windmill_api_workspaces::workspaces::{build_copilot_settings_state, InstanceAISummary};
use crate::ai::{AIConfig, AI_REQUEST_CACHE};
use crate::ai::{invalidate_ai_request_cache_for_workspace, AIConfig};
use crate::db::ApiAuthed;
use crate::teams_oss::{
connect_teams, edit_teams_command, run_teams_message_test_job,
@@ -24,7 +25,7 @@ use axum::{
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::{
error::{Error, JsonResult, Result},
error::{Error, JsonResult},
utils::require_admin,
DB,
};
@@ -34,6 +35,9 @@ use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
use axum::extract::Query;
#[cfg(feature = "enterprise")]
use serde::Deserialize;
use serde::Serialize;
#[cfg(feature = "enterprise")]
use windmill_common::error::Result;
#[cfg(feature = "enterprise")]
use windmill_common::utils::require_admin_or_devops;
@@ -83,7 +87,7 @@ async fn edit_copilot_config(
Path(w_id): Path<String>,
ApiAuthed { is_admin, username, .. }: ApiAuthed,
Json(ai_config): Json<AIConfig>,
) -> Result<String> {
) -> JsonResult<EditCopilotConfigResponse> {
require_admin(is_admin, &username)?;
if let Some(ref custom_prompts) = ai_config.custom_prompts {
@@ -109,11 +113,7 @@ async fn edit_copilot_config(
.execute(&mut *tx)
.await?;
if let Some(ref providers) = ai_config.providers {
for provider in providers.keys() {
AI_REQUEST_CACHE.remove(&(w_id.clone(), provider.clone()));
}
}
invalidate_ai_request_cache_for_workspace(&w_id);
audit_log(
&mut *tx,
@@ -139,37 +139,66 @@ async fn edit_copilot_config(
)
.await?;
Ok(format!("Edit copilot config for workspace {}", &w_id))
let workspace_has_config = ai_config.has_providers();
let instance_ai_config =
sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'")
.fetch_optional(&db)
.await?;
let settings_state =
build_copilot_settings_state(workspace_has_config, instance_ai_config.as_ref());
let effective_ai_config = if workspace_has_config {
ai_config
} else if let Some(instance_ai_config) = instance_ai_config {
serde_json::from_value::<AIConfig>(instance_ai_config).unwrap_or_default()
} else {
AIConfig::default()
};
Ok(Json(EditCopilotConfigResponse {
effective_ai_config,
has_instance_ai_config: settings_state.has_instance_ai_config,
uses_instance_ai_config: settings_state.uses_instance_ai_config,
instance_ai_summary: settings_state.instance_ai_summary,
}))
}
#[derive(Serialize)]
struct EditCopilotConfigResponse {
effective_ai_config: AIConfig,
has_instance_ai_config: bool,
uses_instance_ai_config: bool,
#[serde(skip_serializing_if = "Option::is_none")]
instance_ai_summary: Option<InstanceAISummary>,
}
async fn get_copilot_info(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<AIConfig> {
let mut tx = db.begin().await?;
let copilot_info = sqlx::query_scalar!(
let workspace_ai_config = sqlx::query_scalar!(
"SELECT ai_config as \"ai_config: sqlx::types::Json<AIConfig>\" FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&mut *tx)
.fetch_one(&db)
.await
.map_err(|e| {
Error::internal_err(format!(
"getting ai config: {e:#}"
))
})?;
tx.commit().await?;
if let Some(sqlx::types::Json(copilot_info)) = copilot_info {
Ok(Json(copilot_info))
if let Some(workspace_ai_config) = workspace_ai_config.filter(|c| c.0.has_providers()) {
Ok(Json(workspace_ai_config.0))
} else if let Some(instance_config) =
sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'")
.fetch_optional(&db)
.await?
{
Ok(Json(
serde_json::from_value::<AIConfig>(instance_config).unwrap_or_default(),
))
} else {
Ok(Json(AIConfig {
providers: None,
default_model: None,
code_completion_model: None,
custom_prompts: None,
max_tokens_per_model: None,
}))
Ok(Json(AIConfig::default()))
}
}
+11
View File
@@ -0,0 +1,11 @@
use std::sync::atomic::{AtomicU64, Ordering};
static INSTANCE_AI_CONFIG_REVISION: AtomicU64 = AtomicU64::new(0);
pub fn current_instance_ai_config_revision() -> u64 {
INSTANCE_AI_CONFIG_REVISION.load(Ordering::SeqCst)
}
pub fn bump_instance_ai_config_revision() -> u64 {
INSTANCE_AI_CONFIG_REVISION.fetch_add(1, Ordering::SeqCst) + 1
}
+6 -5
View File
@@ -286,16 +286,17 @@ pub struct FlowData {
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FlowNotes {
pub struct FlowExtras {
pub notes: Option<Box<RawValue>>,
pub groups: Option<Box<RawValue>>,
}
impl FlowData {
pub fn notes(&self) -> Option<FlowNotes> {
serde_json::from_str::<FlowNotes>(self.raw_flow.get())
pub fn extras(&self) -> Option<FlowExtras> {
serde_json::from_str::<FlowExtras>(self.raw_flow.get())
.map_err(|e| {
tracing::error!("Failed to parse notes into FlowNotes: {}", e);
error::Error::internal_err(format!("Failed to parse notes into FlowNotes: {}", e))
tracing::error!("Failed to parse flow extras: {}", e);
error::Error::internal_err(format!("Failed to parse flow extras: {}", e))
})
.ok()
}
@@ -4,6 +4,7 @@ pub const DEFAULT_TAGS_WORKSPACES_SETTING: &str = "default_tags_workspaces";
pub const BASE_URL_SETTING: &str = "base_url";
pub const WS_BASE_URL_SETTING: &str = "ws_base_url";
pub const OAUTH_SETTING: &str = "oauths";
pub const AI_CONFIG_SETTING: &str = "ai_config";
pub const RETENTION_PERIOD_SECS_SETTING: &str = "retention_period_secs";
pub const AUDIT_LOG_RETENTION_DAYS_SETTING: &str = "audit_log_retention_days";
pub const MONITOR_LOGS_ON_OBJECT_STORE_SETTING: &str = "monitor_logs_on_s3";
@@ -351,6 +351,13 @@ pub struct GlobalSettings {
std::collections::HashMap<String, std::collections::HashMap<String, serde_json::Value>>,
>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "instance_config_schema",
schemars(schema_with = "opaque_json_schema")
)]
pub ai_config: Option<serde_json::Value>,
/// Catch-all for settings not yet covered by typed fields.
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
+1
View File
@@ -29,6 +29,7 @@ use sqlx::{Acquire, Postgres};
pub mod agent_workers;
#[cfg(feature = "bedrock")]
pub mod ai_bedrock;
pub mod ai_cache;
pub mod ai_google;
pub mod ai_providers;
pub mod ai_types;
+18
View File
@@ -140,6 +140,24 @@ pub async fn get_workspace_key(w_id: &str, db: &DB) -> crate::error::Result<Stri
Ok(key)
}
/// Generate a stateless approval token from workspace key + job_id.
/// This token grants access to view approval info and attempt to resume,
/// but cannot be reversed to obtain the HMAC resume secret.
pub async fn generate_approval_token(
w_id: &str,
job_id: uuid::Uuid,
db: &DB,
) -> crate::error::Result<String> {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let key = get_workspace_key(w_id, db).await?;
let mut mac = Hmac::<Sha256>::new_from_slice(key.as_bytes())
.map_err(|e| crate::Error::internal_err(format!("HMAC key error: {e}")))?;
mac.update(job_id.as_bytes());
mac.update(b"approval_token");
Ok(hex::encode(mac.finalize().into_bytes()))
}
pub async fn get_secret_value_as_admin(
db: &DB,
w_id: &str,
@@ -68,7 +68,7 @@ pub async fn trigger_dependents_to_recompute_dependencies(
let job_payload = match importer_kind.as_str() {
"script" => match sqlx::query_scalar!(
"SELECT hash FROM script WHERE path = $1 AND workspace_id = $2 AND deleted = false ORDER BY created_at DESC LIMIT 1",
"SELECT hash FROM script WHERE path = $1 AND workspace_id = $2 AND deleted = false AND archived = false ORDER BY created_at DESC LIMIT 1",
importer_path,
w_id
)
@@ -181,6 +181,7 @@ async fn create_native_trigger<T: External>(
&external_id,
&config,
service_config,
data.summary.as_deref(),
)
.await?;
@@ -304,6 +305,7 @@ async fn update_native_trigger_handler<T: External>(
&external_id,
&config,
service_config,
data.summary.as_deref(),
)
.await?;
+17 -7
View File
@@ -195,6 +195,7 @@ pub struct NativeTrigger {
pub error: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub summary: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -209,6 +210,7 @@ pub struct NativeTriggerData<C> {
pub script_path: String,
pub is_flow: bool,
pub service_config: C,
pub summary: Option<String>,
}
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
@@ -821,6 +823,7 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>
external_id: &str,
config: &NativeTriggerConfig,
service_config: C,
summary: Option<&str>,
) -> Result<()> {
use windmill_common::auth::hash_token;
@@ -835,12 +838,13 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>
script_path,
is_flow,
webhook_token_hash,
service_config
service_config,
summary
) VALUES (
$1, $2, $3, $4, $5, $6, $7
$1, $2, $3, $4, $5, $6, $7, $8
)
ON CONFLICT (external_id, workspace_id, service_name)
DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, error = NULL, updated_at = NOW()
DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, summary = $8, error = NULL, updated_at = NOW()
"#,
external_id,
workspace_id,
@@ -849,6 +853,7 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>
config.is_flow,
webhook_token_hash,
sqlx::types::Json(service_config) as _,
summary,
)
.execute(db)
.await?;
@@ -863,6 +868,7 @@ pub async fn update_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres
external_id: &str,
config: &NativeTriggerConfig,
service_config: Option<&RawValue>,
summary: Option<&str>,
) -> Result<()> {
use windmill_common::auth::hash_token;
@@ -871,7 +877,7 @@ pub async fn update_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres
sqlx::query!(
r#"
UPDATE native_trigger
SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4, error = NULL, updated_at = NOW()
SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4, summary = $8, error = NULL, updated_at = NOW()
WHERE
workspace_id = $5
AND service_name = $6
@@ -884,6 +890,7 @@ pub async fn update_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres
workspace_id,
service_name as ServiceName,
external_id,
summary,
)
.execute(db)
.await?;
@@ -934,7 +941,8 @@ pub async fn get_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>>(
service_config,
error,
created_at,
updated_at
updated_at,
summary
FROM
native_trigger
WHERE
@@ -972,7 +980,8 @@ pub async fn get_native_trigger_by_script<'c, E: sqlx::Executor<'c, Database = P
service_config,
error,
created_at,
updated_at
updated_at,
summary
FROM
native_trigger
WHERE
@@ -1018,7 +1027,8 @@ pub async fn list_native_triggers<'c, E: sqlx::Executor<'c, Database = Postgres>
nt.service_config,
nt.error,
nt.created_at,
nt.updated_at
nt.updated_at,
nt.summary
FROM
native_trigger nt
WHERE
+3 -12
View File
@@ -133,21 +133,12 @@ async fn list_variables(
])
.left()
.join("account")
.on(&format!(
"variable.account = account.id AND account.workspace_id = '{}'",
w_id
))
.on("variable.account = account.id AND account.workspace_id = ?".bind(&w_id))
.left()
.join("resource")
.on(&format!(
"resource.path = variable.path AND resource.workspace_id = '{}'",
w_id
))
.on("resource.path = variable.path AND resource.workspace_id = ?".bind(&w_id))
.and_where("variable.workspace_id = ?".bind(&w_id))
.and_where(&format!(
"variable.path NOT LIKE 'u/' || '{}' || '/secret_arg/%'",
authed.username
))
.and_where("variable.path NOT LIKE 'u/' || ? || '/secret_arg/%'".bind(&authed.username))
.order_by("path", false)
.limit(per_page)
.offset(offset)
+1
View File
@@ -110,6 +110,7 @@ gcp_auth = { workspace = true, optional = true }
rust_decimal.workspace = true
jsonwebtoken.workspace = true
sha2.workspace = true
hmac.workspace = true
pem = { workspace = true, optional = true }
urlencoding.workspace = true
nix.workspace = true
+91 -3
View File
@@ -1504,7 +1504,7 @@ async function run() {{
return {{ type: "inline_checkpoint", key: dispatch.key, result: dispatch.result ?? null, started_at: dispatch.started_at, duration_ms: dispatch.duration_ms }};
}}
if (dispatch.mode === "approval") {{
return {{ type: "approval", key: dispatch.key, timeout: dispatch.timeout, form: dispatch.form }};
return {{ type: "approval", key: dispatch.key, timeout: dispatch.timeout, form: dispatch.form, self_approval_disabled: dispatch.self_approval_disabled }};
}}
if (dispatch.mode === "sleep") {{
return {{ type: "sleep", key: dispatch.key, seconds: dispatch.seconds }};
@@ -2634,7 +2634,7 @@ pub async fn handle_wac_v2_output(
job.id, num_steps
)))
}
WacOutput::Approval { key, timeout, form } => {
WacOutput::Approval { key, timeout, form, self_approval_disabled } => {
let db = match conn {
Connection::Sql(db) => db,
_ => {
@@ -2676,11 +2676,91 @@ pub async fn handle_wac_v2_output(
.await
.map_err(|e| error::Error::internal_err(format!("Failed to save checkpoint: {e}")))?;
// Store approval_conditions in flow_status for resume endpoint auth checks
let sad = self_approval_disabled.unwrap_or(false);
if sad {
#[cfg(not(feature = "enterprise"))]
return Err(error::Error::ExecutionErr(
"Disabling self-approval is an enterprise only feature".to_string(),
));
#[cfg(feature = "enterprise")]
{
use windmill_common::flow_status::ApprovalConditions;
let approval_conditions = ApprovalConditions {
user_auth_required: true,
user_groups_required: vec![],
self_approval_disabled: true,
};
sqlx::query(
"UPDATE v2_job_status SET flow_status = JSONB_SET(
COALESCE(flow_status, '{}'::jsonb),
'{approval_conditions}',
$2::jsonb
) WHERE id = $1",
)
.bind(&job.id)
.bind(&serde_json::json!(approval_conditions))
.execute(&mut *tx)
.await
.map_err(|e| {
error::Error::internal_err(format!(
"Failed to save approval conditions: {e}"
))
})?;
}
}
// Generate resume URLs for the inline approval buttons.
// Use a hash of the step key as resume_id so each waitForApproval()
// in the same workflow gets a unique resume_job record.
let resume_id: u32 = {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
key.hash(&mut hasher);
(hasher.finish() & 0xFFFF_FFFF) as u32
};
// Generate stateless approval token using shared utility
let approval_token =
windmill_common::variables::generate_approval_token(&job.workspace_id, job.id, db)
.await?;
let (resume_url, cancel_url, approval_page_url) = {
use hmac::{Hmac, Mac};
use sha2::Sha256;
use windmill_common::variables::get_workspace_key;
let wkey = get_workspace_key(&job.workspace_id, db).await?;
let mut mac = Hmac::<Sha256>::new_from_slice(wkey.as_bytes())
.map_err(|e| error::Error::internal_err(format!("HMAC key error: {e}")))?;
mac.update(job.id.as_bytes());
mac.update(resume_id.to_be_bytes().as_ref());
let signature = hex::encode(mac.finalize().into_bytes());
let base_url = windmill_common::BASE_URL.read().await.clone();
let w_id = &job.workspace_id;
let job_id = &job.id;
let resume = format!(
"{base_url}/api/w/{w_id}/jobs_u/resume/{job_id}/{resume_id}/{signature}"
);
let cancel = format!(
"{base_url}/api/w/{w_id}/jobs_u/cancel/{job_id}/{resume_id}/{signature}"
);
let approval_page =
format!("{base_url}/approve/{w_id}/{job_id}?token={approval_token}");
(resume, cancel, approval_page)
};
// Store approval form metadata for the approval page endpoint
let approval_meta = serde_json::json!({
"key": key,
"form": form,
"timeout": timeout_secs as u32,
"self_approval_disabled": sad,
"resume": resume_url,
"cancel": cancel_url,
"approvalPage": approval_page_url,
});
sqlx::query(
"UPDATE v2_job_status SET workflow_as_code_status = jsonb_set(
@@ -2705,6 +2785,11 @@ pub async fn handle_wac_v2_output(
"started_at": &now_str,
"name": key,
"approval": true,
"self_approval_disabled": sad,
"form": form,
"resume": &resume_url,
"cancel": &cancel_url,
"approvalPage": &approval_page_url,
});
let step_timeline_key = format!("_step/{}", key);
sqlx::query(
@@ -3008,7 +3093,10 @@ pub fn build_nativets_env_code(
"const process = {{ env: {{}} }};\nconst BASE_URL = '{base_internal_url}';\nconst BASE_INTERNAL_URL = '{base_internal_url}';\nprocess.env['BASE_URL'] = BASE_URL;process.env['BASE_INTERNAL_URL'] = BASE_INTERNAL_URL;\n{}",
reserved_variables
.iter()
.map(|(k, v)| format!("process.env['{}'] = '{}';", k, v))
.map(|(k, v)| {
let escaped = v.replace('\\', "\\\\").replace('\'', "\\'").replace('\n', "\\n").replace('\r', "\\r");
format!("process.env['{}'] = '{}';", k, escaped)
})
.collect::<Vec<String>>()
.join("\n")
)
+9 -15
View File
@@ -59,7 +59,13 @@ pub enum WacOutput {
/// No child job is dispatched — the parent suspends directly and resumes
/// when a user hits the resume/cancel endpoint.
#[serde(rename = "approval")]
Approval { key: String, timeout: Option<u32>, form: Option<Value> },
Approval {
key: String,
timeout: Option<u32>,
form: Option<Value>,
#[serde(default)]
self_approval_disabled: Option<bool>,
},
/// Server-side sleep — suspend the workflow for a duration without holding a worker.
#[serde(rename = "sleep")]
Sleep { key: String, seconds: u32 },
@@ -306,15 +312,13 @@ pub async fn prepare_checkpoint_for_resume(
}
/// Detect WAC v2 patterns in TypeScript/Bun code.
/// Checks for `import ... from "windmill-client"` containing workflow/task,
/// Checks for `import ... from "windmill-client"` containing workflow,
/// skipping comment lines. Handles both single-line and multi-line imports.
pub fn is_wac_v2_ts(code: &str) -> bool {
let mut has_wac_import = false;
let mut has_workflow = false;
let mut has_task = false;
let mut in_import_block = false;
let mut import_block_has_workflow = false;
let mut import_block_has_task = false;
for line in code.lines() {
let trimmed = line.trim();
if trimmed.starts_with("//") {
@@ -328,34 +332,24 @@ pub fn is_wac_v2_ts(code: &str) -> bool {
if trimmed.contains("workflow") {
has_workflow = true;
}
if trimmed.contains("task") {
has_task = true;
}
in_import_block = false;
}
// Start of multi-line import: import {
else if trimmed.starts_with("import") && trimmed.contains("{") && !trimmed.contains("}") {
in_import_block = true;
import_block_has_workflow = trimmed.contains("workflow");
import_block_has_task = trimmed.contains("task");
}
// Inside multi-line import block
else if in_import_block {
if trimmed.contains("workflow") {
import_block_has_workflow = true;
}
if trimmed.contains("task") {
import_block_has_task = true;
}
// End of multi-line import: } from "windmill-client"
if trimmed.contains("windmill-client") {
has_wac_import = true;
if import_block_has_workflow {
has_workflow = true;
}
if import_block_has_task {
has_task = true;
}
in_import_block = false;
}
// End of import block but not windmill-client
@@ -367,7 +361,7 @@ pub fn is_wac_v2_ts(code: &str) -> bool {
has_workflow = true;
}
}
has_wac_import && has_workflow && has_task
has_wac_import && has_workflow
}
/// Detect WAC v2 patterns in Python code.
+5 -1
View File
@@ -965,6 +965,7 @@ async fn get_otel_tracing_proxy_envs(
TRACING_PROXY_CA_CERT_PATH.to_string(),
),
("CURL_CA_BUNDLE", TRACING_PROXY_CA_CERT_PATH.to_string()),
("GIT_SSL_CAINFO", TRACING_PROXY_CA_CERT_PATH.to_string()),
("DENO_CERT", TRACING_PROXY_CA_CERT_PATH.to_string()),
])
}
@@ -4493,7 +4494,10 @@ pub async fn run_language_executor(
"const process = {{ env: {{}} }};\nconst BASE_URL = '{base_internal_url}';\nconst BASE_INTERNAL_URL = '{base_internal_url}';\nprocess.env['BASE_URL'] = BASE_URL;process.env['BASE_INTERNAL_URL'] = BASE_INTERNAL_URL;\n{}",
reserved_variables
.iter()
.map(|(k, v)| format!("const {} = '{}';\nprocess.env['{}'] = '{}';\n", k, v, k, v))
.map(|(k, v)| {
let escaped = v.replace('\\', "\\\\").replace('\'', "\\'").replace('\n', "\\n").replace('\r', "\\r");
format!("const {} = '{}';\nprocess.env['{}'] = '{}';\n", k, escaped, k, escaped)
})
.collect::<Vec<String>>()
.join("\n"));
@@ -427,13 +427,13 @@ pub async fn handle_flow_dependency_job(
// `JobKind::FlowDependencies` job store either:
// - A saved flow version `id` in the `script_hash` column.
// - Preview raw flow in the `queue` or `job` table.
let (mut flow, notes) = match job.runnable_id {
let (mut flow, extras) = match job.runnable_id {
Some(ScriptHash(id)) => {
let flow = cache::flow::fetch_version(db, id).await?;
(flow.value().clone(), flow.notes())
(flow.value().clone(), flow.extras())
}
_ => match preview_data {
Some(RawData::Flow(data)) => (data.value().clone(), data.notes()),
Some(RawData::Flow(data)) => (data.value().clone(), data.extras()),
_ => return Err(Error::internal_err("expected script hash")),
},
};
@@ -528,18 +528,22 @@ pub async fn handle_flow_dependency_job(
}
#[derive(Debug, Clone, Serialize)]
struct FlowValueWithNotes<'a> {
struct FlowValueWithExtras<'a> {
#[serde(flatten)]
value: &'a FlowValue,
#[serde(skip_serializing_if = "Option::is_none")]
notes: Option<Box<RawValue>>, // TODO: Make this a Vec<FlowNote>
notes: Option<Box<RawValue>>,
#[serde(skip_serializing_if = "Option::is_none")]
groups: Option<Box<RawValue>>,
}
let new_flow_value = Json(
serde_json::value::to_raw_value(&FlowValueWithNotes {
serde_json::value::to_raw_value(&FlowValueWithExtras {
value: &flow,
notes: notes.and_then(|n| n.notes).map(|n| n.into()),
notes: extras.as_ref().and_then(|e| e.notes.clone()),
groups: extras.as_ref().and_then(|e| e.groups.clone()),
})
.map_err(to_anyhow)?,
);
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.662.0";
export const VERSION = "v1.664.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+13 -4
View File
@@ -1,7 +1,7 @@
import { GlobalOptions } from "../../types.ts";
import { requireLogin } from "../../core/auth.ts";
import { resolveWorkspace, validatePath } from "../../core/context.ts";
import { readFile, writeFile, stat } from "node:fs/promises";
import { readFile, writeFile, stat, mkdir } from "node:fs/promises";
import { Buffer } from "node:buffer";
import { colors } from "@cliffy/ansi/colors";
import { Command } from "@cliffy/command";
@@ -1069,16 +1069,22 @@ async function get(opts: GlobalOptions & { json?: boolean }, path: string) {
}
}
const languageAliases: Record<string, ScriptLanguage> = {
python: "python3",
};
async function bootstrap(
opts: GlobalOptions & { summary: string; description: string },
scriptPath: string,
language: ScriptLanguage
language: ScriptLanguage | string
) {
if (!validatePath(scriptPath)) {
return;
}
const scriptInitialCode = scriptBootstrapCode[language];
const resolvedLanguage = (languageAliases[language] ?? language) as ScriptLanguage;
const scriptInitialCode = scriptBootstrapCode[resolvedLanguage];
if (scriptInitialCode === undefined) {
throw new Error("Language unknown");
}
@@ -1086,7 +1092,7 @@ async function bootstrap(
const config = await readConfigFile();
const extension = filePathExtensionFromContentType(
language,
resolvedLanguage,
config.defaultTs
);
const scriptCodeFileFullPath = scriptPath + extension;
@@ -1118,6 +1124,9 @@ async function bootstrap(
yamlOptions
);
const parentDir = path.dirname(scriptCodeFileFullPath);
await mkdir(parentDir, { recursive: true });
await writeFile(scriptCodeFileFullPath, scriptInitialCode, {
flag: 'wx', encoding: 'utf-8',
});
+4
View File
@@ -231,6 +231,7 @@ export async function pushNativeTrigger(
is_flow: result.is_flow,
service_config: result.service_config,
error: result.error,
summary: result.summary,
};
log.debug(`Native trigger ${serviceName}/${externalId} exists on remote`);
} catch {
@@ -243,6 +244,7 @@ export async function pushNativeTrigger(
script_path: localTrigger.script_path,
is_flow: localTrigger.is_flow,
service_config: localTrigger.service_config,
summary: localTrigger.summary,
};
if (remoteTrigger) {
@@ -251,11 +253,13 @@ export async function pushNativeTrigger(
script_path: localTrigger.script_path,
is_flow: localTrigger.is_flow,
service_config: localTrigger.service_config,
summary: localTrigger.summary,
};
const remoteCompare = {
script_path: remoteTrigger.script_path,
is_flow: remoteTrigger.is_flow,
service_config: remoteTrigger.service_config,
summary: remoteTrigger.summary,
};
if (isSuperset(localCompare, remoteCompare)) {
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -68,7 +68,7 @@ export {
workspaceAdd,
};
export const VERSION = "1.662.0";
export const VERSION = "1.664.0";
// Re-exported from constants.ts to maintain backwards compatibility
export { WM_FORK_PREFIX } from "./core/constants.ts";
+57
View File
@@ -409,6 +409,63 @@ describe("script bootstrap command", () => {
});
});
test("accepts 'python' as alias for python3", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
await writeFile(
join(tempDir, "wmill.yaml"),
`defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`,
"utf-8"
);
await mkdir(join(tempDir, "f", "test"), { recursive: true });
const result = await backend.runCLICommand(
["script", "bootstrap", "f/test/py_alias_script", "python"],
tempDir
);
expect(result.code).toEqual(0);
const codeStat = await stat(join(tempDir, "f/test/py_alias_script.py"));
expect(codeStat.isFile()).toBe(true);
const metaStat = await stat(
join(tempDir, "f/test/py_alias_script.script.yaml")
);
expect(metaStat.isFile()).toBe(true);
});
});
test("creates parent directories automatically", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
await writeFile(
join(tempDir, "wmill.yaml"),
`defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`,
"utf-8"
);
// Do NOT pre-create f/test — bootstrap should create it
const result = await backend.runCLICommand(
["script", "bootstrap", "f/test/auto_dir_script", "bun"],
tempDir
);
expect(result.code).toEqual(0);
const codeStat = await stat(join(tempDir, "f/test/auto_dir_script.ts"));
expect(codeStat.isFile()).toBe(true);
const metaStat = await stat(
join(tempDir, "f/test/auto_dir_script.script.yaml")
);
expect(metaStat.isFile()).toBe(true);
});
});
test("creates Go script files", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.662.0",
"version": "1.664.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.662.0",
"version": "1.664.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.662.0",
"version": "1.664.0",
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -10,9 +10,11 @@
interface Props {
expressOAuthSetup?: boolean
workspace?: string
disableChatOffset?: boolean
}
let { expressOAuthSetup = false }: Props = $props()
let { expressOAuthSetup = false, workspace = undefined, disableChatOffset = false }: Props = $props()
let drawer: Drawer | undefined = $state()
let resourceType = $state('')
@@ -50,6 +52,7 @@
dispatch('close')
}}
size="800px"
{disableChatOffset}
>
<DrawerContent
title="Add a resource"
@@ -68,6 +71,7 @@
on:close={drawer?.closeDrawer}
on:refresh
express={expressOAuthSetup}
{workspace}
/>
{#snippet actions()}
<div class="flex gap-1">
@@ -42,6 +42,7 @@
disabled?: boolean
manual?: boolean
express?: boolean
workspace?: string
}
let {
@@ -50,9 +51,12 @@
isGoogleSignin = $bindable(false),
disabled = $bindable(false),
manual = $bindable(true),
express = false
express = false,
workspace = undefined
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
let isValid = $state(true)
const nativeLanguagesCategory = [
@@ -214,7 +218,7 @@
return
}
const availableRts = await ResourceService.listResourceTypeNames({
workspace: $workspaceStore!
workspace: effectiveWorkspace
})
connectsManual = availableRts
@@ -316,7 +320,7 @@
async function getResourceTypeInfo() {
resourceTypeInfo = await ResourceService.getResourceType({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
path: resourceType
})
const props: Record<string, SchemaProperty> = resourceTypeInfo?.schema?.['properties'] ?? {}
@@ -419,7 +423,7 @@
// Check if variable paths already exist
if (!manual || linkedSecrets.length <= 1) {
const exists = await VariableService.existsVariable({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
path
})
if (exists) {
@@ -429,7 +433,7 @@
for (const secretField of linkedSecrets) {
const varPath = `${path}_${secretField}`
const exists = await VariableService.existsVariable({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
path: varPath
})
if (exists) {
@@ -440,7 +444,7 @@
}
}
let exists = await ResourceService.existsResource({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
path
})
@@ -478,7 +482,7 @@
account = Number(
await OauthService.createAccount({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
requestBody: accountData
})
)
@@ -492,7 +496,7 @@
if (typeof value == 'string' && value != '' && !value.startsWith('$var:')) {
savedVariableCount++
await VariableService.createVariable({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
requestBody: {
path,
value: value,
@@ -513,7 +517,7 @@
if (typeof v == 'string' && v != '' && !v.startsWith('$var:')) {
savedVariableCount++
await VariableService.createVariable({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
requestBody: {
path,
value: v,
@@ -532,7 +536,7 @@
const varPath = `${path}_${secretField}`
savedVariableCount++
await VariableService.createVariable({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
requestBody: {
path: varPath,
value: v,
@@ -549,7 +553,7 @@
}
await ResourceService.createResource({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
requestBody: {
resource_type: resourceType,
path,
+6
View File
@@ -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'
@@ -561,6 +562,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
function updateFlow(flow: OpenFlow) {
if (lockChanges) {
@@ -85,7 +85,6 @@
>
<FlowModuleSchemaItemViewer
onclick={handleClick}
deletable={false}
id={mod.id}
label={mod.summary ||
(`path` in mod.value ? mod.value.path : undefined) ||
@@ -43,6 +43,7 @@
import { SelectionManager } from './graph/selectionUtils.svelte'
import { NoteEditor } from './graph/noteEditor.svelte'
import { setNoteEditorContext } from './graph/noteEditor.svelte'
import { GroupEditor, setGroupEditorContext } from './graph/groupEditor.svelte'
import { cleanFlow } from './flows/utils.svelte'
import {
Calendar,
@@ -672,6 +673,11 @@
)
setNoteEditorContext(noteEditor)
// Set up GroupEditor context for group editing capabilities
const groupEditor = new GroupEditor(flowStore)
let canCreateGroup = $state({ val: false })
setGroupEditorContext(groupEditor, canCreateGroup)
setContext(
'FlowGraphAssetContext',
initFlowGraphAssetsCtx({ getModules: () => flowStore.val.value.modules })
@@ -140,6 +140,7 @@
<FlowGraphV2
bind:this={beforeGraph}
modules={beforeFlow.value.modules}
groups={beforeFlow.value.groups}
failureModule={beforeFlow.value.failure_module}
preprocessorModule={beforeFlow.value.preprocessor_module}
earlyStop={beforeFlow.value.skip_expr !== undefined}
@@ -171,6 +172,7 @@
bind:this={afterGraph}
diffBeforeFlow={beforeFlow}
modules={afterFlow.value.modules}
groups={afterFlow.value.groups}
failureModule={afterFlow.value.failure_module}
preprocessorModule={afterFlow.value.preprocessor_module}
earlyStop={afterFlow.value.skip_expr !== undefined}
@@ -201,6 +203,7 @@
<FlowGraphV2
diffBeforeFlow={beforeFlow}
modules={afterFlow.value.modules}
groups={afterFlow.value.groups}
failureModule={afterFlow.value.failure_module}
preprocessorModule={afterFlow.value.preprocessor_module}
earlyStop={afterFlow.value.skip_expr !== undefined}
@@ -9,24 +9,23 @@
import { dfs } from './flows/dfs'
import { workspaceStore } from '$lib/stores'
interface Props {
flow: {
summary: string
description?: string
value: FlowValue
schema?: any
path?: string
};
overflowAuto?: boolean;
noSide?: boolean;
download?: boolean;
noGraph?: boolean;
triggerNode?: boolean;
stepDetail?: FlowModule | string | undefined;
workspace?: string | undefined;
minHeight?: number;
noBorder?: boolean;
summary: string
description?: string
value: FlowValue
schema?: any
path?: string
}
overflowAuto?: boolean
noSide?: boolean
download?: boolean
noGraph?: boolean
triggerNode?: boolean
stepDetail?: FlowModule | string | undefined
workspace?: string | undefined
minHeight?: number
noBorder?: boolean
}
let {
@@ -40,7 +39,7 @@
workspace = $workspaceStore,
minHeight = 400,
noBorder = false
}: Props = $props();
}: Props = $props()
const dispatch = createEventDispatcher()
</script>
@@ -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')
@@ -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<string, WorkflowStatus> {
@@ -255,7 +257,7 @@
let retry_selected = $state('')
let timeout: number | undefined = undefined
let expandedSubflows: Record<string, FlowModule[]> = $state({})
let expandedSubflows: Record<string, { modules: FlowModule[]; groups?: any[] }> = $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<string, FlowModule[]>
expandedSubflows: Record<string, { modules: FlowModule[]; groups?: any[] }>
): 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}
<JobArgs
id={isReplay ? undefined : job.id}
workspace={isReplay ? undefined : (job.workspace_id ?? $workspaceStore ?? 'no_w')}
workspace={isReplay
? undefined
: (job.workspace_id ?? $workspaceStore ?? 'no_w')}
args={job.args}
/>
{:else}
@@ -2064,20 +2066,25 @@
<div class="text-xs text-emphasis font-semibold mb-1">Inputs</div>
<JobArgs
id={isReplay ? undefined : node.job_id}
workspace={isReplay ? undefined : (job.workspace_id ?? $workspaceStore ?? 'no_w')}
workspace={isReplay
? undefined
: (job.workspace_id ?? $workspaceStore ?? 'no_w')}
args={node.args}
/>
</div>
{/if}
{#if node.workflow_as_code_status}
<div>
<div class="text-xs text-emphasis font-semibold mb-1">Workflow timeline</div>
<div class="text-xs text-emphasis font-semibold mb-1"
>Workflow timeline</div
>
<WorkflowTimeline
flow_status={asWorkflowStatus(node.workflow_as_code_status)}
flowDone={node.type === 'Success' || node.type === 'Failure'}
stepResults={getStepResults(node.workflow_as_code_status)}
result={node.result}
success={node.type === 'Success'}
jobId={node.job_id}
/>
</div>
{/if}
@@ -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 @@
<div class="mt-2"></div>
{/if}
<div>
{#if isOwner || resumeUrl}
<div class={twMerge('flex gap-2', light ? 'flex-col' : 'flex-row ')}>
{#if !hide_cancel}
<div>
<Button
title="Cancel the step"
{loading}
iconOnly
startIcon={{ icon: X }}
variant="default"
disabled={!cancelUrl}
destructive
unifiedSize="md"
on:click={() => continu(false)}
/>
</div>
{/if}
<div class={twMerge('flex gap-2', light ? 'flex-col' : 'flex-row ')}>
{#if !hide_cancel}
<div>
<Button variant="accent" onClick={() => continu(true)} {loading} unifiedSize="md">
Resume
<Tooltip class="text-white">
Since you are an owner of this flow, you can send resume events without necessarily
knowing the resume id sent by the approval step
</Tooltip>
</Button>
<Button
title="Cancel the step"
iconOnly
startIcon={{ icon: X }}
variant="default"
disabled={loading}
destructive
unifiedSize="md"
on:click={() => continu(false)}
/>
</div>
{#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema}
<div
class={twMerge(
'w-full border rounded-lg p-2',
light ? 'min-w-96 max-h-svh overflow-y-auto' : ''
)}
>
<SchemaForm onlyMaskPassword bind:args={default_payload} {defaultValues} {schema} />
</div>
<Tooltip>
The payload is optional, it is passed to the following step through the `resume`
variable
</Tooltip>
{/if}
{/if}
<div>
<Button variant="accent" onClick={() => continu(true)} disabled={loading} unifiedSize="md">
Resume
<Tooltip class="text-white">Resume or approve this suspended step</Tooltip>
</Button>
</div>
{: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}
<div
class={twMerge(
'w-full border rounded-lg p-2',
light ? 'min-w-96 max-h-svh overflow-y-auto' : ''
)}
>
<SchemaForm onlyMaskPassword bind:args={default_payload} {defaultValues} {schema} />
</div>
<Tooltip>
The payload is optional, it is passed to the following step through the `resume` variable
</Tooltip>
{/if}
</div>
</div>
</div>
@@ -33,6 +33,7 @@
hidePath?: boolean
onChange?: (args: { path: string; args: Record<string, any>; description: string }) => void
defaultValues?: Record<string, any> | undefined
workspace?: string | undefined
}
let {
@@ -41,9 +42,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 +72,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 +96,12 @@
export async function editResource(): Promise<void> {
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 +112,7 @@
export async function createResource(): Promise<void> {
await ResourceService.createResource({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
requestBody: { path, value: args, description, resource_type: resource_type! }
})
sendUserToast(`Updated resource at ${path}`)
@@ -119,7 +123,7 @@
if (resource_type) {
try {
const resourceType = await ResourceService.getResourceType({
workspace: $workspaceStore!,
workspace: effectiveWorkspace,
path: resource_type
})
@@ -5,6 +5,11 @@
import { Loader2, Save } from 'lucide-svelte'
let {
workspace = undefined,
disableChatOffset = false
}: { workspace?: string; disableChatOffset?: boolean } = $props()
let drawer: Drawer | undefined = $state()
let canSave = $state(true)
let resource_type: string | undefined = $state(undefined)
@@ -34,7 +39,7 @@
let mode: 'edit' | 'new' = $derived(!path ? 'new' : 'edit')
</script>
<Drawer bind:this={drawer} size="800px">
<Drawer bind:this={drawer} size="800px" {disableChatOffset}>
<DrawerContent
title={mode == 'edit' ? 'Edit ' + path : 'Add a resource'}
on:close={drawer?.closeDrawer}
@@ -46,6 +51,7 @@
{path}
{resource_type}
{defaultValues}
{workspace}
on:refresh
bind:this={resourceEditor}
bind:canSave
@@ -29,6 +29,8 @@
onClear?: () => 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}
/>
<ResourceEditorDrawer
bind:this={resourceEditor}
{workspace}
{disableChatOffset}
on:refresh={async (e) => {
await loadResources(resourceType)
if (e.detail) {
@@ -146,6 +146,7 @@
bind:this={innerComponent}
closeDrawer={handleClose}
showHeaderInfo={false}
{disableChatOffset}
bind:yamlMode
bind:hasUnsavedChanges
bind:hasAnyInvalid
@@ -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)
@@ -234,7 +236,9 @@
<div class="flex-1 min-w-0 h-full">
<div class="h-full overflow-auto bg-surface">
<div class="h-fit px-8 py-4">
{#if tab === 'users' && !yamlMode}
{#if tab === 'ai' && !yamlMode}
<InstanceAISettings {disableChatOffset} />
{:else if tab === 'users' && !yamlMode}
<div class="h-full">
{#if !automateUsernameCreation && !isCloudHosted()}
<div class="mb-4">
@@ -1,6 +1,6 @@
<script lang="ts">
import { base } from '$lib/base'
import { displayDate, msToSec } from '$lib/utils'
import { displayDate, msToSec, emptyString } from '$lib/utils'
import { onDestroy } from 'svelte'
import { getDbClockNow } from '$lib/forLater'
import { ChevronDown, ChevronRight, Loader2, Moon, ShieldCheck } from 'lucide-svelte'
@@ -9,7 +9,11 @@
import ObjectViewer from './propertyPicker/ObjectViewer.svelte'
import { CheckCircle2, XCircle } from 'lucide-svelte'
import { JobService, type Job, type WorkflowStatus } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { Button } from '$lib/components/common'
import { Alert } from '$lib/components/common'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import { sendUserToast } from '$lib/toast'
interface Props {
flow_status: Record<string, WorkflowStatus>
@@ -18,6 +22,7 @@
result?: any
success?: boolean
autoExpandResult?: boolean
jobId?: string
}
let {
@@ -26,7 +31,8 @@
stepResults = {},
result = undefined,
success = true,
autoExpandResult = false
autoExpandResult = false,
jobId = undefined
}: Props = $props()
let resultExpanded = $state(false)
@@ -114,6 +120,49 @@
}
}
}, 2000)
// Approval action state
let approvalLoading: Record<string, boolean> = $state({})
let approvalFormArgs: Record<string, Record<string, any>> = $state({})
async function handleApprove(key: string, formSchema: any) {
const ws = $workspaceStore
if (!ws || !jobId) return
approvalLoading[key] = true
try {
const payload =
formSchema && Object.keys(formSchema).length > 0 ? (approvalFormArgs[key] ?? {}) : undefined
await JobService.resumeSuspended({
workspace: ws,
jobId: jobId,
requestBody: { payload, approved: true }
})
sendUserToast('Approval submitted')
} catch (e: any) {
sendUserToast(e?.body ?? e?.message ?? 'Failed to approve', true)
} finally {
approvalLoading[key] = false
}
}
let cancelLoading = $state(false)
async function handleCancel() {
const ws = $workspaceStore
if (!ws || !jobId) return
cancelLoading = true
try {
await JobService.resumeSuspended({
workspace: ws,
jobId: jobId,
requestBody: { approved: false }
})
sendUserToast('Job cancelled')
} catch (e: any) {
sendUserToast(e?.body ?? e?.message ?? 'Failed to cancel', true)
} finally {
cancelLoading = false
}
}
</script>
{#if flow_status}
@@ -167,22 +216,74 @@
<span class="italic">sleep ({(v as any).sleep_duration_s}s)</span>
</div>
{:else if isApproval}
<div class="w-full px-2 py-1.5 text-xs flex items-center gap-2">
<div class="w-3 flex-shrink-0"></div>
<ShieldCheck
size={12}
class="flex-shrink-0 {isDone ? 'text-green-600' : 'text-yellow-500'}"
/>
<span class="italic text-secondary">
{v.name ?? stepKey(k)}
</span>
{#if !isDone}
<span class="text-tertiary flex items-center gap-1">
<Loader2 size={12} class="animate-spin" />
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}
<div class="w-full px-2 py-1.5 text-xs">
<div class="flex items-center gap-2">
<div class="w-3 flex-shrink-0"></div>
<ShieldCheck
size={12}
class="flex-shrink-0 {isDone ? 'text-green-600' : 'text-yellow-500'}"
/>
<span class="italic text-secondary">
{v.name ?? stepKey(k)}
</span>
{:else}
<span class="text-tertiary">{msToSec(v.duration_ms ?? 0)}s</span>
{#if !isDone}
<span class="text-tertiary flex items-center gap-1">
<Loader2 size={12} class="animate-spin" />
waiting
</span>
{#if canApprove}
<div class="ml-auto flex gap-1">
<Button
variant="default"
unifiedSize="sm"
disabled={approvalLoading[k]}
onclick={() => handleApprove(k, formSchema)}
>
Approve
</Button>
<Button
variant="default"
unifiedSize="sm"
disabled={approvalLoading[k] || cancelLoading}
onclick={() => handleCancel()}
>
Reject
</Button>
</div>
{/if}
{:else}
<span class="text-tertiary">{msToSec(v.duration_ms ?? 0)}s</span>
{/if}
</div>
{#if canApprove && selfApprovalDisabled && $userStore?.is_admin}
<div class="mt-1 ml-5 text-yellow-600 text-2xs">
Self-approval is disabled but allowed because you are an admin/owner
</div>
{/if}
{#if canApprove && hasForm}
<div class="mt-2 ml-5 max-w-md">
{#if emptyString($enterpriseLicense)}
<Alert
type="warning"
title="Adding a form to the approval page is an EE feature"
/>
{:else}
<SchemaForm
onlyMaskPassword
noVariablePicker
schema={{
properties: formSchema,
order: Object.keys(formSchema)
}}
bind:args={approvalFormArgs[k]}
/>
{/if}
</div>
{/if}
</div>
{:else}
@@ -275,13 +376,13 @@
{@const result = stepResults[stepKey(k)]}
{#if isDone && result !== undefined}
<div>
<div class="text-2xs text-secondary font-semibold mb-1">Result</div>
<div class="text-2xs text-secondary font-semibold mb-1"> Result </div>
<div class="max-h-40 overflow-auto">
<ObjectViewer json={result} pureViewer />
</div>
</div>
{:else}
<div class="text-xs text-secondary py-1">Step completed (no result)</div>
<div class="text-xs text-secondary py-1"> Step completed (no result) </div>
{/if}
{:else if loadingJobs[k] && !childJobs[k]}
<div class="flex items-center gap-2 text-xs text-secondary py-1">
@@ -293,7 +394,7 @@
<!-- Logs -->
{#if job.logs || isRunning}
<div class="mb-2">
<div class="text-2xs text-secondary font-semibold mb-1">Logs</div>
<div class="text-2xs text-secondary font-semibold mb-1"> Logs </div>
<LogViewer
content={job.logs ?? ''}
jobId={k}
@@ -309,7 +410,7 @@
<!-- Result -->
{#if isDone && job.result !== undefined}
<div>
<div class="text-2xs text-secondary font-semibold mb-1">Result</div>
<div class="text-2xs text-secondary font-semibold mb-1"> Result </div>
<div class="max-h-40 overflow-auto">
<ObjectViewer json={job.result} pureViewer />
</div>
@@ -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: [
{
@@ -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
@@ -354,6 +358,7 @@ export async function getNonStreamingOpenAIResponsesCompletion(
abortController: AbortController,
testOptions?: {
apiKey?: string
workspace?: string
resourcePath?: string
forceModelProvider: AIProviderModel
}
@@ -390,15 +395,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(
{
+54 -39
View File
@@ -67,7 +67,14 @@ export const AI_PROVIDERS: Record<AIProvider, AIProviderDetails> = {
},
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',
@@ -364,38 +371,46 @@ export const PROVIDER_COMPLETION_CONFIG_MAP: Record<AIProvider, ChatCompletionCr
aws_bedrock: DEFAULT_COMPLETION_CONFIG
} as const
export function getAiProxyBaseURL(workspace?: string): string {
return workspace
? `${location.origin}${OpenAPI.BASE}/w/${workspace}/ai/proxy`
: `${location.origin}${OpenAPI.BASE}/ai/proxy`
}
export function createOpenAIProxyClient(baseURL: string): OpenAI {
return new OpenAI({
baseURL,
apiKey: 'fake-key',
defaultHeaders: {
Authorization: '' // a non empty string will be unable to access Windmill backend proxy
},
dangerouslyAllowBrowser: true
})
}
export function createAnthropicProxyClient(baseURL: string): Anthropic {
return new Anthropic({
baseURL,
apiKey: 'fake-key',
dangerouslyAllowBrowser: true
})
}
class WorkspacedAIClients {
private openaiClient: OpenAI | undefined
private anthropicClient: Anthropic | undefined
init(workspace: string) {
this.initOpenai(workspace)
this.initAnthropic(workspace)
this.openaiClient = this.createOpenaiClient(workspace)
this.anthropicClient = this.createAnthropicClient(workspace)
}
private getBaseURL(workspace: string) {
return `${location.origin}${OpenAPI.BASE}/w/${workspace}/ai/proxy`
createOpenaiClient(workspace: string): OpenAI {
return createOpenAIProxyClient(getAiProxyBaseURL(workspace))
}
private initOpenai(workspace: string) {
const baseURL = this.getBaseURL(workspace)
this.openaiClient = new OpenAI({
baseURL,
apiKey: 'fake-key',
defaultHeaders: {
Authorization: '' // a non empty string will be unable to access Windmill backend proxy
},
dangerouslyAllowBrowser: true
})
}
private initAnthropic(workspace: string) {
const baseURL = this.getBaseURL(workspace)
this.anthropicClient = new Anthropic({
baseURL,
apiKey: 'fake-key',
dangerouslyAllowBrowser: true
})
createAnthropicClient(workspace: string): Anthropic {
return createAnthropicProxyClient(getAiProxyBaseURL(workspace))
}
getOpenaiClient() {
@@ -417,6 +432,7 @@ export const workspaceAIClients = new WorkspacedAIClients()
export async function testKey({
apiKey,
workspace,
resourcePath,
model,
abortController,
@@ -424,6 +440,7 @@ export async function testKey({
aiProvider
}: {
apiKey?: string
workspace?: string
resourcePath?: string
model: string | undefined
messages: ChatCompletionMessageParam[]
@@ -443,6 +460,7 @@ export async function testKey({
if (aiProvider === 'anthropic') {
await testAnthropicKey({
apiKey,
workspace,
resourcePath,
model: modelToTest,
abortController,
@@ -453,6 +471,7 @@ export async function testKey({
await getNonStreamingCompletion(messages, abortController, {
apiKey,
workspace,
resourcePath,
forceModelProvider: {
model: modelToTest,
@@ -463,12 +482,14 @@ export async function testKey({
async function testAnthropicKey({
apiKey,
workspace,
resourcePath,
model,
abortController,
messages
}: {
apiKey?: string
workspace?: string
resourcePath?: string
model: string
abortController: AbortController
@@ -489,12 +510,10 @@ async function testAnthropicKey({
}
const anthropicClient = apiKey
? new Anthropic({
baseURL: `${location.origin}${OpenAPI.BASE}/ai/proxy`,
apiKey: 'fake-key',
dangerouslyAllowBrowser: true
})
: workspaceAIClients.getAnthropicClient()
? createAnthropicProxyClient(getAiProxyBaseURL())
: workspace
? workspaceAIClients.createAnthropicClient(workspace)
: workspaceAIClients.getAnthropicClient()
await anthropicClient.messages.create(
{
@@ -719,6 +738,7 @@ export async function getNonStreamingCompletion(
testOptions?: {
apiKey?: string // testing API KEY using the global ai proxy
resourcePath?: string // testing resource path passed as a header to the backend proxy
workspace?: string // use a specific workspace proxy when testing a workspace resource
forceModelProvider: AIProviderModel
}
) {
@@ -768,15 +788,10 @@ export async function getNonStreamingCompletion(
}
}
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 completion = await openaiClient.chat.completions.create(config, fetchOptions)
response = completion.choices?.[0]?.message.content || ''
@@ -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}
/>
@@ -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()}
<div class="flex gap-1 items-center">
<Button
onClick={addGroupNote}
disabled={!noteEditorContext?.noteEditor || selectionManager.selectedIds.length === 0}
startIcon={{ icon: StickyNote }}
onClick={() => onCreateGroup?.()}
disabled={!canCreateGroup}
startIcon={{ icon: Group }}
>
Create group note
Create group
</Button>
{#if resolvedCount > 0}
<DropdownV2 items={menuItems} />
@@ -39,27 +39,9 @@
render a cancel button, providing the operator with an option to cancel the step. e.g:
<Tabs selected="bun" class="pt-4">
<Tab value="bun" label="TypeScript (Bun)" />
<Tab value="deno" label="TypeScript (Deno)" />
<Tab value="python" label="Python" />
{#snippet content()}
<TabContent value="deno" class="p-2">
<HighlightCode
language={'deno'}
code={`import * as wmill from "npm:windmill-client@^1.158.2"
export async function main() {
const urls = await wmill.getResumeUrls("approver1")
return {
resume: urls['resume'],
cancel: urls['cancel'],
default_args: {}, // optional, see below
enums: {} // optional, see below
}
}`}
/>
</TabContent>
<TabContent value="bun" class="p-2">
<HighlightCode
language={'deno'}
@@ -31,9 +31,22 @@
editor?.setCode(code)
}
function validateGroups(groups: { start_id: string; end_id: string }[] | undefined) {
if (!groups) return
const seen = new Set<string>()
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 on:open={reload} bind:this={drawer} size="800px">
<DrawerContent title="OpenFlow" on:close={() => drawer?.toggleDrawer()}>
{#snippet actions()}
<Button variant="default" unifiedSize="md" disabled={!hasChanges} on:click={reload}>Reset code</Button>
<Button variant="accent" unifiedSize="md" disabled={!hasChanges} on:click={apply}>Apply changes</Button>
<Button variant="default" unifiedSize="md" disabled={!hasChanges} on:click={reload}
>Reset code</Button
>
<Button variant="accent" unifiedSize="md" disabled={!hasChanges} on:click={apply}
>Apply changes</Button
>
{/snippet}
{#if flowStore.val}
@@ -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
)}
<Drawer bind:open={editId}>
<DrawerContent title="Edit Step Id {id}" on:close={() => (editId = false)}>
<DrawerContent title="Edit step id {id}" on:close={() => (editId = false)}>
<div>
<IdEditorInput
buttonText="Edit Id "
@@ -285,7 +285,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={classNames(
'w-full module flex rounded-md cursor-pointer max-w-full drop-shadow-base',
'w-full module flex rounded-md cursor-pointer max-w-full drop-shadow-sm',
colorClasses.bg
)}
style="width: 275px; height: 34px;"
@@ -434,11 +434,9 @@
{label}
{path}
{id}
{deletable}
{bold}
bind:editId
disableEditId={isMultiSelected}
{hover}
{colorClasses}
>
{#snippet icon()}
@@ -484,11 +482,10 @@
{/if}
</div>
{#if deletable && !isDragging}
{#if deletable && !isMoving}
{#if maximizeSubflow !== undefined}
{@render buttonMaximizeSubflow?.()}
{/if}
{#if (id && Object.values($flowInputsStore?.[id]?.flowStepWarnings || {}).length > 0) || Boolean(warningMessage)}
<Popover
style="will-change: transform;"
@@ -569,7 +566,7 @@
{/if}
</div>
{#if editMode && enableTestRun && flowJob?.type !== 'QueuedJob' && !isDragging}
{#if editMode && enableTestRun && flowJob?.type !== 'QueuedJob' && !isMoving}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute top-1/2 -translate-y-1/2 -translate-x-[100%] -left-[0] flex items-center w-fit px-1 h-9 min-w-9"
@@ -577,7 +574,7 @@
onmouseleave={() => (hover = false)}
>
{#if !isMultiSelected && (hover || selected || testRunDropdownOpen) && outputPickerVisible}
<div transition:fade={{ duration: 100 }}>
<div class="bg-surface rounded-md" transition:fade={{ duration: 100 }}>
{#if !testIsLoading}
<Button
size="xs"
@@ -2,8 +2,6 @@
import Popover from '$lib/components/Popover.svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
import type { FlowNodeColorClasses } from '$lib/components/graph'
import { Pencil } from 'lucide-svelte'
import { slide } from 'svelte/transition'
import { twMerge } from 'tailwind-merge'
let iconWidth: number = $state(0)
@@ -12,11 +10,9 @@
label?: string
path?: string
id?: string
deletable?: boolean
bold?: boolean
editId?: boolean
disableEditId?: boolean
hover?: boolean
colorClasses?: FlowNodeColorClasses
icon?: import('svelte').Snippet
onclick?: () => void
@@ -26,11 +22,9 @@
label = '',
path = '',
id = '',
deletable = false,
bold = false,
editId = $bindable(false),
disableEditId = false,
hover = false,
colorClasses,
icon,
onclick
@@ -87,11 +81,6 @@
}}
>
<span class="max-w-full text-2xs truncate flex items-center">
{#if !disableEditId && (editId || (hover && deletable))}
<span transition:slide={{ axis: 'x', duration: 100 }}>
<Pencil size={10} class="mr-1" />
</span>
{/if}
<span class="max-w-12 truncate">
{id}
</span>
@@ -23,7 +23,7 @@
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import Portal from '$lib/components/Portal.svelte'
import { getDependentComponents } from '../flowExplorer'
import { getAllModules, getDependentComponents } from '../flowExplorer'
import { locateModules, groupByParent } from '../multiSelectUtils'
import { workspaceStore } from '$lib/stores'
import { copilotInfo } from '$lib/aiStore'
@@ -54,6 +54,18 @@
} from '../agentToolUtils'
import { loadFlowModuleState } from '../flowStateUtils.svelte'
import { getNoteEditorContext } from '$lib/components/graph/noteEditor.svelte'
import {
GroupedModulesProxy,
type ExtendedOpenFlow
} from '$lib/components/graph/groupedModulesProxy.svelte'
import { GroupDisplayState } from '$lib/components/graph/groupEditor.svelte'
import {
type FlowStructureNode,
matchStructureNode,
dfsStructure,
findInStructure,
moduleToStructureNode
} from '$lib/components/graph/flowStructure'
interface Props {
sidebarSize?: number | undefined
@@ -125,6 +137,8 @@
// Get NoteEditor context for note position updates
const noteEditorContext = getNoteEditorContext()
const proxy = new GroupedModulesProxy(flowStore as unknown as StateStore<ExtendedOpenFlow>)
const groupDisplayState = new GroupDisplayState(() => flowStore.val.value?.groups ?? [])
$effect(() => {
if (!moveManager.movingModuleId) return
@@ -139,16 +153,13 @@
return () => document.removeEventListener('keydown', onKeyDown, true)
})
export async function insertNewModuleAtIndex(
modules: FlowModule[] | AgentTool[],
index: number,
/** Create a new FlowModule without inserting it into any array */
async function createNewModule(
kind: InsertKind,
wsScript?: { path: string; summary: string; hash: string | undefined },
wsFlow?: { path: string; summary: string },
inlineScript?: InlineScript,
toolKind?: SpecialToolKind | 'flowmoduleTool'
): Promise<FlowModule[] | AgentTool[]> {
push(history, flowStore.val)
inlineScript?: InlineScript
): Promise<FlowModule> {
let module = emptyModule(flowStateStore.val, flowStore.val, kind == 'flow')
let state = emptyFlowModuleState()
flowStateStore.val[module.id] = state
@@ -196,6 +207,21 @@
module.stop_after_if = { skip_if_stopped: false, expr: 'true' }
}
return module
}
export async function insertNewModuleAtIndex(
modules: FlowModule[] | AgentTool[],
index: number,
kind: InsertKind,
wsScript?: { path: string; summary: string; hash: string | undefined },
wsFlow?: { path: string; summary: string },
inlineScript?: InlineScript,
toolKind?: SpecialToolKind | 'flowmoduleTool'
): Promise<FlowModule[] | AgentTool[]> {
push(history, flowStore.val)
const module = await createNewModule(kind, wsScript, wsFlow, inlineScript)
if (!modules) return [module]
if (toolKind === 'mcpTool') {
@@ -220,7 +246,7 @@
;(modules as AgentTool[]).splice(index, 0, agentTool)
return modules as AgentTool[]
} else {
// Standard FlowModule insertion (existing behavior)
// Standard FlowModule insertion
modules.splice(index, 0, module)
return modules
}
@@ -330,6 +356,13 @@
let deleteCallback: (() => void) | undefined = $state(undefined)
let dependents: Record<string, string[]> = $state({})
/** Confirmation gate for actions that would empty or duplicate groups */
let affectedGroupsPending: import('$lib/components/graph/groupEditor.svelte').FlowGroup[] =
$state([])
let affectedGroupsAction: (() => void) | undefined = $state(undefined)
let affectedGroupsCancel: (() => void) | undefined = $state(undefined)
let affectedGroupsActionLabel: 'delete' | 'move' = $state('delete')
let graph: FlowGraphV2 | undefined = $state(undefined)
let noteMode = $state(false)
let diffManager = $derived(getDiffManager())
@@ -361,24 +394,46 @@
}
}
const opts = { displayState: groupDisplayState }
const { emptiedGroups, duplicateGroups, commit } = proxy.prepareMutation((tree) => {
for (const id of ids) {
const found = findInStructure(tree, id)
if (found) found.parentChildren.splice(found.index, 1)
}
}, opts)
const affectedGroups = [...emptiedGroups, ...duplicateGroups]
const cb = () => {
push(history, flowStore.val)
commit({ removeDuplicates: duplicateGroups.length > 0 })
for (const id of ids) {
removeAtId(flowStore.val.value.modules, id)
delete flowStateStore.val[id]
}
selectionManager.clearSelection()
refreshStateStore(flowStore)
}
if (Object.keys(allDeps).length > 0) {
dependents = allDeps
deleteCallback = cb
const proceed = () => {
if (Object.keys(allDeps).length > 0) {
dependents = allDeps
deleteCallback = cb
} else {
cb()
}
}
if (affectedGroups.length > 0) {
affectedGroupsPending = affectedGroups
affectedGroupsActionLabel = 'delete'
affectedGroupsAction = proceed
} else {
cb()
proceed()
}
}
// Operates directly on the flat module array (not the structure tree).
// Cloned modules are inserted after the originals, intentionally outside any group.
export function duplicateMultiple(ids: string[]) {
const locations = locateModules(ids, flowStore.val.value.modules)
const groups = groupByParent(locations)
@@ -423,6 +478,10 @@
moveManager.toggleMovingMultiple(ids)
}
export function createGroup(ids: string[]) {
graph?.createGroupFromSelection(ids)
}
const dispatch = createEventDispatcher<{
generateStep: { moduleId: string; instructions: string; lang: ScriptLang }
change: void
@@ -506,6 +565,40 @@
</div>
{/each}
</ConfirmationModal>
<ConfirmationModal
title={affectedGroupsPending.length === 1 ? 'Remove group?' : 'Remove groups?'}
confirmationText={affectedGroupsActionLabel === 'delete' ? 'Delete step' : 'Move step'}
open={affectedGroupsPending.length > 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]}
<p
>The group{group.summary ? ` "${group.summary}"` : ''} will be removed (empty or duplicate).
Are you sure you want to {affectedGroupsActionLabel} the step?</p
>
{:else}
<p>The following groups will be removed (empty or duplicate):</p>
<ul class="list-disc pl-4 mt-1">
{#each affectedGroupsPending as group}
<li>{group.summary || `${group.start_id} ${group.end_id}`}</li>
{/each}
</ul>
<p class="mt-2">Are you sure you want to {affectedGroupsActionLabel} the step?</p>
{/if}
</ConfirmationModal>
</Portal>
<div class="flex flex-col h-full relative -pt-1" bind:clientWidth={flowPaneWidth}>
<div
@@ -542,8 +635,12 @@
{moveManager}
maxHeight={minHeight}
modules={flowStore.val.value.modules}
groupedModules={proxy.items}
groupError={proxy.error}
{groupDisplayState}
{noteMode}
notes={flowStore.val.value.notes}
groups={flowStore.val.value.groups}
preprocessorModule={flowStore.val.value?.preprocessor_module}
failureModule={flowStore.val.value?.failure_module}
currentInputSchema={flowStore.val.schema}
@@ -563,159 +660,274 @@
chatInputEnabled={Boolean(flowStore.val.value?.chat_input_enabled)}
onDelete={(id) => {
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) {
@@ -761,6 +973,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)
@@ -46,7 +46,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={classNames(
'w-full flex relative rounded-md drop-shadow-base',
'w-full flex relative rounded-md drop-shadow-sm',
colorClasses.bg,
onTop ? 'z-[901]' : '',
className
@@ -170,7 +170,9 @@ export async function saveScheduleFromCfg(
tag: scheduleCfg.tag,
paused_until: scheduleCfg.paused_until,
cron_version: scheduleCfg.cron_version,
dynamic_skip: scheduleCfg.dynamic_skip
dynamic_skip: scheduleCfg.dynamic_skip,
permissioned_as: scheduleCfg.permissioned_as,
preserve_permissioned_as: scheduleCfg.preserve_permissioned_as
}
try {
if (edit) {
@@ -72,11 +72,17 @@ export function evalValue(
return v
}
/** Ensure modules comes first and groups last in the value object for readable YAML export. */
function reorderFlowValue(value: ExtendedOpenFlow['value']): ExtendedOpenFlow['value'] {
const { modules, groups, ...rest } = value
return { modules, ...rest, ...(groups != null ? { groups } : {}) }
}
export function filteredContentForExport(flow: ExtendedOpenFlow) {
let o = {
summary: flow.summary,
description: flow.description,
value: flow.value,
value: reorderFlowValue(flow.value),
schema: flow.schema
}
if (flow.dedicated_worker) {
@@ -42,7 +42,12 @@
return { x: n.position.x, y: n.position.y }
}
function computeGhost(moduleId: string, draggedNodeIds: Set<string>, allNodes: Node[], allEdges: Edge[]) {
function computeGhost(
moduleId: string,
draggedNodeIds: Set<string>,
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;"
>
<Move size={12} />
</div>
@@ -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<string, FlowModule[]>
expandedSubflows?: Record<string, { modules: FlowModule[]; groups?: FlowGroup[] }>
isOwner?: boolean
isRunning?: boolean
individualStepTests?: boolean
@@ -133,6 +150,8 @@
suspendStatus?: Record<string, { job: Job; nb: number }>
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<void>
onNewBranch?: (id: string) => Promise<void>
onSelect?: (id: string | FlowModule) => void
@@ -193,6 +213,8 @@
onSelectedIteration = undefined,
success = undefined,
modules = [],
groupedModules: groupedModulesProp = undefined,
groupError = undefined,
failureModule = undefined,
preprocessorModule = undefined,
minHeight = 0,
@@ -232,6 +254,8 @@
flowHasChanged = false,
noteMode = false,
notes = undefined,
groups = undefined,
groupDisplayState: groupDisplayStateProp = undefined,
exitNoteMode = undefined,
onNotePositionUpdate = undefined,
chatInputEnabled = false,
@@ -257,6 +281,9 @@
() => nodes
)
const groupDisplayState =
untrack(() => groupDisplayStateProp) ?? new GroupDisplayState(() => groups ?? [])
// Runtime text height tracking for notes (not stored in FlowNote)
let noteTextHeights = $state<Record<string, number>>({})
@@ -264,6 +291,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())
@@ -298,7 +327,9 @@
moveManager: untrack(() => moveManager),
clearFlowSelection,
yOffset,
diffManager
diffManager,
getFlowNodes: () => currentGraphNodeDeps,
groupDisplayState
} as any)
if (triggerContext && untrack(() => allowSimplifiedPoll)) {
@@ -332,14 +363,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<string, { top: number; bottom: number }> | 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<string, { width: number; height: number }> | undefined = undefined
function layoutNodes(
nodes: NodeDep[],
nodeExtraSpace?: Map<string, { top: number; bottom: number; left: number; right: number }>
): (NodeDep & NodePos)[] {
let lastResult = lastNodes?.[2]
if (
lastResult &&
deepEqual(nodes, lastNodes?.[0]) &&
deepEqual(nodeExtraSpace, lastNodes?.[1])
) {
console.debug('layoutNodes', 'same nodes')
return lastResult
}
@@ -352,16 +405,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: {
@@ -370,7 +430,7 @@
}
}))
lastNodes = [nodes, newNodes]
lastNodes = [nodes, nodeExtraSpace, newNodes]
return newNodes
}
@@ -414,13 +474,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)
},
@@ -585,17 +648,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) => ({
@@ -605,25 +688,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) => ({
@@ -644,14 +719,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 ?? [])]
@@ -699,7 +766,10 @@
assetsOverflowed: AssetsOverflowedNode,
aiTool: AiToolNode,
newAiTool: NewAiToolNode,
note: NoteNode
note: NoteNode,
collapsedGroup: CollapsedGroupNode,
groupHead: GroupHeadNode,
groupEnd: GroupEndNode
} as any
const edgeTypes = {
@@ -735,7 +805,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,
@@ -767,16 +871,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()
})
@@ -893,6 +1024,16 @@
}
}
export function createGroupFromSelection(ids: string[]) {
if (groupEditorCtx?.groupEditor) {
groupEditorCtx.groupEditor.createGroup(ids, currentGraphNodeDeps)
tick().then(() => {
clearFlowSelection()
selectionManager.clearSelection()
})
}
}
const modifierKey = isMac() ? 'Meta' : 'Control'
</script>
@@ -909,7 +1050,7 @@
bind:this={flowContainer}
>
{#if graph?.error}
<div class="center-center p-2">
<div class="center-center p-2 mt-20">
<Alert title="Error parsing the flow" type="error" class="max-w-1/2">
{graph.error}
@@ -1008,6 +1149,12 @@
/>
{/if}
<GroupOverlay
allNodes={nodesWithOffset as (Node & { type: string })[]}
groups={allGroups}
groupDepths={currentGroupDepths}
/>
<!-- SelectionTool for handling selection changes and filtering -->
<SelectionTool {selectionManager} clearGraphSelection={clearFlowSelection} />
@@ -1065,7 +1212,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)
@@ -0,0 +1,158 @@
<script lang="ts">
import { preventDefault, stopPropagation } from 'svelte/legacy'
import { EllipsisVertical, StickyNote, Ungroup } from 'lucide-svelte'
import { NoteColor, NOTE_COLOR_SWATCHES } from './noteColors'
import Toggle from '../Toggle.svelte'
import DropdownV2 from '../DropdownV2.svelte'
import { twMerge } from 'tailwind-merge'
import MoveHandleButton from './MoveHandleButton.svelte'
import type { MoveManager } from './moveManager.svelte'
interface Props {
note: string | undefined | null
color: string | undefined
autocollapse: boolean
visible?: boolean
menuOpen?: boolean
moveManager?: MoveManager
moveModuleId?: string
onMenuOpenChange?: (open: boolean) => void
onAddNote: () => void
onRemoveNote: () => void
onUpdateColor: (color: NoteColor) => void
onUpdateAutocollapse: (value: boolean) => void
onDeleteGroup?: () => void
}
let {
note,
color,
autocollapse,
visible = true,
menuOpen = $bindable(),
moveManager,
moveModuleId,
onMenuOpenChange,
onAddNote,
onRemoveNote,
onUpdateColor,
onUpdateAutocollapse,
onDeleteGroup = undefined
}: Props = $props()
$effect(() => {
onMenuOpenChange?.(menuOpen ?? false)
})
</script>
<div
class="absolute -translate-y-[100%] top-2 right-0 h-7 p-1 min-w-7 flex flex-row gap-2"
style="will-change: transform;"
>
{#if moveManager && moveModuleId}
<MoveHandleButton
{moveManager}
moduleId={moveModuleId}
singleNode
{visible}
onClickMove={() => moveManager.toggleMoving(moveModuleId!)}
/>
{/if}
{#if note == null}
<button
class={twMerge(
'center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary',
visible ? 'block' : '!hidden',
'shadow-md rounded-md'
)}
onpointerdown={stopPropagation(preventDefault(() => {}))}
onclick={() => onAddNote()}
title="Add note"
>
<StickyNote size={12} />
</button>
{/if}
<DropdownV2
placement="bottom-end"
bind:open={menuOpen}
fixedHeight={false}
usePointerDownOutside
customMenu
>
{#snippet buttonReplacement()}
<button
class={twMerge(
'center-center p-1 text-secondary shadow-sm bg-surface duration-0 hover:bg-surface-tertiary',
visible || menuOpen ? 'block' : '!hidden',
'shadow-md rounded-md'
)}
onpointerdown={stopPropagation(preventDefault(() => {}))}
title="Actions"
>
<EllipsisVertical size={12} />
</button>
{/snippet}
{#snippet menu()}
<div
class="bg-surface-tertiary dark:border w-56 origin-top-right rounded-lg shadow-lg focus:outline-none py-1"
>
<!-- Color picker -->
<div class="px-4 py-2">
<div class="grid grid-cols-5 gap-1">
{#each Object.values(NoteColor) as c (c)}
<button
class="w-6 h-6 rounded-full hover:scale-110 transition-transform duration-100
{NOTE_COLOR_SWATCHES[c]}
{(color ?? NoteColor.BLUE) === c ? 'ring-2 ring-accent' : 'dark:border-gray-600'}"
onclick={() => onUpdateColor(c)}
title={c.charAt(0).toUpperCase() + c.slice(1)}
></button>
{/each}
</div>
</div>
<!-- Autocollapse toggle -->
<div class="px-4 py-2">
<Toggle
size="xs"
checked={autocollapse}
options={{ right: 'Autocollapse' }}
on:change={(e) => onUpdateAutocollapse(e.detail)}
/>
</div>
<div class="my-1 border-t border-border-light"></div>
<!-- Add / Remove note -->
<button
class="px-4 py-2 text-primary font-normal hover:bg-surface-hover cursor-pointer text-xs w-full flex flex-row gap-2 items-center rounded-sm"
onclick={() => {
note == null ? onAddNote() : onRemoveNote()
menuOpen = false
}}
>
<StickyNote size={14} class="shrink-0" />
<p class="truncate grow min-w-0 whitespace-nowrap text-left"
>{note == null ? 'Add note' : 'Remove note'}</p
>
</button>
{#if onDeleteGroup}
<div class="my-1 border-t border-border-light"></div>
<!-- Ungroup -->
<button
class="px-4 py-2 font-normal hover:bg-red-500/10 cursor-pointer text-xs w-full flex flex-row gap-2 items-center rounded-sm text-red-600 dark:text-red-400"
onclick={() => {
onDeleteGroup?.()
menuOpen = false
}}
>
<Ungroup size={14} class="shrink-0" />
<p class="truncate grow min-w-0 whitespace-nowrap text-left">Ungroup</p>
</button>
{/if}
</div>
{/snippet}
</DropdownV2>
</div>
@@ -0,0 +1,116 @@
<script lang="ts">
import { NOTE_COLORS, NoteColor } from './noteColors'
import { stopPropagation, preventDefault } from 'svelte/legacy'
import { ChevronRight } from 'lucide-svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
interface Props {
summary?: string
color?: string
collapsed: boolean
editMode: boolean
onToggleCollapse: () => void
onSummaryUpdate?: (text: string) => void
}
let { summary, color, collapsed, editMode, onToggleCollapse, onSummaryUpdate }: Props = $props()
let colorConfig = $derived(
NOTE_COLORS[(color as NoteColor) ?? NoteColor.BLUE] ?? NOTE_COLORS[NoteColor.BLUE]
)
const PLACEHOLDER = 'Group'
// Inline summary editing
let editingSummary = $state(false)
let summaryInput = $state('')
let textInputComponent: TextInput | undefined = $state(undefined)
function startEditingSummary() {
if (!editMode) return
editingSummary = true
summaryInput = summary ?? ''
requestAnimationFrame(() => {
textInputComponent?.focus()
textInputComponent?.select()
})
}
function saveSummary() {
editingSummary = false
const trimmed = summaryInput.trim()
if (trimmed !== (summary ?? '')) {
onSummaryUpdate?.(trimmed)
}
}
function handleSummaryKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
saveSummary()
} else if (event.key === 'Escape') {
editingSummary = false
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="flex items-center h-[22px] w-full px-2 relative cursor-pointer {colorConfig.background} {colorConfig.text} {collapsed
? 'rounded-t-md'
: 'rounded-md'}"
onclick={stopPropagation(preventDefault(onToggleCollapse))}
onpointerdown={stopPropagation(preventDefault(() => {}))}
title={collapsed ? 'Expand group' : 'Collapse group'}
>
<div
class="flex items-center justify-center shrink-0 opacity-60 transition-transform duration-100"
class:rotate-90={!collapsed}
>
<ChevronRight size={12} />
</div>
<div class="absolute inset-x-0 flex items-center justify-center h-full pointer-events-none px-7">
{#if editingSummary}
<div
class="input-sizer inline-grid items-center pointer-events-auto text-2xs font-medium max-w-full"
data-value={summaryInput || PLACEHOLDER}
>
<TextInput
bind:this={textInputComponent}
bind:value={summaryInput}
size="xs"
class="!bg-transparent !border-transparent !shadow-none !text-2xs !font-medium !p-0 !m-0 !min-w-0 text-center !min-h-0 !h-auto nodrag nowheel"
inputProps={{
placeholder: PLACEHOLDER,
onblur: saveSummary,
onkeydown: handleSummaryKeydown,
spellcheck: false,
size: 1,
style: 'padding: 2px !important; grid-area: 1 / 1'
}}
/>
</div>
{:else}
<span
class="text-2xs font-medium truncate text-center pointer-events-auto {editMode
? 'cursor-text rounded px-0.5 -mx-0.5 hover:bg-black/10 dark:hover:bg-white/10'
: ''}"
onclick={editMode ? stopPropagation(preventDefault(startEditingSummary)) : undefined}
onpointerdown={editMode ? stopPropagation(preventDefault(() => {})) : undefined}
>{summary || PLACEHOLDER}</span
>
{/if}
</div>
</div>
<style>
.input-sizer::after {
content: attr(data-value) ' ';
visibility: hidden;
white-space: pre;
grid-area: 1 / 1;
font: inherit;
padding: 2px;
text-align: center;
}
</style>
@@ -0,0 +1,76 @@
<script lang="ts">
import GroupHeader from './GroupHeader.svelte'
import GroupNoteArea from './GroupNoteArea.svelte'
import GroupActionBar from './GroupActionBar.svelte'
import { getGroupEditorContext } from './groupEditor.svelte'
import { getGraphContext } from './graphContext'
interface Props {
groupId: string
summary?: string
note?: string | null
color?: string
collapsed: boolean
autocollapse: boolean
editMode: boolean
showNotes: boolean
}
let { groupId, summary, note, color, collapsed, autocollapse, editMode, showNotes }: Props =
$props()
const groupEditorContext = getGroupEditorContext()
const graphContext = getGraphContext()
const moveManager = graphContext?.moveManager
let moveModuleId = $derived(collapsed ? `collapsed-group:${groupId}` : `group:${groupId}`)
let hovered = $state(false)
let menuOpen = $state(false)
let actionBarHovered = $state(false)
let visible = $derived(hovered || menuOpen || actionBarHovered)
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="nodrag relative"
onmouseenter={() => (hovered = true)}
onmouseleave={() => (hovered = false)}
>
<GroupHeader
{summary}
{color}
{collapsed}
{editMode}
onToggleCollapse={() => graphContext?.groupDisplayState?.toggleRuntimeCollapse(groupId)}
onSummaryUpdate={(text) => groupEditorContext?.groupEditor.updateSummary(groupId, text)}
/>
{#if showNotes && note != null}
<GroupNoteArea
note={note ?? ''}
{color}
{collapsed}
{editMode}
onHeightChange={(h) => graphContext?.groupDisplayState?.setNoteHeight(groupId, h)}
onNoteUpdate={(text) => groupEditorContext?.groupEditor.updateNote(groupId, text)}
/>
{/if}
{#if editMode}
<GroupActionBar
{note}
{color}
{autocollapse}
{visible}
{menuOpen}
{moveManager}
{moveModuleId}
onMenuOpenChange={(open) => (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}
</div>
@@ -0,0 +1,182 @@
<script lang="ts">
import FlowModuleIcon from '$lib/components/flows/FlowModuleIcon.svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { getGraphContext } from './graphContext'
import { getNodeColorClasses } from '$lib/components/graph'
import type { FlowNodeState } from '$lib/components/graph/util'
import type { GraphModuleState } from './model'
import type { FlowModule } from '$lib/gen'
import type { GraphEventHandlers } from './graphBuilder.svelte'
interface Props {
modules: FlowModule[]
flowModuleStates?: Record<string, GraphModuleState> | undefined
eventHandlers?: GraphEventHandlers
}
let { modules, flowModuleStates, eventHandlers }: Props = $props()
const { selectionManager } = getGraphContext()
// Badge width model: icon(16) + pl(2) + gap(2) + pr(6) = 26px fixed + ~5.5px per char
const BADGE_FIXED = 26
const CHAR_WIDTH = 5.5
const BADGE_MAX = 128 // 8rem
const GAP = 4 // gap-1
const ROW_WIDTH = 255 // 275px container - 2*px-2 padding
const OVERFLOW_BTN_WIDTH = 32
function estimateBadgeWidth(id: string): number {
return BADGE_FIXED + id.length * CHAR_WIDTH
}
function totalWidth(mods: FlowModule[], capped: boolean): number {
return mods.reduce(
(sum, mod, i) =>
sum +
Math.min(estimateBadgeWidth(mod.id), capped ? BADGE_MAX : Infinity) +
(i > 0 ? GAP : 0),
0
)
}
let { displayModules, overflowModules, capIds } = $derived.by(() => {
// Step 1: try all badges with full ids
if (totalWidth(modules, false) <= ROW_WIDTH) {
return { displayModules: modules, overflowModules: [], capIds: false }
}
// Step 2: try all badges with ids capped at 8rem
if (totalWidth(modules, true) <= ROW_WIDTH) {
return { displayModules: modules, overflowModules: [], capIds: true }
}
// Step 3: remove last modules until capped badges fit
const available = ROW_WIDTH - OVERFLOW_BTN_WIDTH - GAP
let count = modules.length
while (count > 1 && totalWidth(modules.slice(0, count), true) > available) {
count--
}
return {
displayModules: modules.slice(0, count),
overflowModules: modules.slice(count),
capIds: true
}
})
const STATE_PRIORITY: Record<string, number> = {
WaitingForEvents: 4,
InProgress: 3,
WaitingForExecutor: 3,
Failure: 2,
Success: 1
}
let overflowAggregateState = $derived.by<FlowNodeState | undefined>(() => {
if (!flowModuleStates) return undefined
let best: FlowNodeState | undefined = undefined
let bestPriority = 0
for (const mod of overflowModules) {
const state = flowModuleStates[mod.id]?.type
if (state) {
const p = STATE_PRIORITY[state] ?? 0
if (p > bestPriority) {
bestPriority = p
best = state
}
}
}
return best
})
function moduleLabel(mod: FlowModule): string {
if (mod.summary) return mod.summary
const type = mod.value?.type
if (type === 'forloopflow') return 'For loop'
if (type === 'whileloopflow') return 'While loop'
if (type === 'branchone') return 'Run one branch'
if (type === 'branchall') return 'Run all branches'
if (type === 'flow') return 'Flow'
if (type === 'identity') return 'Identity'
if (type === 'aiagent') return 'AI Agent'
return mod.id
}
function selectModule(mod: FlowModule) {
selectionManager.selectId(mod.id)
eventHandlers?.select(mod.id)
}
</script>
<div class="flex items-center gap-1">
{#each displayModules as mod (mod.id)}
{@const selected = selectionManager.isNodeSelected(mod.id)}
{@const nodeState = flowModuleStates?.[mod.id]?.type}
{@const colorClasses = getNodeColorClasses(nodeState, selected)}
<Tooltip placement="bottom">
{#snippet children()}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="h-5 rounded-md overflow-hidden flex items-center gap-0.5 shrink-0 shadow-sm pl-0.5 pr-1.5 cursor-pointer hover:opacity-80 {colorClasses.bg} {colorClasses.outline}"
style={capIds ? 'max-width: 8rem;' : ''}
onclick={() => selectModule(mod)}
>
<div class="w-4 h-4 flex items-center justify-center shrink-0">
<FlowModuleIcon module={mod} size={12} />
</div>
<span class="text-3xs font-medium truncate text-right flex-1 {colorClasses.text}"
>{mod.id}</span
>
</div>
{/snippet}
{#snippet text()}
<span class="font-medium">{mod.id}</span>: {moduleLabel(mod)}
{/snippet}
</Tooltip>
{/each}
{#if overflowModules.length > 0}
{@const overflowColorClasses = getNodeColorClasses(overflowAggregateState, false)}
<DropdownV2 placement="bottom" customMenu usePointerDownOutside>
{#snippet buttonReplacement()}
<div
class="h-5 rounded-md flex items-center justify-center shrink-0 shadow-sm px-1.5 cursor-pointer hover:opacity-80 {overflowColorClasses.bg} {overflowColorClasses.outline}"
>
<span class="text-3xs font-medium {overflowColorClasses.text}"
>+{overflowModules.length}</span
>
</div>
{/snippet}
{#snippet menu()}
<div
class="bg-surface-tertiary dark:border rounded-lg shadow-lg py-1 w-56 overflow-y-auto"
style="max-height: 50vh;"
>
{#each overflowModules as mod (mod.id)}
{@const nodeState = flowModuleStates?.[mod.id]?.type}
{@const colorClasses = getNodeColorClasses(nodeState, false)}
{@const selected = selectionManager.isNodeSelected(mod.id)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-surface-hover text-2xs {selected
? 'bg-surface-accent-selected'
: ''}"
onclick={() => selectModule(mod)}
>
<div
class="w-4 h-4 rounded flex items-center justify-center shrink-0 {colorClasses.bg}"
>
<FlowModuleIcon module={mod} size={12} />
</div>
<span class="truncate flex-1">{moduleLabel(mod)}</span>
<span class="text-tertiary shrink-0">{mod.id}</span>
</div>
{/each}
</div>
{/snippet}
</DropdownV2>
{/if}
</div>
@@ -0,0 +1,165 @@
<script lang="ts">
import { Group } from 'lucide-svelte'
import { getNodeColorClasses } from '$lib/components/graph'
import { NOTE_COLORS, NoteColor } from './noteColors'
import { NODE } from './util'
import { twMerge } from 'tailwind-merge'
import { preventDefault, stopPropagation } from 'svelte/legacy'
import GroupNoteArea from './GroupNoteArea.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import GroupModuleIcons from './GroupModuleIcons.svelte'
import type { FlowModule } from '$lib/gen'
interface Props {
summary?: string
selected?: boolean
stepCount?: number
color?: string
note?: string
showNote?: boolean
editMode?: boolean
modules?: FlowModule[]
onExpand?: () => void
onSummaryUpdate?: (text: string) => void
onNoteUpdate?: (text: string) => void
onHeightChange?: (height: number) => void
}
let {
summary,
selected = false,
stepCount,
color,
note,
showNote = false,
editMode = false,
modules,
onExpand,
onSummaryUpdate,
onNoteUpdate,
onHeightChange
}: Props = $props()
let noteColorConfig = $derived(
color ? (NOTE_COLORS[color as NoteColor] ?? NOTE_COLORS[NoteColor.BLUE]) : undefined
)
let defaultColorClasses = $derived(getNodeColorClasses(undefined, selected))
// Inline summary editing
let editingSummary = $state(false)
let summaryInput = $state('')
let textInputComponent: TextInput | undefined = $state(undefined)
function startEditingSummary() {
if (!editMode) return
editingSummary = true
summaryInput = summary ?? ''
requestAnimationFrame(() => {
textInputComponent?.focus()
textInputComponent?.select()
})
}
function saveSummary() {
editingSummary = false
const trimmed = summaryInput.trim()
if (trimmed !== (summary ?? '')) {
onSummaryUpdate?.(trimmed)
}
}
function handleSummaryKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
saveSummary()
} else if (event.key === 'Escape') {
editingSummary = false
}
}
// Reset height to 0 when note is hidden
$effect(() => {
if (!showNote) {
onHeightChange?.(0)
}
})
</script>
<div
class={twMerge(
'w-full module cursor-pointer max-w-full',
'shadow-sm rounded-md overflow-clip',
'bg-surface-tertiary'
)}
style="width: {NODE.width}px;"
>
<div
class={twMerge(
'absolute z-0 outline-offset-0 inset-0',
'rounded-md',
noteColorConfig ? noteColorConfig.outline : defaultColorClasses.outline
)}
></div>
<div class="flex items-center w-full gap-1.5 px-2 h-[34px] relative z-1">
{#if modules && modules.length > 0}
<GroupModuleIcons {modules} />
{:else}
<Group size={14} />
{/if}
<div
class="absolute inset-x-0 flex items-center justify-center h-[34px] pointer-events-none px-8"
>
{#if editingSummary}
<TextInput
bind:this={textInputComponent}
bind:value={summaryInput}
size="xs"
class="!bg-transparent !border-transparent !shadow-none !text-2xs !font-medium !p-0 !m-0 !min-w-0 w-full text-center !min-h-0 !h-auto nodrag nowheel pointer-events-auto"
inputProps={{
placeholder: 'Group',
onblur: saveSummary,
onkeydown: handleSummaryKeydown,
spellcheck: false
}}
/>
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="text-2xs font-medium truncate text-center pointer-events-auto {editMode
? 'cursor-text rounded px-0.5 -mx-0.5 hover:bg-black/10 dark:hover:bg-white/10'
: ''}"
onclick={editMode ? stopPropagation(preventDefault(startEditingSummary)) : undefined}
onpointerdown={editMode ? stopPropagation(preventDefault(() => {})) : undefined}
>{summary || 'Group'}</span
>
{/if}
</div>
<div class="flex-1"></div>
{#if stepCount != null}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="text-3xs opacity-60 shrink-0 whitespace-nowrap {noteColorConfig
? noteColorConfig.text
: ''} {onExpand
? 'cursor-pointer hover:opacity-100 hover:text-blue-500 dark:hover:text-blue-400'
: ''}"
onclick={onExpand ? stopPropagation(preventDefault(onExpand)) : undefined}
>{stepCount} node{stepCount !== 1 ? 's' : ''}</span
>
{/if}
</div>
{#if showNote}
<div class="relative z-1">
<GroupNoteArea
note={note ?? ''}
{color}
{editMode}
onHeightChange={(h) => onHeightChange?.(h)}
onNoteUpdate={(text) => onNoteUpdate?.(text)}
/>
</div>
{/if}
</div>
@@ -0,0 +1,151 @@
<script lang="ts">
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import { Check, X } from 'lucide-svelte'
import { NOTE_COLORS, NoteColor } from './noteColors'
import { stopPropagation, preventDefault } from 'svelte/legacy'
interface Props {
note: string
color?: string
collapsed?: boolean
editMode: boolean
onHeightChange: (height: number) => void
onNoteUpdate: (text: string) => void
}
let { note, color, collapsed = false, editMode, onHeightChange, onNoteUpdate }: Props = $props()
let editing = $state(false)
let editHeight = $state(0)
let textContent = $state('')
let textareaElement: HTMLTextAreaElement | undefined = $state(undefined)
let containerElement: HTMLDivElement | undefined = $state(undefined)
function autoResize(el: HTMLTextAreaElement) {
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
let noteColorConfig = $derived(
color
? (NOTE_COLORS[color as NoteColor] ?? NOTE_COLORS[NoteColor.BLUE])
: NOTE_COLORS[NoteColor.BLUE]
)
// Measure height and report to parent (skip while editing to avoid full graph rebuilds)
$effect(() => {
if (containerElement && !editing) {
const height = containerElement.clientHeight
onHeightChange(height)
}
})
// Also observe resize for dynamic content
$effect(() => {
if (!containerElement) return
const observer = new ResizeObserver((entries) => {
if (editing) return
for (const entry of entries) {
onHeightChange(entry.contentRect.height)
}
})
observer.observe(containerElement)
return () => {
observer.disconnect()
onHeightChange(0)
}
})
function handleDoubleClick() {
if (!editMode) return
editHeight = containerElement?.clientHeight ?? 0
editing = true
textContent = note
requestAnimationFrame(() => {
if (textareaElement) {
autoResize(textareaElement)
textareaElement.focus()
}
})
}
function handleSave() {
editing = false
if (textContent !== note) {
onNoteUpdate(textContent)
}
}
function handleCancel() {
editing = false
textContent = note
}
function handleKeydown(event: KeyboardEvent) {
event.stopPropagation()
if (event.key === 'Escape') {
handleSave()
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={containerElement}
class="nodrag nopan {collapsed ? 'mx-px' : 'w-full rounded-b-md'}"
>
<div class={collapsed ? 'relative' : 'w-full rounded-b-md relative'}>
{#if editing}
<div class="absolute top-0 right-1 flex gap-0.5 z-10">
<button
class="p-0.5 {noteColorConfig.text} opacity-60 hover:opacity-100 cursor-pointer"
onpointerdown={stopPropagation(preventDefault(handleSave))}
title="Save (Esc)"
>
<Check size={11} />
</button>
<button
class="p-0.5 {noteColorConfig.text} opacity-60 hover:opacity-100 cursor-pointer"
onpointerdown={stopPropagation(preventDefault(handleCancel))}
title="Cancel"
>
<X size={11} />
</button>
</div>
<textarea
bind:this={textareaElement}
bind:value={textContent}
class="w-full shadow-none resize-none !text-2xs overflow-y-auto border-none bg-transparent p-1 nodrag nopan nowheel focus:outline-none select-text {noteColorConfig.text}"
style:max-height="max({editHeight}px, 4lh)"
oninput={() => textareaElement && autoResize(textareaElement)}
placeholder="Write a note (markdown supported)"
onblur={handleSave}
onkeydown={handleKeydown}
onpointerdown={stopPropagation(() => {})}
spellcheck="false"
></textarea>
{:else if note}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="w-full text-2xs break-words overflow-hidden p-2 select-text {noteColorConfig.text} {editMode
? 'cursor-pointer'
: ''}"
ondblclick={editMode ? stopPropagation(preventDefault(handleDoubleClick)) : undefined}
onpointerdown={editMode ? stopPropagation(() => {}) : undefined}
>
<GfmMarkdown md={note} noPadding />
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="text-2xs italic opacity-60 p-2 {noteColorConfig.text} {editMode
? 'cursor-pointer'
: ''}"
ondblclick={editMode ? stopPropagation(preventDefault(handleDoubleClick)) : undefined}
onpointerdown={editMode ? stopPropagation(() => {}) : undefined}
>
Double click to edit the note
</div>
{/if}
</div>
</div>
@@ -0,0 +1,90 @@
<script lang="ts">
import { ViewportPortal, type Node } from '@xyflow/svelte'
import { GROUP_HEADER_HEIGHT, groupKey, type FlowGroup } from './groupEditor.svelte'
import { getGraphContext } from './graphContext'
import { NoteColor, NOTE_COLORS } from './noteColors'
import type { GroupHeadN } from './graphBuilder.svelte'
interface Props {
allNodes: (Node & { type: string })[]
groups: FlowGroup[]
groupDepths: Record<string, number>
}
let { allNodes, groups, groupDepths }: Props = $props()
const graphContext = getGraphContext()
// Pre-compute bounds for all groups reactively (tracks allNodes measured changes)
let groupBoundsMap = $derived.by(() => {
const map: Record<string, { x: number; y: number; width: number; height: number } | null> = {}
const nodeMap = new Map(allNodes.map((n) => [n.id, n]))
for (const group of groups) {
if (graphContext?.groupDisplayState?.isRuntimeCollapsed(groupKey(group))) {
continue
}
const headId = `group:${groupKey(group)}`
const endId = `group:${groupKey(group)}-end`
const headNode = nodeMap.get(headId)
const endNode = nodeMap.get(endId)
if (headNode && endNode) {
const d = headNode.data as GroupHeadN['data']
const headCenterX = headNode.position.x + (headNode.measured?.width ?? 275) / 2
const wrapperWidth = d.wrapperWidth ?? 275
const headHeight = headNode.measured?.height ?? GROUP_HEADER_HEIGHT
const topY = headNode.position.y + headHeight / 2
map[groupKey(group)] = {
x: headCenterX - wrapperWidth / 2,
y: topY,
width: wrapperWidth,
height: endNode.position.y - topY
}
} else {
map[groupKey(group)] = null
}
}
return map
})
function getOutlineColorClass(color?: string): string {
const config =
NOTE_COLORS[(color as NoteColor) ?? NoteColor.BLUE] ?? NOTE_COLORS[NoteColor.BLUE]
return config.outline
}
function getBgColorClass(color?: string): string {
return (
NOTE_COLORS[(color as NoteColor) ?? NoteColor.BLUE]?.backgroundLight ??
NOTE_COLORS[NoteColor.BLUE].backgroundLight
)
}
const moveManager = graphContext?.moveManager
function isGroupDragged(groupId: string): boolean {
if (!moveManager) return false
return (
moveManager.draggedNodeIds.has(`group:${groupId}`) ||
moveManager.draggedNodeIds.has(`collapsed-group:${groupId}`)
)
}
</script>
{#each groups as group (groupKey(group))}
{@const bounds = groupBoundsMap[groupKey(group)]}
{#if bounds}
<ViewportPortal target="back">
<div
class="absolute rounded-lg outline outline-1 -outline-offset-1 pointer-events-none {getOutlineColorClass(
group.color
)} {getBgColorClass(group.color)}"
class:opacity-30={isGroupDragged(groupKey(group))}
style:transform="translate({bounds.x}px, {bounds.y}px)"
style:width="{bounds.width}px"
style:height="{bounds.height}px"
style:z-index={-10 + (groupDepths[groupKey(group)] ?? 0)}
></div>
</ViewportPortal>
{/if}
{/each}
@@ -1,6 +1,12 @@
<script lang="ts">
import { writable } from 'svelte/store'
import { SvelteFlow, SvelteFlowProvider, type Node, type Edge, type Viewport } from '@xyflow/svelte'
import {
SvelteFlow,
SvelteFlowProvider,
type Node,
type Edge,
type Viewport
} from '@xyflow/svelte'
import { setGraphContext } from './graphContext'
import { SelectionManager } from './selectionUtils.svelte'
import { createFlowDiffManager } from '../flows/flowDiffManager.svelte'
@@ -20,19 +26,37 @@
import AssetsOverflowedNode from './renderers/nodes/AssetsOverflowedNode.svelte'
import AiToolNode from './renderers/nodes/AIToolNode.svelte'
import NewAiToolNode from './renderers/nodes/NewAIToolNode.svelte'
import CollapsedGroupNode from './renderers/nodes/CollapsedGroupNode.svelte'
import GroupHeadNode from './renderers/nodes/GroupHeadNode.svelte'
import GroupEndNode from './renderers/nodes/GroupEndNode.svelte'
import BaseEdge from './renderers/edges/BaseEdge.svelte'
import EmptyEdge from './renderers/edges/EmptyEdge.svelte'
import DataflowEdge from './renderers/edges/DataflowEdge.svelte'
import HiddenBaseEdge from './renderers/edges/HiddenBaseEdge.svelte'
let {
nodes,
edges,
nodes: nodesProp,
edges: edgesProp,
width,
height,
initialViewport
}: { nodes: Node[]; edges: Edge[]; width: number; height: number; initialViewport?: Viewport } =
$props()
}: {
nodes: Node[]
edges: Edge[]
width: number
height: number
initialViewport?: Viewport
} = $props()
// Use $state.raw to avoid deep reactive proxies that trigger xyflow's performance warning
let nodes = $state.raw<Node[]>([])
let edges = $state.raw<Edge[]>([])
$effect(() => {
nodes = [...nodesProp]
})
$effect(() => {
edges = [...edgesProp]
})
setGraphContext({
selectionManager: new SelectionManager(),
@@ -58,7 +82,10 @@
asset: AssetNode,
assetsOverflowed: AssetsOverflowedNode,
aiTool: AiToolNode,
newAiTool: NewAiToolNode
newAiTool: NewAiToolNode,
collapsedGroup: CollapsedGroupNode,
groupHead: GroupHeadNode,
groupEnd: GroupEndNode
} as any
const edgeTypes = {
@@ -1,47 +0,0 @@
<script lang="ts">
import ContextMenu, { type ContextMenuItem } from '../common/contextmenu/ContextMenu.svelte'
import { StickyNote } from 'lucide-svelte'
import type { Snippet } from 'svelte'
import { getNoteEditorContext } from './noteEditor.svelte'
import { getGraphContext } from './graphContext'
import { tick } from 'svelte'
interface Props {
children: Snippet
selectedNodeIds: string[]
}
let { children, selectedNodeIds }: Props = $props()
// Get NoteEditor context for group note creation
const noteEditorContext = getNoteEditorContext()
// Get Graph context for clearFlowSelection function
const graphContext = getGraphContext()
const menuItems: ContextMenuItem[] = $derived([
{
id: 'create-group-note',
label: `Create group note (${selectedNodeIds.length} nodes)`,
icon: StickyNote,
disabled: selectedNodeIds.length === 0 || !noteEditorContext?.noteEditor,
onClick: () => {
if (selectedNodeIds.length > 0 && noteEditorContext?.noteEditor && graphContext) {
// Create the group note first
noteEditorContext.noteEditor.createGroupNote(selectedNodeIds)
// Wait for next tick to ensure DOM updates
tick().then(() => {
graphContext?.clearFlowSelection?.()
graphContext?.selectionManager.selectId(selectedNodeIds[0])
})
}
}
}
])
</script>
{#if noteEditorContext?.noteEditor && selectedNodeIds.length > 1}
<ContextMenu items={menuItems}>
{@render children()}
</ContextMenu>
{/if}
@@ -10,7 +10,11 @@
isOpen?: boolean
}
let { selectedColor, onColorChange, isOpen = $bindable(false) }: Props = $props()
let {
selectedColor,
onColorChange,
isOpen = $bindable(false)
}: Props = $props()
</script>
<Popover
@@ -1,10 +1,10 @@
<script lang="ts">
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
}
]
: [])
@@ -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<string, { x: number; y: number }>
bbox: { width: number; height: number }
contentMinX: number
groupDimensions?: Map<string, { width: number; height: number }>
}
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<string, { top: number; bottom: number; left: number; right: number }>
): Map<string, { width: number; height: number }> | undefined {
if (!nodeExtraSpace || nodeExtraSpace.size === 0) return undefined
const sizes = new Map<string, { width: number; height: number }>()
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<string, LayoutNode>,
constants: LayoutConstants,
childrenMap: Map<string, string[]>,
depth: number = 0
depth: number = 0,
nodeExtraSpace?: Map<string, { top: number; bottom: number; left: number; right: number }>
): LayoutResult {
const positions = new Map<string, { x: number; y: number }>()
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<string, { width: number; height: number }>()
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<string, { width: number; height: number }>()
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<LayoutConstants>
constants?: Partial<LayoutConstants>,
nodeExtraSpace?: Map<string, { top: number; bottom: number; left: number; right: number }>
): 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
@@ -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'])
})
})
@@ -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<string> } {
if (modules.length === 0) {
return { items: [], consumed: new Set() }
}
const indexMap = new Map<string, number>()
for (let i = 0; i < modules.length; i++) {
indexMap.set(modules[i].id, i)
}
// Reject duplicate group IDs
const seenGroupIds = new Set<string>()
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<string, FlowModule>
): 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<string, number> {
const depths: Record<string, number> = {}
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<string>()
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<string>()
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
}
@@ -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<string, GraphModuleState> | undefined
flowJob: Job | undefined
isOwner: boolean
suspendStatus: Record<string, { job: Job; nb: number }>
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<string, FlowModule[]>
// triggerProps?: {
// path?: string
// flowIsSimplifiable?: boolean
// }
expandedSubflows: Record<string, { modules: FlowModule[]; groups?: FlowGroup[] }>,
showNotes: boolean,
collapsedGroupIds: Set<string>
): {
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<string, FlowModule>()
for (const m of getAllModules(modules, failureModule)) {
moduleMap.set(m.id, m)
}
function addNode(module: FlowModule, extraData?: Record<string, any>) {
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<string>()
const recStack = new Set<string>()
@@ -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) {
@@ -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'
@@ -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<string>
): { 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<string>()
const queue = [rawEndId]
const visited = new Set<string>()
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) {
@@ -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<Set<string>>(new Set())
#runtimeInitialized = $state(false)
#noteHeights = $state<Record<string, number>>({})
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<string, number> {
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<ExtendedOpenFlow>
constructor(flowStore: StateStore<ExtendedOpenFlow>) {
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<string> {
const excludeIds = new Set<string>()
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<NoteColor>()
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<boolean>
}
const CONTEXT_KEY = 'GroupEditorContext'
export function setGroupEditorContext(
groupEditor: GroupEditor,
canCreateGroup: StateStore<boolean>
): void {
setContext<GroupEditorContext>(CONTEXT_KEY, { groupEditor, canCreateGroup })
}
export function getGroupEditorContext(): GroupEditorContext | undefined {
return getContext<GroupEditorContext | undefined>(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 []
}
@@ -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<FlowStructureNode[]>([])
#error = $state<unknown>(undefined)
#flowStore: StateStore<ExtendedOpenFlow>
constructor(flowStore: StateStore<ExtendedOpenFlow>) {
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<string, FlowModule>()
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
}
}
}
@@ -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)
@@ -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<string, number>
groupDisplayState: GroupDisplayState
insertable: boolean
flowModuleStates: Record<string, GraphModuleState> | undefined
}
): Map<string, ExtraSpace> | undefined {
const extraSpace = new Map<string, ExtraSpace>()
// 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
}
+21 -10
View File
@@ -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, NoteColorConfig> = {
[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'

Some files were not shown because too many files have changed in this diff Show More