From c4c9ef5fd7b41052b08ee941725434e8ca4ac970 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 3 Apr 2026 13:39:32 -0400 Subject: [PATCH] feat: add optional labels to scripts, flows, apps, schedules, triggers (#8609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add optional labels to scripts, flows, apps, raw apps, schedules, and triggers Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update sqlx cache, make labels optional in openapi, regenerate system prompts Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add minimal labels input UI to script, flow, and schedule editors Co-Authored-By: Claude Opus 4.6 (1M context) * fix: reduce gap between summary and labels input Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add labels to script/flow detail pages and summary/path popover Co-Authored-By: Claude Opus 4.6 (1M context) * fix: move labels inside SummaryPathDisplay trigger for clickable area, reduce gap Co-Authored-By: Claude Opus 4.6 (1M context) * fix: display labels inline to the right of summary, not below Co-Authored-By: Claude Opus 4.6 (1M context) * fix: increase gap between summary and labels Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add labels to resources/variables, make labels nullable, add home page label filter badges Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add labels to workspace export/import, resources, variables + test coverage Co-Authored-By: Claude Opus 4.6 (1M context) * fix: make migration idempotent, regenerate sqlx cache after merge Co-Authored-By: Claude Opus 4.6 (1M context) * fix: pass labels in script create and flow create/update API calls Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add labels input UI to resource and variable editors Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove negative margin from LabelsInput to prevent overlap Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add top and left margin to LabelsInput for better spacing Co-Authored-By: Claude Opus 4.6 (1M context) * fix: reduce left margin on LabelsInput Co-Authored-By: Claude Opus 4.6 (1M context) * fix: widen label input to w-32 Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use inline-flex so LabelsInput doesn't stretch full width Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove flex-wrap so label input stays on same line as badges Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add label filter presets to resources, variables, and schedules search Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use max-w-32 on label input to prevent stretching Co-Authored-By: Claude Opus 4.6 (1M context) * fix: pull labels closer to summary with negative top margin Co-Authored-By: Claude Opus 4.6 (1M context) * fix: increase negative margin to pull labels even closer to summary Co-Authored-By: Claude Opus 4.6 (1M context) * fix: pass labels in schedule create/update API calls Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use COALESCE to preserve existing labels when not provided in schedule/flow update Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add labels to CreateResource, EditResource, CreateVariable, EditVariable in OpenAPI spec Co-Authored-By: Claude Opus 4.6 (1M context) * feat: display label badges on resource and variable list pages Co-Authored-By: Claude Opus 4.6 (1M context) * feat: display label badges on schedule and all trigger list pages Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add folder and label presets to schedules search filter Co-Authored-By: Claude Opus 4.6 (1M context) * fix: apply user_folders_only filter on all workspaces including admins Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add label presets to resources and variables search filters Co-Authored-By: Claude Opus 4.6 (1M context) * fix: derive folder presets from loaded items, not all workspace folders Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add label query parameter to resource and variable list endpoints in OpenAPI Co-Authored-By: Claude Opus 4.6 (1M context) * feat: display label filter badges inline with folder filters on home page Co-Authored-By: Claude Opus 4.6 (1M context) * Revert "feat: display label filter badges inline with folder filters on home page" This reverts commit 6767a50aa6900f775e94fdfbf931818831b99b52. * feat: support comma-separated label filters (allowMultiple) in all list endpoints Co-Authored-By: Claude Opus 4.6 (1M context) * fix: append label presets with comma for allowMultiple filters instead of duplicating key Co-Authored-By: Claude Opus 4.6 (1M context) * fix: hide label presets that are already in the comma-separated filter value Co-Authored-By: Claude Opus 4.6 (1M context) * fix: replace unsafe manual SQL ARRAY construction with parameterized queries, add labels to ScriptWDraft Co-Authored-By: Claude Opus 4.6 (1M context) * fix: complete down migration, add labels to Resource/Variable OpenAPI schemas, remove type cast, add label length validation Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add labels field to Schedule test fixture Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add labels field to Rust client struct constructions Co-Authored-By: Claude Opus 4.6 (1M context) * fix: regenerate sqlx cache with --all-features for EE builds Co-Authored-By: Claude Opus 4.6 (1M context) * chore: regenerate sqlx cache and package-lock after merge with main Co-Authored-By: Claude Opus 4.6 (1M context) * fix: squash two migrations into one, use IF NOT EXISTS for idempotency Co-Authored-By: Claude Opus 4.6 (1M context) * fix: track label changes in SummaryPathDisplay to enable save button Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use JSON string comparison for label dirty tracking in popover Co-Authored-By: Claude Opus 4.6 (1M context) * fix: navigate to script by path after save from popover to load new version Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update initialLabels after save so subsequent label changes enable save again Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use onchange callback for label dirty tracking instead of derived comparison Co-Authored-By: Claude Opus 4.6 (1M context) * fix: reload script by path after label save to fetch new version Co-Authored-By: Claude Opus 4.6 (1M context) * feat: propagate script/flow labels to jobs at push time Co-Authored-By: Claude Opus 4.6 (1M context) * feat: show script/flow labels on runs page, merge with wm_labels for completed jobs Co-Authored-By: Claude Opus 4.6 (1M context) * fix: change job labels type from JSONB to text[], show labels on job detail page, fix type mismatch Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add labels to QueuedJob struct, fix get_job queries to return v2_job.labels Co-Authored-By: Claude Opus 4.6 (1M context) * fix: replace +Label text with icon only Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add tag icon before labels on job detail page Co-Authored-By: Claude Opus 4.6 (1M context) * fix: move tag icon inside badge on job detail page Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use blue badge with tag icon in RunBadges, remove duplicate labels from JobDetailHeader Co-Authored-By: Claude Opus 4.6 (1M context) * fix: set icon position to left so tag icon renders in badge Co-Authored-By: Claude Opus 4.6 (1M context) * fix: render Tag icon inline in badge children instead of via icon prop Co-Authored-By: Claude Opus 4.6 (1M context) * fix: retry icon prop with small badge and position left Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add hover tooltip showing "Label: X" on job label badges Co-Authored-By: Claude Opus 4.6 (1M context) * feat: include v2_job.labels in runs page label filter and broad search Co-Authored-By: Claude Opus 4.6 (1M context) * chore: regenerate sqlx cache and system prompts after merge with main Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add labels to EE JobPayload constructions, regenerate sqlx cache with --all-features Co-Authored-By: Claude Opus 4.6 (1M context) * fix: regenerate sqlx cache CE-only (without EE symlinks that cause conflicts) Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update remaining wm_labels JSONB queries to use text[] merge expression Co-Authored-By: Claude Opus 4.6 (1M context) * fix: simplify job labels to just read v2_job.labels (wm_labels already merged at completion) Co-Authored-By: Claude Opus 4.6 (1M context) * fix: consistent label badge spacing with gap-0.5 wrapper and px-0.5 on badges Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add labels: None to test utils JobPayload construction Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add labels to all test fixture JobPayload/NewFlow/EditApp constructions, regenerate sqlx cache Co-Authored-By: Claude Opus 4.6 (1M context) * fix: fix vertical content shift by fixing container and input height to h-5 Co-Authored-By: Claude Opus 4.6 (1M context) * fix: npm_check errors - unused imports, combinedItems order, flow.labels type, badge px-1 padding Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove unused FolderService imports, fix label badge alignment in RunBadges Co-Authored-By: Claude Opus 4.6 (1M context) * fix: restore deleted service imports in variables page, remove empty loadFolders Co-Authored-By: Claude Opus 4.6 (1M context) * chore: trigger CI with updated ee-repo-ref * chore: update ee-repo-ref to merged EE companion PR Co-Authored-By: Claude Opus 4.6 (1M context) * chore: trigger fresh CI run for updated ee-repo-ref * fix: match label badge size with other badges in RunBadges using {large} prop Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove icon from RunBadges label badge to fix vertical alignment Co-Authored-By: Claude Opus 4.6 (1M context) * fix: shorten "Job kind" to "Kind" in run badges Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add small inline tag icon (10px, -mt-px) to label badge without disrupting height Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add "Label: X" hover tooltip to all label badges, show hidden labels on +N hover Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add tag icon and "Label: X" tooltip to home page label filter badges Co-Authored-By: Claude Opus 4.6 (1M context) * fix: show LabelsInput even when path is hidden in ResourceEditor Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add labels input to new resource creation drawer (AppConnectInner) Co-Authored-By: Claude Opus 4.6 (1M context) * iterate * fix: add LabelsInput to all resource creation steps in AppConnectInner Co-Authored-By: Claude Opus 4.6 (1M context) * fix: reduce LabelsInput top margin from -mt-3 to -mt-1 Co-Authored-By: Claude Opus 4.6 (1M context) * fix: increase negative margin to -mt-2 for tighter spacing Co-Authored-By: Claude Opus 4.6 (1M context) * fix: split the difference with -mt-1.5 Co-Authored-By: Claude Opus 4.6 (1M context) * fix: adjust to -mt-1 for label spacing Co-Authored-By: Claude Opus 4.6 (1M context) * fix: per-site label spacing via class prop instead of global negative margin Co-Authored-By: Claude Opus 4.6 (1M context) * feat: make label badges clickable to toggle label filter on resources, variables, schedules Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use proper array indexOf for label filter toggle, set undefined correctly on removal Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use delete instead of undefined to properly clear label filter Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add /labels/list endpoint and autocomplete dropdown to LabelsInput Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use inline preventDefault for Svelte 5 event handling Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add "Create new" option in label autocomplete, regenerate sqlx cache with update_sqlx.sh Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add GIN indexes on labels column for all 16 tables Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove CONCURRENTLY from GIN index creation in migration Co-Authored-By: Claude Opus 4.6 (1M context) * test: add comprehensive label coverage for pull, edit, removal across all item types Co-Authored-By: Claude Opus 4.6 (1M context) * fix: simplify job label filters to only use v2_job.labels, remove wm_labels back-compat Co-Authored-By: Claude Opus 4.6 (1M context) * test: add integration tests for job label propagation, display, and filtering Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address PR review findings — missing labels in fetch_script_for_update, app rename, escape key bug - Add `labels` to SELECT in `fetch_script_for_update` to prevent lost labels on script clone - Pass `labels` in app branch of `moveRenameManager.ts` so app renames preserve labels - Clear `inputValue` before `adding = false` in LabelsInput escape handler to prevent accidental label add via onblur - Fix `test_job_label_filter` to complete jobs via SQL (label filtering only works on completed jobs) - Add `test_wm_labels_from_result_merged_with_static_labels` integration test using Bun Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- ...fbd38c53fb586251ba780b75a007e5a47ed6.json} | 13 +- ...cf6cb506c75e84c65438c9026831eb10d340b.json | 19 - ...1526adcecdd8729f28c15e0db8cd28eaa8cf0.json | 16 +- ...8159555b652869a036b006f4a151e999b17b7.json | 6 + ...0fcb380f2470c8488371b207a42cb7d4cd26.json} | 7 +- ...2844c71b95c75bd8f173eac51336ab176ccc0.json | 16 + ...67fab305c21e4d7703bedbadf405b5c2d7582.json | 16 - ...07ca90d8dd76da2ca560b310b12820be2576.json} | 7 +- ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- ...ed879a9a7be6ff044a9ce1b581ba725665fe.json} | 10 +- ...6dc628e2ba56eab5e1a50c99481da9793759e.json | 16 - ...b9ad6f5276b60ce20e0378d6148976e02777.json} | 12 +- ...980f712f92798d11feaeb0832962ad9ddb2e.json} | 12 +- ...5fc383272d2057ac5b47a7425f095f4e8788.json} | 7 +- ...db33a366a84426a115c75f58dcc43ab91705e.json | 16 + ...0ddc09bbcf4506eff2290664c295d16fec4ae.json | 16 + ...18b88c4d7ea8f0cde663ed8f8226faf75b0f6.json | 16 + ...b20cb3020f93454978d078082482b86cbebe.json} | 7 +- ...06ee2035ae9811bc49abce903a04439baf800.json | 22 + ...05bc22731c27f0bf27b3b06ec426a05eda2e.json} | 13 +- ...aa872403890eae3e606f379343671c2fff02.json} | 7 +- ...667056f6b3696dea7d73758e53f825bfce13e.json | 16 + ...13c24dd55d63bda78a0a5f35ed6f22bea6bef.json | 20 + ...a54b6aa1f458f3ecca8f35432153e54b143d.json} | 7 +- ...de805cbe6bdee314ca40d912010db307bd13e.json | 16 + backend/ee-repo-ref.txt | 2 +- .../20260330000000_add_labels.down.sql | 16 + .../20260330000000_add_labels.up.sql | 33 + backend/summarized_schema.txt | 32 +- backend/tests/dependency_map.rs | 2 + backend/tests/error_handler.rs | 6 +- backend/tests/job_payload.rs | 20 +- backend/tests/list_jobs.rs | 352 ++++++++-- backend/tests/success_handler.rs | 2 +- backend/windmill-api-flows/src/flows.rs | 42 +- backend/windmill-api-jobs/src/execution.rs | 2 + backend/windmill-api-jobs/src/query.rs | 16 +- backend/windmill-api-jobs/src/types.rs | 9 +- backend/windmill-api-schedule/src/lib.rs | 35 +- backend/windmill-api-scripts/src/scripts.rs | 20 +- backend/windmill-api/openapi.yaml | 272 +++++++ backend/windmill-api/src/apps.rs | 61 +- backend/windmill-api/src/jobs.rs | 11 +- backend/windmill-api/src/lib.rs | 21 + backend/windmill-api/src/raw_apps.rs | 9 + backend/windmill-api/src/workspaces_export.rs | 7 +- backend/windmill-common/src/jobs.rs | 12 +- backend/windmill-common/src/lib.rs | 11 +- backend/windmill-common/src/scripts.rs | 9 +- backend/windmill-common/src/variables.rs | 6 + backend/windmill-queue/src/jobs.rs | 14 +- backend/windmill-queue/src/schedule.rs | 12 +- backend/windmill-queue/tests/schedule_push.rs | 1 + backend/windmill-store/src/resources.rs | 36 +- backend/windmill-store/src/variables.rs | 27 +- backend/windmill-test-utils/src/lib.rs | 3 +- backend/windmill-trigger-http/src/handler.rs | 12 + backend/windmill-trigger/src/handler.rs | 34 + backend/windmill-trigger/src/types.rs | 16 +- backend/windmill-types/src/apps.rs | 1 + backend/windmill-types/src/flows.rs | 7 + backend/windmill-types/src/jobs.rs | 7 +- backend/windmill-types/src/schedule.rs | 2 + backend/windmill-types/src/scripts.rs | 8 + backend/windmill-worker/src/bun_executor.rs | 2 + backend/windmill-worker/src/worker_flow.rs | 10 +- cli/src/commands/script/script.ts | 1 + cli/src/guidance/skills.ts | 36 + cli/test/labels_export.test.ts | 663 ++++++++++++++++++ ee-repo-ref.txt | 1 + .../src/lib/components/AppConnectInner.svelte | 10 +- .../src/lib/components/FilterSearchbar.svelte | 26 +- .../src/lib/components/FlowBuilder.svelte | 9 +- .../src/lib/components/LabelsInput.svelte | 165 +++++ .../src/lib/components/ResourceEditor.svelte | 8 +- .../src/lib/components/ScriptBuilder.svelte | 8 +- .../lib/components/SummaryPathDisplay.svelte | 42 +- .../src/lib/components/VariableEditor.svelte | 11 +- .../lib/components/common/table/AppRow.svelte | 18 + .../components/common/table/FlowRow.svelte | 18 + .../components/common/table/ScriptRow.svelte | 18 + .../details/DetailPageHeader.svelte | 4 +- .../flows/content/FlowSettings.svelte | 2 + .../src/lib/components/flows/scheduleUtils.ts | 3 +- .../src/lib/components/home/ItemsList.svelte | 37 +- .../src/lib/components/moveRenameManager.ts | 12 +- .../components/resources/resourcesFilter.ts | 13 +- .../src/lib/components/runs/RunBadges.svelte | 9 +- .../components/schedules/schedulesFilter.ts | 13 +- .../schedules/ScheduleEditorInner.svelte | 6 + .../components/variables/variablesFilter.ts | 13 +- .../(logged)/email_triggers/+page.svelte | 9 +- .../(logged)/flows/get/[...path]/+page.svelte | 19 +- .../(root)/(logged)/gcp_triggers/+page.svelte | 9 +- .../(logged)/kafka_triggers/+page.svelte | 9 +- .../(logged)/mqtt_triggers/+page.svelte | 9 +- .../(logged)/nats_triggers/+page.svelte | 9 +- .../(logged)/postgres_triggers/+page.svelte | 9 +- .../(root)/(logged)/resources/+page.svelte | 73 +- .../(root)/(logged)/schedules/+page.svelte | 54 +- .../scripts/get/[...hash]/+page.svelte | 3 +- .../(root)/(logged)/sqs_triggers/+page.svelte | 9 +- .../(root)/(logged)/variables/+page.svelte | 69 +- .../(logged)/websocket_triggers/+page.svelte | 9 +- rust-client/src/client.rs | 5 +- .../schemas/gcp_trigger.schema.yaml | 4 + .../schemas/http_trigger.schema.yaml | 4 + .../schemas/kafka_trigger.schema.yaml | 4 + .../schemas/mqtt_trigger.schema.yaml | 4 + .../schemas/nats_trigger.schema.yaml | 4 + .../schemas/postgres_trigger.schema.yaml | 4 + .../schemas/schedule.schema.yaml | 4 + .../schemas/sqs_trigger.schema.yaml | 4 + .../schemas/websocket_trigger.schema.yaml | 4 + 114 files changed, 2643 insertions(+), 349 deletions(-) rename backend/.sqlx/{query-dd20f94d560238096390371c98ded1f80825a11cd61c0bb431678ad9ab4a138e.json => query-2effe885aafaa0c5fc1f969b8c6ffbd38c53fb586251ba780b75a007e5a47ed6.json} (76%) delete mode 100644 backend/.sqlx/query-39426bd3018b390ea2073419884cf6cb506c75e84c65438c9026831eb10d340b.json rename backend/.sqlx/{query-2be66f23536223549db9b50025932b6b1bad90b8fa47d97acb7d75aa3c37ef86.json => query-48efd8f89df9c1dd8f8a4eb2b2640fcb380f2470c8488371b207a42cb7d4cd26.json} (56%) create mode 100644 backend/.sqlx/query-4c333861e736b8138162f0ff3bf2844c71b95c75bd8f173eac51336ab176ccc0.json delete mode 100644 backend/.sqlx/query-51f09f073842a6990535b887d8267fab305c21e4d7703bedbadf405b5c2d7582.json rename backend/.sqlx/{query-14276a040cb4db88d71fccdc3579e8c0bb132b70668301b535872d1632753e30.json => query-5425e2c5e29fc5145dde5ea53d5307ca90d8dd76da2ca560b310b12820be2576.json} (74%) rename backend/.sqlx/{query-ad96768ff61fab1cfb9421683bb13b64f5f157fafe114d50fb910dc36ebe0f91.json => query-642ca096c1c151fc01445593c8d2ed879a9a7be6ff044a9ce1b581ba725665fe.json} (95%) delete mode 100644 backend/.sqlx/query-676c758d9c4492dada50edd3ad06dc628e2ba56eab5e1a50c99481da9793759e.json rename backend/.sqlx/{query-209dc4c1b91eeab1c12ffcd9f9e16f315c689ca772c736b333dcdf07c8086087.json => query-6d992a933bb878733b7afd7a4295b9ad6f5276b60ce20e0378d6148976e02777.json} (67%) rename backend/.sqlx/{query-a33673ebc4d1eb4c3513987dbc43e2c80974598e1d9fe7203145bfc29928ba65.json => query-71767e6684957af5dff56a1bb64f980f712f92798d11feaeb0832962ad9ddb2e.json} (88%) rename backend/.sqlx/{query-4d983f1e3e63a1a70edf5d867d9f23f2069a7a4ba1dcc1331ecccdf1c6a95cb8.json => query-790d79ec7abe6ebe1092afd9de4c5fc383272d2057ac5b47a7425f095f4e8788.json} (85%) create mode 100644 backend/.sqlx/query-86c0ef92652085b93979a8836bedb33a366a84426a115c75f58dcc43ab91705e.json create mode 100644 backend/.sqlx/query-a969194571dd3f12e628ce0f01b0ddc09bbcf4506eff2290664c295d16fec4ae.json create mode 100644 backend/.sqlx/query-b657b718ec381fbb1d41841c88118b88c4d7ea8f0cde663ed8f8226faf75b0f6.json rename backend/.sqlx/{query-6bde827da007b470b9d0acccfc3e00ce6aac650b9138a236f34c614eed753849.json => query-b6f95b3fd1d0431d96d0409424dbb20cb3020f93454978d078082482b86cbebe.json} (65%) create mode 100644 backend/.sqlx/query-b782179261b4054289a1a595f6e06ee2035ae9811bc49abce903a04439baf800.json rename backend/.sqlx/{query-54b4c762add9b1ebfdb2a6d5abd6d20e86dc0e6544f0bb22fa4ec68aa54a4dc8.json => query-bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e.json} (79%) rename backend/.sqlx/{query-77ac7257be02fb04c4b3213e2221e6f60621b4b2909d770de744ef5671e12ed9.json => query-c06796e8647cf278c6e0809562aaaa872403890eae3e606f379343671c2fff02.json} (76%) create mode 100644 backend/.sqlx/query-ddda19024473b4e5b1d450bf56c667056f6b3696dea7d73758e53f825bfce13e.json create mode 100644 backend/.sqlx/query-deac41298e8b0d0870e314fef0813c24dd55d63bda78a0a5f35ed6f22bea6bef.json rename backend/.sqlx/{query-6b53f7c4bb73177316d6134698f3979f51b53dcd4d8ec50d312c9e7fe31ad5f5.json => query-e4836a1ee97e4723bddc28b94e23a54b6aa1f458f3ecca8f35432153e54b143d.json} (62%) create mode 100644 backend/.sqlx/query-f6f850c419771b193f506ceca63de805cbe6bdee314ca40d912010db307bd13e.json create mode 100644 backend/migrations/20260330000000_add_labels.down.sql create mode 100644 backend/migrations/20260330000000_add_labels.up.sql create mode 100644 cli/test/labels_export.test.ts create mode 100644 ee-repo-ref.txt create mode 100644 frontend/src/lib/components/LabelsInput.svelte diff --git a/backend/.sqlx/query-dd20f94d560238096390371c98ded1f80825a11cd61c0bb431678ad9ab4a138e.json b/backend/.sqlx/query-2effe885aafaa0c5fc1f969b8c6ffbd38c53fb586251ba780b75a007e5a47ed6.json similarity index 76% rename from backend/.sqlx/query-dd20f94d560238096390371c98ded1f80825a11cd61c0bb431678ad9ab4a138e.json rename to backend/.sqlx/query-2effe885aafaa0c5fc1f969b8c6ffbd38c53fb586251ba780b75a007e5a47ed6.json index 5b50558a5a..6d948ee8ae 100644 --- a/backend/.sqlx/query-dd20f94d560238096390371c98ded1f80825a11cd61c0bb431678ad9ab4a138e.json +++ b/backend/.sqlx/query-2effe885aafaa0c5fc1f969b8c6ffbd38c53fb586251ba780b75a007e5a47ed6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO schedule (\n workspace_id, path, schedule, timezone, edited_by, script_path,\n is_flow, args, enabled, email, permissioned_as,\n on_failure, on_failure_times, on_failure_exact, on_failure_extra_args,\n on_recovery, on_recovery_times, on_recovery_extra_args,\n on_success, on_success_extra_args,\n ws_error_handler_muted, retry, summary, no_flow_overlap,\n tag, paused_until, cron_version, description, dynamic_skip\n ) VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13, $14, $15,\n $16, $17, $18,\n $19, $20,\n $21, $22, $23, $24,\n $25, $26, $27, $28, $29\n )\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip\n ", + "query": "\n INSERT INTO schedule (\n workspace_id, path, schedule, timezone, edited_by, script_path,\n is_flow, args, enabled, email, permissioned_as,\n on_failure, on_failure_times, on_failure_exact, on_failure_extra_args,\n on_recovery, on_recovery_times, on_recovery_extra_args,\n on_success, on_success_extra_args,\n ws_error_handler_muted, retry, summary, no_flow_overlap,\n tag, paused_until, cron_version, description, dynamic_skip, labels\n ) VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13, $14, $15,\n $16, $17, $18,\n $19, $20,\n $21, $22, $23, $24,\n $25, $26, $27, $28, $29, $30\n )\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ", "describe": { "columns": [ { @@ -162,6 +162,11 @@ "ordinal": 31, "name": "dynamic_skip", "type_info": "Varchar" + }, + { + "ordinal": 32, + "name": "labels", + "type_info": "TextArray" } ], "parameters": { @@ -194,7 +199,8 @@ "Timestamptz", "Text", "Text", - "Varchar" + "Varchar", + "TextArray" ] }, "nullable": [ @@ -229,8 +235,9 @@ true, true, true, + true, true ] }, - "hash": "dd20f94d560238096390371c98ded1f80825a11cd61c0bb431678ad9ab4a138e" + "hash": "2effe885aafaa0c5fc1f969b8c6ffbd38c53fb586251ba780b75a007e5a47ed6" } diff --git a/backend/.sqlx/query-39426bd3018b390ea2073419884cf6cb506c75e84c65438c9026831eb10d340b.json b/backend/.sqlx/query-39426bd3018b390ea2073419884cf6cb506c75e84c65438c9026831eb10d340b.json deleted file mode 100644 index 69064f7d45..0000000000 --- a/backend/.sqlx/query-39426bd3018b390ea2073419884cf6cb506c75e84c65438c9026831eb10d340b.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO resource\n (workspace_id, path, value, description, resource_type, created_by, edited_at)\n VALUES ($1, $2, $3, $4, $5, $6, now()) ON CONFLICT (workspace_id, path)\n DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, resource_type = EXCLUDED.resource_type, edited_at = now()", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Jsonb", - "Text", - "Varchar", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "39426bd3018b390ea2073419884cf6cb506c75e84c65438c9026831eb10d340b" -} diff --git a/backend/.sqlx/query-41f2c271514ee254739c3a097871526adcecdd8729f28c15e0db8cd28eaa8cf0.json b/backend/.sqlx/query-41f2c271514ee254739c3a097871526adcecdd8729f28c15e0db8cd28eaa8cf0.json index cf134912fb..78efa6ccbc 100644 --- a/backend/.sqlx/query-41f2c271514ee254739c3a097871526adcecdd8729f28c15e0db8cd28eaa8cf0.json +++ b/backend/.sqlx/query-41f2c271514ee254739c3a097871526adcecdd8729f28c15e0db8cd28eaa8cf0.json @@ -45,31 +45,36 @@ }, { "ordinal": 8, + "name": "labels", + "type_info": "TextArray" + }, + { + "ordinal": 9, "name": "is_expired", "type_info": "Bool" }, { - "ordinal": 9, + "ordinal": 10, "name": "is_refreshed", "type_info": "Bool" }, { - "ordinal": 10, + "ordinal": 11, "name": "refresh_error", "type_info": "Text" }, { - "ordinal": 11, + "ordinal": 12, "name": "is_linked", "type_info": "Bool" }, { - "ordinal": 12, + "ordinal": 13, "name": "is_oauth?", "type_info": "Bool" }, { - "ordinal": 13, + "ordinal": 14, "name": "account", "type_info": "Int4" } @@ -89,6 +94,7 @@ false, true, true, + true, null, null, true, diff --git a/backend/.sqlx/query-45e4d13f5806122faecdb1d9ab18159555b652869a036b006f4a151e999b17b7.json b/backend/.sqlx/query-45e4d13f5806122faecdb1d9ab18159555b652869a036b006f4a151e999b17b7.json index 259b328558..12b8201ebb 100644 --- a/backend/.sqlx/query-45e4d13f5806122faecdb1d9ab18159555b652869a036b006f4a151e999b17b7.json +++ b/backend/.sqlx/query-45e4d13f5806122faecdb1d9ab18159555b652869a036b006f4a151e999b17b7.json @@ -42,6 +42,11 @@ "ordinal": 7, "name": "created_by", "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "labels", + "type_info": "TextArray" } ], "parameters": { @@ -57,6 +62,7 @@ false, false, true, + true, true ] }, diff --git a/backend/.sqlx/query-2be66f23536223549db9b50025932b6b1bad90b8fa47d97acb7d75aa3c37ef86.json b/backend/.sqlx/query-48efd8f89df9c1dd8f8a4eb2b2640fcb380f2470c8488371b207a42cb7d4cd26.json similarity index 56% rename from backend/.sqlx/query-2be66f23536223549db9b50025932b6b1bad90b8fa47d97acb7d75aa3c37ef86.json rename to backend/.sqlx/query-48efd8f89df9c1dd8f8a4eb2b2640fcb380f2470c8488371b207a42cb7d4cd26.json index 204cd6df90..4621e0cc11 100644 --- a/backend/.sqlx/query-2be66f23536223549db9b50025932b6b1bad90b8fa47d97acb7d75aa3c37ef86.json +++ b/backend/.sqlx/query-48efd8f89df9c1dd8f8a4eb2b2640fcb380f2470c8488371b207a42cb7d4cd26.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO variable\n (workspace_id, path, value, is_secret, description, account, is_oauth, expires_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + "query": "INSERT INTO variable\n (workspace_id, path, value, is_secret, description, account, is_oauth, expires_at, labels)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", "describe": { "columns": [], "parameters": { @@ -12,10 +12,11 @@ "Varchar", "Int4", "Bool", - "Timestamptz" + "Timestamptz", + "TextArray" ] }, "nullable": [] }, - "hash": "2be66f23536223549db9b50025932b6b1bad90b8fa47d97acb7d75aa3c37ef86" + "hash": "48efd8f89df9c1dd8f8a4eb2b2640fcb380f2470c8488371b207a42cb7d4cd26" } diff --git a/backend/.sqlx/query-4c333861e736b8138162f0ff3bf2844c71b95c75bd8f173eac51336ab176ccc0.json b/backend/.sqlx/query-4c333861e736b8138162f0ff3bf2844c71b95c75bd8f173eac51336ab176ccc0.json new file mode 100644 index 0000000000..f1de82e5e6 --- /dev/null +++ b/backend/.sqlx/query-4c333861e736b8138162f0ff3bf2844c71b95c75bd8f173eac51336ab176ccc0.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE variable SET labels = $1 WHERE path = $2 AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "TextArray", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "4c333861e736b8138162f0ff3bf2844c71b95c75bd8f173eac51336ab176ccc0" +} diff --git a/backend/.sqlx/query-51f09f073842a6990535b887d8267fab305c21e4d7703bedbadf405b5c2d7582.json b/backend/.sqlx/query-51f09f073842a6990535b887d8267fab305c21e4d7703bedbadf405b5c2d7582.json deleted file mode 100644 index 89bb7c33ca..0000000000 --- a/backend/.sqlx/query-51f09f073842a6990535b887d8267fab305c21e4d7703bedbadf405b5c2d7582.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Text" - ] - }, - "nullable": [] - }, - "hash": "51f09f073842a6990535b887d8267fab305c21e4d7703bedbadf405b5c2d7582" -} diff --git a/backend/.sqlx/query-14276a040cb4db88d71fccdc3579e8c0bb132b70668301b535872d1632753e30.json b/backend/.sqlx/query-5425e2c5e29fc5145dde5ea53d5307ca90d8dd76da2ca560b310b12820be2576.json similarity index 74% rename from backend/.sqlx/query-14276a040cb4db88d71fccdc3579e8c0bb132b70668301b535872d1632753e30.json rename to backend/.sqlx/query-5425e2c5e29fc5145dde5ea53d5307ca90d8dd76da2ca560b310b12820be2576.json index 76ee6b6809..7c13ded9a3 100644 --- a/backend/.sqlx/query-14276a040cb4db88d71fccdc3579e8c0bb132b70668301b535872d1632753e30.json +++ b/backend/.sqlx/query-5425e2c5e29fc5145dde5ea53d5307ca90d8dd76da2ca560b310b12820be2576.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "WITH inserted_job AS (\n INSERT INTO v2_job (\n id, -- 1\n workspace_id, -- 2\n raw_code, -- 3\n raw_lock, -- 4\n raw_flow, -- 5\n tag, -- 6\n parent_job, -- 7\n created_by, -- 8\n permissioned_as, -- 9\n runnable_id, -- 10\n runnable_path, -- 11\n args, -- 12\n kind, -- 13\n trigger, -- 14\n script_lang, -- 15\n same_worker, -- 16\n pre_run_error, -- 17 \n permissioned_as_email, -- 18\n visible_to_owner, -- 19\n flow_innermost_root_job, -- 20\n root_job, -- 38\n concurrent_limit, -- 21\n concurrency_time_window_s, -- 22\n timeout, -- 23\n flow_step_id, -- 24\n cache_ttl, -- 25\n priority, -- 26\n trigger_kind, -- 39\n script_entrypoint_override, -- 12\n preprocessed -- 27,\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,\n $19, $20, $38, $21, $22, $23, $24, $25, $26, $39::job_trigger_kind,\n ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27)\n ),\n inserted_runtime AS (\n INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null)\n ),\n inserted_job_perms AS (\n INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, end_user_email) \n values ($1, $32, $33, $34, $35, $36, $37, $2, $41) \n ON CONFLICT (job_id) DO UPDATE SET email = EXCLUDED.email, username = EXCLUDED.username, is_admin = EXCLUDED.is_admin, is_operator = EXCLUDED.is_operator, folders = EXCLUDED.folders, groups = EXCLUDED.groups, workspace_id = EXCLUDED.workspace_id, end_user_email = EXCLUDED.end_user_email\n )\n INSERT INTO v2_job_queue\n (workspace_id, id, running, scheduled_for, started_at, tag, priority, cache_ignore_s3_path, runnable_settings_handle)\n VALUES ($2, $1, $28, COALESCE($29, now()), CASE WHEN $27 OR $40 THEN now() END, $30, $31, $42, $43)", + "query": "WITH inserted_job AS (\n INSERT INTO v2_job (\n id, -- 1\n workspace_id, -- 2\n raw_code, -- 3\n raw_lock, -- 4\n raw_flow, -- 5\n tag, -- 6\n parent_job, -- 7\n created_by, -- 8\n permissioned_as, -- 9\n runnable_id, -- 10\n runnable_path, -- 11\n args, -- 12\n kind, -- 13\n trigger, -- 14\n script_lang, -- 15\n same_worker, -- 16\n pre_run_error, -- 17 \n permissioned_as_email, -- 18\n visible_to_owner, -- 19\n flow_innermost_root_job, -- 20\n root_job, -- 38\n concurrent_limit, -- 21\n concurrency_time_window_s, -- 22\n timeout, -- 23\n flow_step_id, -- 24\n cache_ttl, -- 25\n priority, -- 26\n trigger_kind, -- 39\n script_entrypoint_override, -- 12\n preprocessed, -- 27,\n labels -- 44\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,\n $19, $20, $38, $21, $22, $23, $24, $25, $26, $39::job_trigger_kind,\n ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27, $44)\n ),\n inserted_runtime AS (\n INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null)\n ),\n inserted_job_perms AS (\n INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, end_user_email) \n values ($1, $32, $33, $34, $35, $36, $37, $2, $41) \n ON CONFLICT (job_id) DO UPDATE SET email = EXCLUDED.email, username = EXCLUDED.username, is_admin = EXCLUDED.is_admin, is_operator = EXCLUDED.is_operator, folders = EXCLUDED.folders, groups = EXCLUDED.groups, workspace_id = EXCLUDED.workspace_id, end_user_email = EXCLUDED.end_user_email\n )\n INSERT INTO v2_job_queue\n (workspace_id, id, running, scheduled_for, started_at, tag, priority, cache_ignore_s3_path, runnable_settings_handle)\n VALUES ($2, $1, $28, COALESCE($29, now()), CASE WHEN $27 OR $40 THEN now() END, $30, $31, $42, $43)", "describe": { "columns": [], "parameters": { @@ -132,10 +132,11 @@ "Bool", "Varchar", "Bool", - "Int8" + "Int8", + "TextArray" ] }, "nullable": [] }, - "hash": "14276a040cb4db88d71fccdc3579e8c0bb132b70668301b535872d1632753e30" + "hash": "5425e2c5e29fc5145dde5ea53d5307ca90d8dd76da2ca560b310b12820be2576" } diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 713ccb9dd3..36ddb8ab9f 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - null + true ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-ad96768ff61fab1cfb9421683bb13b64f5f157fafe114d50fb910dc36ebe0f91.json b/backend/.sqlx/query-642ca096c1c151fc01445593c8d2ed879a9a7be6ff044a9ce1b581ba725665fe.json similarity index 95% rename from backend/.sqlx/query-ad96768ff61fab1cfb9421683bb13b64f5f157fafe114d50fb910dc36ebe0f91.json rename to backend/.sqlx/query-642ca096c1c151fc01445593c8d2ed879a9a7be6ff044a9ce1b581ba725665fe.json index 43f77fe77c..a205bcbbe6 100644 --- a/backend/.sqlx/query-ad96768ff61fab1cfb9421683bb13b64f5f157fafe114d50fb910dc36ebe0f91.json +++ b/backend/.sqlx/query-642ca096c1c151fc01445593c8d2ed879a9a7be6ff044a9ce1b581ba725665fe.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE schedule SET\n enabled = $1,\n email = $2\n WHERE path = $3 AND workspace_id = $4\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip\n ", + "query": "\n UPDATE schedule SET\n enabled = $1,\n email = $2\n WHERE path = $3 AND workspace_id = $4\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ", "describe": { "columns": [ { @@ -162,6 +162,11 @@ "ordinal": 31, "name": "dynamic_skip", "type_info": "Varchar" + }, + { + "ordinal": 32, + "name": "labels", + "type_info": "TextArray" } ], "parameters": { @@ -204,8 +209,9 @@ true, true, true, + true, true ] }, - "hash": "ad96768ff61fab1cfb9421683bb13b64f5f157fafe114d50fb910dc36ebe0f91" + "hash": "642ca096c1c151fc01445593c8d2ed879a9a7be6ff044a9ce1b581ba725665fe" } diff --git a/backend/.sqlx/query-676c758d9c4492dada50edd3ad06dc628e2ba56eab5e1a50c99481da9793759e.json b/backend/.sqlx/query-676c758d9c4492dada50edd3ad06dc628e2ba56eab5e1a50c99481da9793759e.json deleted file mode 100644 index 11339abea1..0000000000 --- a/backend/.sqlx/query-676c758d9c4492dada50edd3ad06dc628e2ba56eab5e1a50c99481da9793759e.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at)\n SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at\n FROM flow\n WHERE path = $2 AND workspace_id = $3", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "676c758d9c4492dada50edd3ad06dc628e2ba56eab5e1a50c99481da9793759e" -} diff --git a/backend/.sqlx/query-209dc4c1b91eeab1c12ffcd9f9e16f315c689ca772c736b333dcdf07c8086087.json b/backend/.sqlx/query-6d992a933bb878733b7afd7a4295b9ad6f5276b60ce20e0378d6148976e02777.json similarity index 67% rename from backend/.sqlx/query-209dc4c1b91eeab1c12ffcd9f9e16f315c689ca772c736b333dcdf07c8086087.json rename to backend/.sqlx/query-6d992a933bb878733b7afd7a4295b9ad6f5276b60ce20e0378d6148976e02777.json index e4fbe7efe0..e32c1e06ed 100644 --- a/backend/.sqlx/query-209dc4c1b91eeab1c12ffcd9f9e16f315c689ca772c736b333dcdf07c8086087.json +++ b/backend/.sqlx/query-6d992a933bb878733b7afd7a4295b9ad6f5276b60ce20e0378d6148976e02777.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n flow_version.id AS version,\n flow_version.value->>'early_return' as early_return,\n flow_version.value->>'preprocessor_module' IS NOT NULL as has_preprocessor,\n (flow_version.value->>'chat_input_enabled')::boolean as chat_input_enabled,\n flow.tag,\n flow.dedicated_worker,\n flow.on_behalf_of_email,\n flow.edited_by\n FROM\n flow_version\n INNER JOIN flow\n ON flow.path = flow_version.path AND\n flow.workspace_id = flow_version.workspace_id\n WHERE\n flow_version.workspace_id = $1 AND\n flow_version.path = $2 AND\n flow_version.id = $3\n ", + "query": "\n SELECT\n flow_version.id AS version,\n flow_version.value->>'early_return' as early_return,\n flow_version.value->>'preprocessor_module' IS NOT NULL as has_preprocessor,\n (flow_version.value->>'chat_input_enabled')::boolean as chat_input_enabled,\n flow.tag,\n flow.dedicated_worker,\n flow.on_behalf_of_email,\n flow.edited_by,\n flow.labels\n FROM\n flow_version\n INNER JOIN flow\n ON flow.path = flow_version.path AND\n flow.workspace_id = flow_version.workspace_id\n WHERE\n flow_version.workspace_id = $1 AND\n flow_version.path = $2 AND\n flow_version.id = $3\n ", "describe": { "columns": [ { @@ -42,6 +42,11 @@ "ordinal": 7, "name": "edited_by", "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "labels", + "type_info": "TextArray" } ], "parameters": { @@ -59,8 +64,9 @@ true, true, true, - false + false, + true ] }, - "hash": "209dc4c1b91eeab1c12ffcd9f9e16f315c689ca772c736b333dcdf07c8086087" + "hash": "6d992a933bb878733b7afd7a4295b9ad6f5276b60ce20e0378d6148976e02777" } diff --git a/backend/.sqlx/query-a33673ebc4d1eb4c3513987dbc43e2c80974598e1d9fe7203145bfc29928ba65.json b/backend/.sqlx/query-71767e6684957af5dff56a1bb64f980f712f92798d11feaeb0832962ad9ddb2e.json similarity index 88% rename from backend/.sqlx/query-a33673ebc4d1eb4c3513987dbc43e2c80974598e1d9fe7203145bfc29928ba65.json rename to backend/.sqlx/query-71767e6684957af5dff56a1bb64f980f712f92798d11feaeb0832962ad9ddb2e.json index 322ddc1f80..96a68f827d 100644 --- a/backend/.sqlx/query-a33673ebc4d1eb4c3513987dbc43e2c80974598e1d9fe7203145bfc29928ba65.json +++ b/backend/.sqlx/query-71767e6684957af5dff56a1bb64f980f712f92798d11feaeb0832962ad9ddb2e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, cache_ignore_s3_path, runnable_settings_handle, language as \"language: ScriptLang\", dedicated_worker, priority, timeout, on_behalf_of_email, created_by FROM script\n WHERE path = $1 AND workspace_id = $2 AND archived = false AND (lock IS NOT NULL OR $3 = false)\n ORDER BY created_at DESC LIMIT 1", + "query": "select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, cache_ignore_s3_path, runnable_settings_handle, language as \"language: ScriptLang\", dedicated_worker, priority, timeout, on_behalf_of_email, created_by, labels FROM script\n WHERE path = $1 AND workspace_id = $2 AND archived = false AND (lock IS NOT NULL OR $3 = false)\n ORDER BY created_at DESC LIMIT 1", "describe": { "columns": [ { @@ -114,6 +114,11 @@ "ordinal": 15, "name": "created_by", "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "labels", + "type_info": "TextArray" } ], "parameters": { @@ -139,8 +144,9 @@ true, true, true, - false + false, + true ] }, - "hash": "a33673ebc4d1eb4c3513987dbc43e2c80974598e1d9fe7203145bfc29928ba65" + "hash": "71767e6684957af5dff56a1bb64f980f712f92798d11feaeb0832962ad9ddb2e" } diff --git a/backend/.sqlx/query-4d983f1e3e63a1a70edf5d867d9f23f2069a7a4ba1dcc1331ecccdf1c6a95cb8.json b/backend/.sqlx/query-790d79ec7abe6ebe1092afd9de4c5fc383272d2057ac5b47a7425f095f4e8788.json similarity index 85% rename from backend/.sqlx/query-4d983f1e3e63a1a70edf5d867d9f23f2069a7a4ba1dcc1331ecccdf1c6a95cb8.json rename to backend/.sqlx/query-790d79ec7abe6ebe1092afd9de4c5fc383272d2057ac5b47a7425f095f4e8788.json index 35eb0d8599..29885406c0 100644 --- a/backend/.sqlx/query-4d983f1e3e63a1a70edf5d867d9f23f2069a7a4ba1dcc1331ecccdf1c6a95cb8.json +++ b/backend/.sqlx/query-790d79ec7abe6ebe1092afd9de4c5fc383272d2057ac5b47a7425f095f4e8788.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39)", + "query": "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40)", "describe": { "columns": [], "parameters": { @@ -89,10 +89,11 @@ "Int4", "Bool", "Int8", - "Jsonb" + "Jsonb", + "TextArray" ] }, "nullable": [] }, - "hash": "4d983f1e3e63a1a70edf5d867d9f23f2069a7a4ba1dcc1331ecccdf1c6a95cb8" + "hash": "790d79ec7abe6ebe1092afd9de4c5fc383272d2057ac5b47a7425f095f4e8788" } diff --git a/backend/.sqlx/query-86c0ef92652085b93979a8836bedb33a366a84426a115c75f58dcc43ab91705e.json b/backend/.sqlx/query-86c0ef92652085b93979a8836bedb33a366a84426a115c75f58dcc43ab91705e.json new file mode 100644 index 0000000000..65308f2a5d --- /dev/null +++ b/backend/.sqlx/query-86c0ef92652085b93979a8836bedb33a366a84426a115c75f58dcc43ab91705e.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE http_trigger SET labels = $1 WHERE workspace_id = $2 AND path = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "TextArray", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "86c0ef92652085b93979a8836bedb33a366a84426a115c75f58dcc43ab91705e" +} diff --git a/backend/.sqlx/query-a969194571dd3f12e628ce0f01b0ddc09bbcf4506eff2290664c295d16fec4ae.json b/backend/.sqlx/query-a969194571dd3f12e628ce0f01b0ddc09bbcf4506eff2290664c295d16fec4ae.json new file mode 100644 index 0000000000..527e41e133 --- /dev/null +++ b/backend/.sqlx/query-a969194571dd3f12e628ce0f01b0ddc09bbcf4506eff2290664c295d16fec4ae.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Text" + ] + }, + "nullable": [] + }, + "hash": "a969194571dd3f12e628ce0f01b0ddc09bbcf4506eff2290664c295d16fec4ae" +} diff --git a/backend/.sqlx/query-b657b718ec381fbb1d41841c88118b88c4d7ea8f0cde663ed8f8226faf75b0f6.json b/backend/.sqlx/query-b657b718ec381fbb1d41841c88118b88c4d7ea8f0cde663ed8f8226faf75b0f6.json new file mode 100644 index 0000000000..483cfc562e --- /dev/null +++ b/backend/.sqlx/query-b657b718ec381fbb1d41841c88118b88c4d7ea8f0cde663ed8f8226faf75b0f6.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE app SET labels = $1 WHERE path = $2 AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "TextArray", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "b657b718ec381fbb1d41841c88118b88c4d7ea8f0cde663ed8f8226faf75b0f6" +} diff --git a/backend/.sqlx/query-6bde827da007b470b9d0acccfc3e00ce6aac650b9138a236f34c614eed753849.json b/backend/.sqlx/query-b6f95b3fd1d0431d96d0409424dbb20cb3020f93454978d078082482b86cbebe.json similarity index 65% rename from backend/.sqlx/query-6bde827da007b470b9d0acccfc3e00ce6aac650b9138a236f34c614eed753849.json rename to backend/.sqlx/query-b6f95b3fd1d0431d96d0409424dbb20cb3020f93454978d078082482b86cbebe.json index cf8050b606..3c45fe92ba 100644 --- a/backend/.sqlx/query-6bde827da007b470b9d0acccfc3e00ce6aac650b9138a236f34c614eed753849.json +++ b/backend/.sqlx/query-b6f95b3fd1d0431d96d0409424dbb20cb3020f93454978d078082482b86cbebe.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO flow (\n workspace_id, path, summary, description,\n dependency_job, lock_error_logs, draft_only, tag,\n dedicated_worker, visible_to_runner_only, on_behalf_of_email,\n ws_error_handler_muted,\n value, schema, edited_by, edited_at\n ) VALUES (\n $1, $2, $3, $4,\n NULL, '', $5, $6,\n $7, $8, $9,\n $10,\n $11, $12::text::json, $13, now()\n )", + "query": "INSERT INTO flow (\n workspace_id, path, summary, description,\n dependency_job, lock_error_logs, draft_only, tag,\n dedicated_worker, visible_to_runner_only, on_behalf_of_email,\n ws_error_handler_muted,\n value, schema, edited_by, edited_at, labels\n ) VALUES (\n $1, $2, $3, $4,\n NULL, '', $5, $6,\n $7, $8, $9,\n $10,\n $11, $12::text::json, $13, now(), $14\n )", "describe": { "columns": [], "parameters": { @@ -17,10 +17,11 @@ "Bool", "Jsonb", "Text", - "Varchar" + "Varchar", + "TextArray" ] }, "nullable": [] }, - "hash": "6bde827da007b470b9d0acccfc3e00ce6aac650b9138a236f34c614eed753849" + "hash": "b6f95b3fd1d0431d96d0409424dbb20cb3020f93454978d078082482b86cbebe" } diff --git a/backend/.sqlx/query-b782179261b4054289a1a595f6e06ee2035ae9811bc49abce903a04439baf800.json b/backend/.sqlx/query-b782179261b4054289a1a595f6e06ee2035ae9811bc49abce903a04439baf800.json new file mode 100644 index 0000000000..cdf942ca54 --- /dev/null +++ b/backend/.sqlx/query-b782179261b4054289a1a595f6e06ee2035ae9811bc49abce903a04439baf800.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT unnest(labels) as \"label!\" FROM (\n SELECT labels FROM script WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM flow WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM resource WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM variable WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM schedule WHERE workspace_id = $1 AND labels IS NOT NULL\n UNION ALL SELECT labels FROM app WHERE workspace_id = $1 AND labels IS NOT NULL\n ) t ORDER BY 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "label!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "b782179261b4054289a1a595f6e06ee2035ae9811bc49abce903a04439baf800" +} diff --git a/backend/.sqlx/query-54b4c762add9b1ebfdb2a6d5abd6d20e86dc0e6544f0bb22fa4ec68aa54a4dc8.json b/backend/.sqlx/query-bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e.json similarity index 79% rename from backend/.sqlx/query-54b4c762add9b1ebfdb2a6d5abd6d20e86dc0e6544f0bb22fa4ec68aa54a4dc8.json rename to backend/.sqlx/query-bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e.json index af8c1d983e..578edc6044 100644 --- a/backend/.sqlx/query-54b4c762add9b1ebfdb2a6d5abd6d20e86dc0e6544f0bb22fa4ec68aa54a4dc8.json +++ b/backend/.sqlx/query-bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n permissioned_as = $26\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip\n ", + "query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n permissioned_as = $26,\n labels = COALESCE($27, labels)\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ", "describe": { "columns": [ { @@ -162,6 +162,11 @@ "ordinal": 31, "name": "dynamic_skip", "type_info": "Varchar" + }, + { + "ordinal": 32, + "name": "labels", + "type_info": "TextArray" } ], "parameters": { @@ -191,7 +196,8 @@ "Varchar", "Varchar", "Varchar", - "Varchar" + "Varchar", + "TextArray" ] }, "nullable": [ @@ -226,8 +232,9 @@ true, true, true, + true, true ] }, - "hash": "54b4c762add9b1ebfdb2a6d5abd6d20e86dc0e6544f0bb22fa4ec68aa54a4dc8" + "hash": "bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e" } diff --git a/backend/.sqlx/query-77ac7257be02fb04c4b3213e2221e6f60621b4b2909d770de744ef5671e12ed9.json b/backend/.sqlx/query-c06796e8647cf278c6e0809562aaaa872403890eae3e606f379343671c2fff02.json similarity index 76% rename from backend/.sqlx/query-77ac7257be02fb04c4b3213e2221e6f60621b4b2909d770de744ef5671e12ed9.json rename to backend/.sqlx/query-c06796e8647cf278c6e0809562aaaa872403890eae3e606f379343671c2fff02.json index b61fdcb8ed..1b1a8da18c 100644 --- a/backend/.sqlx/query-77ac7257be02fb04c4b3213e2221e6f60621b4b2909d770de744ef5671e12ed9.json +++ b/backend/.sqlx/query-c06796e8647cf278c6e0809562aaaa872403890eae3e606f379343671c2fff02.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE\n flow\n SET\n path = $1,\n summary = $2,\n description = $3,\n dependency_job = NULL,\n lock_error_logs = '',\n draft_only = NULL,\n tag = $4,\n dedicated_worker = $5,\n visible_to_runner_only = $6,\n on_behalf_of_email = $7,\n ws_error_handler_muted = $8,\n value = $9,\n schema = $10::text::json,\n edited_by = $11,\n edited_at = now()\n WHERE\n path = $12 AND workspace_id = $13", + "query": "\n UPDATE\n flow\n SET\n path = $1,\n summary = $2,\n description = $3,\n dependency_job = NULL,\n lock_error_logs = '',\n draft_only = NULL,\n tag = $4,\n dedicated_worker = $5,\n visible_to_runner_only = $6,\n on_behalf_of_email = $7,\n ws_error_handler_muted = $8,\n value = $9,\n schema = $10::text::json,\n edited_by = $11,\n edited_at = now(),\n labels = COALESCE($14, labels)\n WHERE\n path = $12 AND workspace_id = $13", "describe": { "columns": [], "parameters": { @@ -17,10 +17,11 @@ "Text", "Varchar", "Text", - "Text" + "Text", + "TextArray" ] }, "nullable": [] }, - "hash": "77ac7257be02fb04c4b3213e2221e6f60621b4b2909d770de744ef5671e12ed9" + "hash": "c06796e8647cf278c6e0809562aaaa872403890eae3e606f379343671c2fff02" } diff --git a/backend/.sqlx/query-ddda19024473b4e5b1d450bf56c667056f6b3696dea7d73758e53f825bfce13e.json b/backend/.sqlx/query-ddda19024473b4e5b1d450bf56c667056f6b3696dea7d73758e53f825bfce13e.json new file mode 100644 index 0000000000..be8864a85d --- /dev/null +++ b/backend/.sqlx/query-ddda19024473b4e5b1d450bf56c667056f6b3696dea7d73758e53f825bfce13e.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels)\n SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels\n FROM flow\n WHERE path = $2 AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "ddda19024473b4e5b1d450bf56c667056f6b3696dea7d73758e53f825bfce13e" +} diff --git a/backend/.sqlx/query-deac41298e8b0d0870e314fef0813c24dd55d63bda78a0a5f35ed6f22bea6bef.json b/backend/.sqlx/query-deac41298e8b0d0870e314fef0813c24dd55d63bda78a0a5f35ed6f22bea6bef.json new file mode 100644 index 0000000000..67ce9d8719 --- /dev/null +++ b/backend/.sqlx/query-deac41298e8b0d0870e314fef0813c24dd55d63bda78a0a5f35ed6f22bea6bef.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO resource\n (workspace_id, path, value, description, resource_type, created_by, edited_at, labels)\n VALUES ($1, $2, $3, $4, $5, $6, now(), $7) ON CONFLICT (workspace_id, path)\n DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, resource_type = EXCLUDED.resource_type, edited_at = now(), labels = EXCLUDED.labels", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Jsonb", + "Text", + "Varchar", + "Varchar", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "deac41298e8b0d0870e314fef0813c24dd55d63bda78a0a5f35ed6f22bea6bef" +} diff --git a/backend/.sqlx/query-6b53f7c4bb73177316d6134698f3979f51b53dcd4d8ec50d312c9e7fe31ad5f5.json b/backend/.sqlx/query-e4836a1ee97e4723bddc28b94e23a54b6aa1f458f3ecca8f35432153e54b143d.json similarity index 62% rename from backend/.sqlx/query-6b53f7c4bb73177316d6134698f3979f51b53dcd4d8ec50d312c9e7fe31ad5f5.json rename to backend/.sqlx/query-e4836a1ee97e4723bddc28b94e23a54b6aa1f458f3ecca8f35432153e54b143d.json index 0c82c9dd74..b101845b0e 100644 --- a/backend/.sqlx/query-6b53f7c4bb73177316d6134698f3979f51b53dcd4d8ec50d312c9e7fe31ad5f5.json +++ b/backend/.sqlx/query-e4836a1ee97e4723bddc28b94e23a54b6aa1f458f3ecca8f35432153e54b143d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO app\n (workspace_id, path, summary, policy, versions, draft_only, custom_path)\n VALUES ($1, $2, $3, $4, '{}', $5, $6) RETURNING id", + "query": "INSERT INTO app\n (workspace_id, path, summary, policy, versions, draft_only, custom_path, labels)\n VALUES ($1, $2, $3, $4, '{}', $5, $6, $7) RETURNING id", "describe": { "columns": [ { @@ -16,12 +16,13 @@ "Varchar", "Jsonb", "Bool", - "Text" + "Text", + "TextArray" ] }, "nullable": [ false ] }, - "hash": "6b53f7c4bb73177316d6134698f3979f51b53dcd4d8ec50d312c9e7fe31ad5f5" + "hash": "e4836a1ee97e4723bddc28b94e23a54b6aa1f458f3ecca8f35432153e54b143d" } diff --git a/backend/.sqlx/query-f6f850c419771b193f506ceca63de805cbe6bdee314ca40d912010db307bd13e.json b/backend/.sqlx/query-f6f850c419771b193f506ceca63de805cbe6bdee314ca40d912010db307bd13e.json new file mode 100644 index 0000000000..7d9c00c0c6 --- /dev/null +++ b/backend/.sqlx/query-f6f850c419771b193f506ceca63de805cbe6bdee314ca40d912010db307bd13e.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE resource SET labels = $1 WHERE path = $2 AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "TextArray", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "f6f850c419771b193f506ceca63de805cbe6bdee314ca40d912010db307bd13e" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 9e715551dd..9ba2542cc7 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -41b0d1cb312919109407640fc4bd7060cfe0e107 +b30d25cf5f7ca7f94509fe9a732e22ab2186f929 \ No newline at end of file diff --git a/backend/migrations/20260330000000_add_labels.down.sql b/backend/migrations/20260330000000_add_labels.down.sql new file mode 100644 index 0000000000..62bfa4c015 --- /dev/null +++ b/backend/migrations/20260330000000_add_labels.down.sql @@ -0,0 +1,16 @@ +ALTER TABLE script DROP COLUMN IF EXISTS labels; +ALTER TABLE flow DROP COLUMN IF EXISTS labels; +ALTER TABLE app DROP COLUMN IF EXISTS labels; +ALTER TABLE raw_app DROP COLUMN IF EXISTS labels; +ALTER TABLE schedule DROP COLUMN IF EXISTS labels; +ALTER TABLE http_trigger DROP COLUMN IF EXISTS labels; +ALTER TABLE websocket_trigger DROP COLUMN IF EXISTS labels; +ALTER TABLE kafka_trigger DROP COLUMN IF EXISTS labels; +ALTER TABLE nats_trigger DROP COLUMN IF EXISTS labels; +ALTER TABLE mqtt_trigger DROP COLUMN IF EXISTS labels; +ALTER TABLE postgres_trigger DROP COLUMN IF EXISTS labels; +ALTER TABLE sqs_trigger DROP COLUMN IF EXISTS labels; +ALTER TABLE gcp_trigger DROP COLUMN IF EXISTS labels; +ALTER TABLE email_trigger DROP COLUMN IF EXISTS labels; +ALTER TABLE resource DROP COLUMN IF EXISTS labels; +ALTER TABLE variable DROP COLUMN IF EXISTS labels; diff --git a/backend/migrations/20260330000000_add_labels.up.sql b/backend/migrations/20260330000000_add_labels.up.sql new file mode 100644 index 0000000000..1e93536418 --- /dev/null +++ b/backend/migrations/20260330000000_add_labels.up.sql @@ -0,0 +1,33 @@ +ALTER TABLE script ADD COLUMN IF NOT EXISTS labels text[]; +ALTER TABLE flow ADD COLUMN IF NOT EXISTS labels text[]; +ALTER TABLE app ADD COLUMN IF NOT EXISTS labels text[]; +ALTER TABLE raw_app ADD COLUMN IF NOT EXISTS labels text[]; +ALTER TABLE schedule ADD COLUMN IF NOT EXISTS labels text[]; +ALTER TABLE http_trigger ADD COLUMN IF NOT EXISTS labels text[]; +ALTER TABLE websocket_trigger ADD COLUMN IF NOT EXISTS labels text[]; +ALTER TABLE kafka_trigger ADD COLUMN IF NOT EXISTS labels text[]; +ALTER TABLE nats_trigger ADD COLUMN IF NOT EXISTS labels text[]; +ALTER TABLE mqtt_trigger ADD COLUMN IF NOT EXISTS labels text[]; +ALTER TABLE postgres_trigger ADD COLUMN IF NOT EXISTS labels text[]; +ALTER TABLE sqs_trigger ADD COLUMN IF NOT EXISTS labels text[]; +ALTER TABLE gcp_trigger ADD COLUMN IF NOT EXISTS labels text[]; +ALTER TABLE email_trigger ADD COLUMN IF NOT EXISTS labels text[]; +ALTER TABLE resource ADD COLUMN IF NOT EXISTS labels text[]; +ALTER TABLE variable ADD COLUMN IF NOT EXISTS labels text[]; + +CREATE INDEX IF NOT EXISTS idx_script_labels ON script USING GIN (labels) WHERE labels IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_flow_labels ON flow USING GIN (labels) WHERE labels IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_app_labels ON app USING GIN (labels) WHERE labels IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_raw_app_labels ON raw_app USING GIN (labels) WHERE labels IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_schedule_labels ON schedule USING GIN (labels) WHERE labels IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_resource_labels ON resource USING GIN (labels) WHERE labels IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_variable_labels ON variable USING GIN (labels) WHERE labels IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_http_trigger_labels ON http_trigger USING GIN (labels) WHERE labels IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_websocket_trigger_labels ON websocket_trigger USING GIN (labels) WHERE labels IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_kafka_trigger_labels ON kafka_trigger USING GIN (labels) WHERE labels IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_nats_trigger_labels ON nats_trigger USING GIN (labels) WHERE labels IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_mqtt_trigger_labels ON mqtt_trigger USING GIN (labels) WHERE labels IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_postgres_trigger_labels ON postgres_trigger USING GIN (labels) WHERE labels IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_sqs_trigger_labels ON sqs_trigger USING GIN (labels) WHERE labels IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_gcp_trigger_labels ON gcp_trigger USING GIN (labels) WHERE labels IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_email_trigger_labels ON email_trigger USING GIN (labels) WHERE labels IS NOT NULL; diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 582bf7b684..8446017263 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -38,7 +38,7 @@ account: workspace_id(char), id(int), expires_at(ts), refresh_token(char), clien agent_token_blacklist: token(char), expires_at(ts), blacklisted_at(ts), blacklisted_by(char) ai_agent_memory: workspace_id(char), conversation_id(uuid), step_id(char), messages(jsonb), created_at(ts), updated_at(ts) alerts: id(int), alert_type(char), message(text), created_at(ts), acknowledged(bool), workspace_id(text), acknowledged_workspace(bool), resource(text) -app: id(bigint), workspace_id(char), path(char), summary(char), policy(jsonb), versions(bigint[]), extra_perms(jsonb), draft_only(bool), custom_path(text) +app: id(bigint), workspace_id(char), path(char), summary(char), policy(jsonb), versions(bigint[]), extra_perms(jsonb), draft_only(bool), custom_path(text), labels(text[]) FK: (workspace_id) -> workspace(id) app_bundles: app_version_id(bigint), w_id(char), file_type(char), data(bytes) app_script: id(bigint), app(bigint), hash(char(64)), lock(text), code(text), code_sha256(char(64)) @@ -72,9 +72,9 @@ deployment_metadata: workspace_id(char), path(char), script_hash(bigint), app_ve draft: workspace_id(char), path(char), typ(draft_type), value(json), created_at(ts) FK: (workspace_id) -> workspace(id) email_to_igroup: email(char), igroup(char) -email_trigger: path(char), local_part(char), workspaced_local_part(bool), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode) +email_trigger: path(char), local_part(char), workspaced_local_part(bool), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), labels(text[]) favorite: usr(char), workspace_id(char), path(char), favorite_kind(favorite_kind) -flow: workspace_id(char), path(char), summary(text), description(text), value(jsonb), edited_by(char), edited_at(ts), archived(bool), schema(json), extra_perms(jsonb), dependency_job(uuid), draft_only(bool), tag(char), ws_error_handler_muted(bool), dedicated_worker(bool), timeout(int), visible_to_runner_only(bool), concurrency_key(char), versions(bigint[]), on_behalf_of_email(text), lock_error_logs(text) +flow: workspace_id(char), path(char), summary(text), description(text), value(jsonb), edited_by(char), edited_at(ts), archived(bool), schema(json), extra_perms(jsonb), dependency_job(uuid), draft_only(bool), tag(char), ws_error_handler_muted(bool), dedicated_worker(bool), timeout(int), visible_to_runner_only(bool), concurrency_key(char), versions(bigint[]), on_behalf_of_email(text), lock_error_logs(text), labels(text[]) FK: (workspace_id) -> workspace(id) flow_conversation: id(uuid), workspace_id(char), flow_path(char), title(char), created_at(ts), updated_at(ts), created_by(char) FK: (workspace_id) -> workspace(id) @@ -91,14 +91,14 @@ folder: name(char), workspace_id(char), display_name(char), owners(char), extra_ FK: (workspace_id) -> workspace(id) folder_permission_history: id(bigint), workspace_id(char), folder_name(char), changed_by(char), changed_at(ts), change_type(char), affected(char) FK: (workspace_id, folder_name) -> folder(workspace_id, name) -gcp_trigger: gcp_resource_path(char), topic_id(char), subscription_id(char), delivery_type(delivery_mode), delivery_config(jsonb), path(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), subscription_mode(gcp_subscription_mode), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), auto_acknowledge_msg(bool), ack_deadline(int), mode(trigger_mode) +gcp_trigger: gcp_resource_path(char), topic_id(char), subscription_id(char), delivery_type(delivery_mode), delivery_config(jsonb), path(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), subscription_mode(gcp_subscription_mode), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), auto_acknowledge_msg(bool), ack_deadline(int), mode(trigger_mode), labels(text[]) global_settings: name(char), value(jsonb), updated_at(ts) group_: workspace_id(char), name(char), summary(text), extra_perms(jsonb) FK: (workspace_id) -> workspace(id) group_permission_history: id(bigint), workspace_id(char), group_name(char), changed_by(char), changed_at(ts), change_type(char), member_affected(char) FK: (workspace_id, group_name) -> group_(workspace_id, name) healthchecks: id(bigint), check_type(char), healthy(bool), created_at(ts) -http_trigger: path(char), route_path(char), route_path_key(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), authentication_method(authentication_method), http_method(http_method), static_asset_config(jsonb), is_static_website(bool), workspaced_route(bool), wrap_body(bool), raw_string(bool), authentication_resource_path(char), summary(char), description(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), request_type(request_type), mode(trigger_mode) +http_trigger: path(char), route_path(char), route_path_key(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), authentication_method(authentication_method), http_method(http_method), static_asset_config(jsonb), is_static_website(bool), workspaced_route(bool), wrap_body(bool), raw_string(bool), authentication_resource_path(char), summary(char), description(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), request_type(request_type), mode(trigger_mode), labels(text[]) input: id(uuid), workspace_id(char), runnable_id(char), runnable_type(runnable_type), name(text), args(jsonb), created_at(ts), created_by(char), is_public(bool) FK: (workspace_id) -> workspace(id) instance_group: name(char), summary(char), id(char), scim_display_name(char), external_id(char) @@ -111,7 +111,7 @@ job_stats: workspace_id(char), job_id(uuid), metric_id(char), metric_name(char), FK: (workspace_id) -> workspace(id) kafka_pending_commits: id(bigint), workspace_id(char), kafka_trigger_path(char), topic(char), partition(int), offset(bigint), created_at(ts) FK: (workspace_id, kafka_trigger_path) -> kafka_trigger(workspace_id, path) -kafka_trigger: path(char), kafka_resource_path(char), topics(char), group_id(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), filters(jsonb[]), auto_commit(bool) +kafka_trigger: path(char), kafka_resource_path(char), topics(char), group_id(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), filters(jsonb[]), auto_commit(bool), labels(text[]) log_file: hostname(char), log_ts(ts), ok_lines(bigint), err_lines(bigint), mode(log_mode), worker_group(char), file_path(char), json_fmt(bool) magic_link: email(char), token(char), expiration(ts) mcp_oauth_client: mcp_server_url(text), client_id(text), client_secret(text), client_secret_expires_at(ts), token_endpoint(text), created_at(ts) @@ -121,10 +121,10 @@ mcp_oauth_server_client: client_id(char), client_name(char), redirect_uris(text[ mcp_oauth_server_code: code(char), client_id(char), user_email(char), workspace_id(char), scopes(text[]), redirect_uri(text), code_challenge(char), code_challenge_method(char), created_at(ts), expires_at(ts) FK: (client_id) -> mcp_oauth_server_client(client_id) metrics: id(char), value(jsonb), created_at(ts) -mqtt_trigger: mqtt_resource_path(char), subscribe_topics(jsonb[]), client_version(mqtt_client_version), v5_config(jsonb), v3_config(jsonb), client_id(char), path(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode) +mqtt_trigger: mqtt_resource_path(char), subscribe_topics(jsonb[]), client_version(mqtt_client_version), v5_config(jsonb), v3_config(jsonb), client_id(char), path(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), labels(text[]) native_trigger: external_id(char), workspace_id(char), service_name(native_trigger_service), script_path(char), is_flow(bool), webhook_token_hash(char), service_config(jsonb), error(text), created_at(ts), updated_at(ts) FK: (workspace_id) -> workspace(id) -nats_trigger: path(char), nats_resource_path(char), subjects(char), stream_name(char), consumer_name(char), use_jetstream(bool), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode) +nats_trigger: path(char), nats_resource_path(char), subjects(char), stream_name(char), consumer_name(char), use_jetstream(bool), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), labels(text[]) FK: (workspace_id) -> workspace(id) notify_event: id(bigint), channel(text), payload(text), created_at(ts) otel_traces: trace_id(bytes), span_id(bytes), trace_state(text), parent_span_id(bytes), flags(int), name(text), kind(int), start_time_unix_nano(bigint), end_time_unix_nano(bigint), attributes(jsonb), dropped_attributes_count(int), events(jsonb), dropped_events_count(int), links(jsonb), dropped_links_count(int), status(jsonb) @@ -133,23 +133,23 @@ parallel_monitor_lock: parent_flow_id(uuid), job_id(uuid), last_ping(ts) password: email(char), password_hash(char), login_type(char), super_admin(bool), verified(bool), name(char), company(char), first_time_user(bool), username(char), devops(bool) pending_user: email(char), created_at(ts), username(char) pip_resolution_cache: hash(char), expiration(ts), lockfile(text) -postgres_trigger: path(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), postgres_resource_path(char), error(text), server_id(char), last_server_ping(ts), replication_slot_name(char), publication_name(char), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode) +postgres_trigger: path(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), postgres_resource_path(char), error(text), server_id(char), last_server_ping(ts), replication_slot_name(char), publication_name(char), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), labels(text[]) FK: (workspace_id) -> workspace(id) -raw_app: path(char), version(int), workspace_id(char), summary(char), edited_at(ts), data(text), extra_perms(jsonb) +raw_app: path(char), version(int), workspace_id(char), summary(char), edited_at(ts), data(text), extra_perms(jsonb), labels(text[]) FK: (workspace_id) -> workspace(id) -resource: workspace_id(char), path(char), value(jsonb), description(text), resource_type(char), extra_perms(jsonb), edited_at(ts), created_by(char) +resource: workspace_id(char), path(char), value(jsonb), description(text), resource_type(char), extra_perms(jsonb), edited_at(ts), created_by(char), labels(text[]) FK: (workspace_id) -> workspace(id) resource_type: workspace_id(char), name(char), schema(jsonb), description(text), edited_at(ts), created_by(char), format_extension(char), is_fileset(bool) FK: (workspace_id) -> workspace(id) resume_job: id(uuid), job(uuid), flow(uuid), created_at(ts), value(jsonb), approver(char), resume_id(int), approved(bool) FK: (flow) -> v2_job_queue(id) runnable_settings: hash(bigint), debouncing_settings(bigint), concurrency_settings(bigint) -schedule: workspace_id(char), path(char), edited_by(char), edited_at(ts), schedule(char), enabled(bool), script_path(char), args(jsonb), extra_perms(jsonb), is_flow(bool), email(char), error(text), timezone(char), on_failure(char), on_recovery(char), on_failure_times(int), on_failure_exact(bool), on_failure_extra_args(jsonb), on_recovery_times(int), on_recovery_extra_args(jsonb), ws_error_handler_muted(bool), retry(jsonb), summary(char), no_flow_overlap(bool), tag(char), paused_until(ts), on_success(char), on_success_extra_args(jsonb), cron_version(text), description(text), dynamic_skip(char) +schedule: workspace_id(char), path(char), edited_by(char), edited_at(ts), schedule(char), enabled(bool), script_path(char), args(jsonb), extra_perms(jsonb), is_flow(bool), email(char), error(text), timezone(char), on_failure(char), on_recovery(char), on_failure_times(int), on_failure_exact(bool), on_failure_extra_args(jsonb), on_recovery_times(int), on_recovery_extra_args(jsonb), ws_error_handler_muted(bool), retry(jsonb), summary(char), no_flow_overlap(bool), tag(char), paused_until(ts), on_success(char), on_success_extra_args(jsonb), cron_version(text), description(text), dynamic_skip(char), labels(text[]) FK: (workspace_id) -> workspace(id) -script: workspace_id(char), hash(bigint), path(char), parent_hashes(bigint[]), summary(text), description(text), content(text), created_by(char), created_at(ts), archived(bool), schema(json), deleted(bool), is_template(bool), extra_perms(jsonb), lock(text), lock_error_logs(text), language(script_lang), kind(script_kind), tag(char), draft_only(bool), envs(char), concurrent_limit(int), concurrency_time_window_s(int), cache_ttl(int), dedicated_worker(bool), ws_error_handler_muted(bool), priority(smallint), timeout(int), delete_after_use(bool), restart_unless_cancelled(bool), concurrency_key(char), visible_to_runner_only(bool), auto_kind(varchar), codebase(char), has_preprocessor(bool), on_behalf_of_email(text), schema_validation(bool), assets(jsonb), debounce_key(char), debounce_delay_s(int), cache_ignore_s3_path(bool), runnable_settings_handle(bigint) +script: workspace_id(char), hash(bigint), path(char), parent_hashes(bigint[]), summary(text), description(text), content(text), created_by(char), created_at(ts), archived(bool), schema(json), deleted(bool), is_template(bool), extra_perms(jsonb), lock(text), lock_error_logs(text), language(script_lang), kind(script_kind), tag(char), draft_only(bool), envs(char), concurrent_limit(int), concurrency_time_window_s(int), cache_ttl(int), dedicated_worker(bool), ws_error_handler_muted(bool), priority(smallint), timeout(int), delete_after_use(bool), restart_unless_cancelled(bool), concurrency_key(char), visible_to_runner_only(bool), auto_kind(varchar), codebase(char), has_preprocessor(bool), on_behalf_of_email(text), schema_validation(bool), assets(jsonb), debounce_key(char), debounce_delay_s(int), cache_ignore_s3_path(bool), runnable_settings_handle(bigint), labels(text[]) FK: (workspace_id) -> workspace(id) skip_workspace_diff_tally: workspace_id(char), added_at(ts) -sqs_trigger: path(char), queue_url(char), aws_resource_path(char), message_attributes(text[]), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), error(text), server_id(char), last_server_ping(ts), aws_auth_resource_type(aws_auth_resource_type), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode) +sqs_trigger: path(char), queue_url(char), aws_resource_path(char), message_attributes(text[]), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), error(text), server_id(char), last_server_ping(ts), aws_auth_resource_type(aws_auth_resource_type), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), labels(text[]) FK: (workspace_id) -> workspace(id) trashbin: id(bigint), workspace_id(char), item_kind(char), item_path(char), item_data(jsonb), deleted_by(char), deleted_at(ts), expires_at(ts) FK: (workspace_id) -> workspace(id) @@ -173,9 +173,9 @@ v2_job_runtime: id(uuid), ping(ts), memory_peak(int) FK: (id) -> v2_job_queue(id) v2_job_status: id(uuid), flow_status(jsonb), flow_leaf_jobs(jsonb), workflow_as_code_status(jsonb) FK: (id) -> v2_job_queue(id) -variable: workspace_id(char), path(char), value(char), is_secret(bool), description(char), extra_perms(jsonb), account(int), is_oauth(bool), expires_at(ts) +variable: workspace_id(char), path(char), value(char), is_secret(bool), description(char), extra_perms(jsonb), account(int), is_oauth(bool), expires_at(ts), labels(text[]) FK: (workspace_id) -> workspace(id) -websocket_trigger: path(char), url(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), filters(jsonb[]), initial_messages(jsonb[]), url_runnable_args(jsonb), can_return_message(bool), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), can_return_error_result(bool), mode(trigger_mode) +websocket_trigger: path(char), url(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), filters(jsonb[]), initial_messages(jsonb[]), url_runnable_args(jsonb), can_return_message(bool), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), can_return_error_result(bool), mode(trigger_mode), labels(text[]) windmill_migrations: name(text), created_at(ts) worker_group_job_stats: hour(bigint), worker_group(text), script_lang(char), workspace_id(char), job_count(int), total_duration_ms(bigint) FK: (workspace_id) -> workspace(id) diff --git a/backend/tests/dependency_map.rs b/backend/tests/dependency_map.rs index ba7b9bd546..86bca53984 100644 --- a/backend/tests/dependency_map.rs +++ b/backend/tests/dependency_map.rs @@ -450,6 +450,7 @@ def main(): on_behalf_of_email: None, preserve_on_behalf_of: None, ws_error_handler_muted: None, + labels: None, }) .send() .await @@ -511,6 +512,7 @@ def main(): deployment_message: None, custom_path: None, preserve_on_behalf_of: None, + labels: None, }) .send() .await diff --git a/backend/tests/error_handler.rs b/backend/tests/error_handler.rs index b117cbfba3..04658988a5 100644 --- a/backend/tests/error_handler.rs +++ b/backend/tests/error_handler.rs @@ -162,7 +162,7 @@ export async function main(path: string, email: string, job_id: string, is_flow: priority: None, apply_preprocessor: false, concurrency_settings: ConcurrencySettings::default(), - debouncing_settings: DebouncingSettings::default(), + debouncing_settings: DebouncingSettings::default(), labels: None, }) .run_until_complete(&db, false, server.addr.port()) .await; @@ -285,7 +285,7 @@ async fn test_error_handler_muted_on_script(db: Pool) -> anyhow::Resul priority: None, apply_preprocessor: false, concurrency_settings: ConcurrencySettings::default(), - debouncing_settings: DebouncingSettings::default(), + debouncing_settings: DebouncingSettings::default(), labels: None, }) .run_until_complete(&db, false, server.addr.port()) .await; @@ -380,7 +380,7 @@ async fn test_error_handler_not_triggered_on_success(db: Pool) -> anyh priority: None, apply_preprocessor: false, concurrency_settings: ConcurrencySettings::default(), - debouncing_settings: DebouncingSettings::default(), + debouncing_settings: DebouncingSettings::default(), labels: None, }) .run_until_complete(&db, false, server.addr.port()) .await; diff --git a/backend/tests/job_payload.rs b/backend/tests/job_payload.rs index 4a8355b78d..393dca7238 100644 --- a/backend/tests/job_payload.rs +++ b/backend/tests/job_payload.rs @@ -49,7 +49,7 @@ mod job_payload { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(), debouncing_settings: - windmill_common::runnable_settings::DebouncingSettings::default(), + windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, @@ -89,7 +89,7 @@ mod job_payload { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(), debouncing_settings: - windmill_common::runnable_settings::DebouncingSettings::default(), + windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, }) .run_until_complete_with(db, false, port, |id| async move { let job = sqlx::query!("SELECT preprocessed FROM v2_job WHERE id = $1", id) @@ -424,7 +424,7 @@ mod job_payload { path: "f/system/hello_with_nodes_flow".to_string(), dedicated_worker: None, apply_preprocessor: false, - version: 1443253234253454, + version: 1443253234253454, labels: None, }) .run_until_complete(&db, false, port) .await @@ -473,7 +473,7 @@ mod job_payload { path: "f/system/hello_with_preprocessor".to_string(), dedicated_worker: None, apply_preprocessor: true, - version: 1443253234253456, + version: 1443253234253456, labels: None, }) .run_until_complete_with(db, false, port, |id| async move { let job = sqlx::query!("SELECT preprocessed FROM v2_job WHERE id = $1", id) @@ -543,7 +543,7 @@ mod job_payload { path: "f/system/hello_with_nodes_flow".to_string(), dedicated_worker: None, apply_preprocessor: true, - version: 1443253234253454, + version: 1443253234253454, labels: None, }) .run_until_complete(&db, false, port) .await @@ -789,7 +789,7 @@ mod job_payload { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(), debouncing_settings: - windmill_common::runnable_settings::DebouncingSettings::default(), + windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, }) .arg("foo", json!("hello")) .arg("bar", json!("world")) @@ -839,7 +839,7 @@ mod job_payload { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(), debouncing_settings: - windmill_common::runnable_settings::DebouncingSettings::default(), + windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, }) .arg("foo", json!("hello")) .arg("bar", json!("world")) @@ -889,7 +889,7 @@ mod job_payload { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(), debouncing_settings: - windmill_common::runnable_settings::DebouncingSettings::default(), + windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, }) .arg("foo", json!("hello")) .arg("bar", json!("world")) @@ -939,7 +939,7 @@ mod job_payload { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(), debouncing_settings: - windmill_common::runnable_settings::DebouncingSettings::default(), + windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, }) .arg("foo", json!("hello")) .arg("bar", json!("world")) @@ -991,7 +991,7 @@ mod job_payload { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(), debouncing_settings: - windmill_common::runnable_settings::DebouncingSettings::default(), + windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, }) .arg("foo", json!("hello")) .arg("bar", json!("world")) diff --git a/backend/tests/list_jobs.rs b/backend/tests/list_jobs.rs index 5db9e3cb14..7d3c874882 100644 --- a/backend/tests/list_jobs.rs +++ b/backend/tests/list_jobs.rs @@ -64,8 +64,8 @@ async fn test_list_jobs_without_include_args(db: Pool) -> anyhow::Resu cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), modules: None, })) @@ -121,8 +121,8 @@ async fn test_list_jobs_with_include_args(db: Pool) -> anyhow::Result< cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), modules: None, })) @@ -192,8 +192,8 @@ async fn test_list_jobs_completed_with_include_args(db: Pool) -> anyho cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), modules: None, })) @@ -265,8 +265,8 @@ async fn test_list_jobs_mixed_queue_and_completed(db: Pool) -> anyhow: cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), modules: None, })) @@ -286,8 +286,8 @@ async fn test_list_jobs_mixed_queue_and_completed(db: Pool) -> anyhow: cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), modules: None, })) @@ -349,7 +349,9 @@ async fn test_list_jobs_mixed_queue_and_completed(db: Pool) -> anyhow: /// Test list_jobs with multiple queued jobs and include_args #[sqlx::test(fixtures("base"))] -async fn test_list_jobs_multiple_queued_with_include_args(db: Pool) -> anyhow::Result<()> { +async fn test_list_jobs_multiple_queued_with_include_args( + db: Pool, +) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; @@ -369,8 +371,8 @@ async fn test_list_jobs_multiple_queued_with_include_args(db: Pool) -> cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), modules: None, })) @@ -387,8 +389,8 @@ async fn test_list_jobs_multiple_queued_with_include_args(db: Pool) -> cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), modules: None, })) @@ -411,9 +413,13 @@ async fn test_list_jobs_multiple_queued_with_include_args(db: Pool) -> let jobs: Vec = response.json().await?; // Find both jobs - let job1 = jobs.iter().find(|j| j.id == job1_id.to_string()) + let job1 = jobs + .iter() + .find(|j| j.id == job1_id.to_string()) .expect("should find job1"); - let job2 = jobs.iter().find(|j| j.id == job2_id.to_string()) + let job2 = jobs + .iter() + .find(|j| j.id == job2_id.to_string()) .expect("should find job2"); // Both should have args @@ -461,8 +467,8 @@ async fn test_queue_list_without_include_args(db: Pool) -> anyhow::Res cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), modules: None, })) @@ -480,10 +486,7 @@ async fn test_queue_list_without_include_args(db: Pool) -> anyhow::Res .send() .await?; - assert!( - response.status().is_success(), - "queue/list should succeed" - ); + assert!(response.status().is_success(), "queue/list should succeed"); let jobs: Vec = response.json().await?; @@ -524,8 +527,8 @@ async fn test_queue_list_with_include_args(db: Pool) -> anyhow::Result cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), modules: None, })) @@ -594,8 +597,8 @@ async fn test_queue_list_multiple_jobs_with_include_args(db: Pool) -> cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), modules: None, })) @@ -612,8 +615,8 @@ async fn test_queue_list_multiple_jobs_with_include_args(db: Pool) -> cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), modules: None, })) @@ -631,10 +634,7 @@ async fn test_queue_list_multiple_jobs_with_include_args(db: Pool) -> .send() .await?; - assert!( - response.status().is_success(), - "queue/list should succeed" - ); + assert!(response.status().is_success(), "queue/list should succeed"); let jobs: Vec = response.json().await?; @@ -694,8 +694,8 @@ async fn test_completed_list_without_include_args(db: Pool) -> anyhow: cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), modules: None, })) @@ -760,8 +760,8 @@ async fn test_completed_list_with_include_args(db: Pool) -> anyhow::Re cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), modules: None, })) @@ -835,8 +835,8 @@ async fn test_completed_list_multiple_jobs_with_include_args( cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), modules: None, })) @@ -856,8 +856,8 @@ async fn test_completed_list_multiple_jobs_with_include_args( cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, - concurrency_settings: - windmill_common::runnable_settings::ConcurrencySettings::default().into(), + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), modules: None, })) @@ -912,3 +912,273 @@ async fn test_completed_list_multiple_jobs_with_include_args( Ok(()) } + +// ============================================================================= +// Labels integration tests +// ============================================================================= + +#[sqlx::test(fixtures("base"))] +async fn test_job_labels_propagated_at_push_time(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let client = windmill_api_client::create_client( + &format!("http://localhost:{port}"), + "SECRET_TOKEN".to_string(), + ); + + // Push a script job with labels + let job_id = RunJob::from(JobPayload::ScriptHash { + hash: windmill_common::scripts::ScriptHash(0), + path: "u/admin/test_labels_script".to_string(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + language: ScriptLang::Python3, + priority: None, + apply_preprocessor: false, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + labels: Some(vec!["prod".to_string(), "deploy".to_string()]), + }) + .push(&db) + .await; + + // Verify labels in queue listing + let response = client + .client() + .get(format!("{}/w/test-workspace/jobs/list", client.baseurl())) + .send() + .await?; + + assert!(response.status().is_success()); + let jobs: Vec = response.json().await?; + let job = jobs + .iter() + .find(|j| j["id"].as_str() == Some(&job_id.to_string())) + .expect("should find job"); + + let labels = job["labels"].as_array().expect("labels should be an array"); + assert!(labels.contains(&json!("prod"))); + assert!(labels.contains(&json!("deploy"))); + + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_job_without_labels_has_no_labels_field(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let client = windmill_api_client::create_client( + &format!("http://localhost:{port}"), + "SECRET_TOKEN".to_string(), + ); + + // Push a job without labels + let job_id = RunJob::from(JobPayload::Code(RawCode { + hash: None, + content: "def main(): return 1".to_string(), + path: None, + language: ScriptLang::Python3, + lock: None, + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, + })) + .push(&db) + .await; + + let response = client + .client() + .get(format!("{}/w/test-workspace/jobs/list", client.baseurl())) + .send() + .await?; + + assert!(response.status().is_success()); + let jobs: Vec = response.json().await?; + let job = jobs + .iter() + .find(|j| j["id"].as_str() == Some(&job_id.to_string())) + .expect("should find job"); + + // Labels should be null/missing for jobs without labels + assert!( + job.get("labels").is_none() || job["labels"].is_null(), + "job without labels should not have labels field" + ); + + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_job_label_filter(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let client = windmill_api_client::create_client( + &format!("http://localhost:{port}"), + "SECRET_TOKEN".to_string(), + ); + + // Push two jobs with different labels, then complete them via SQL + // Label filtering only works on completed jobs + let job_prod_id = RunJob::from(JobPayload::ScriptHash { + hash: windmill_common::scripts::ScriptHash(0), + path: "u/admin/prod_script".to_string(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + language: ScriptLang::Bun, + priority: None, + apply_preprocessor: false, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + labels: Some(vec!["prod".to_string()]), + }) + .push(&db) + .await; + + let job_staging_id = RunJob::from(JobPayload::ScriptHash { + hash: windmill_common::scripts::ScriptHash(0), + path: "u/admin/staging_script".to_string(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + language: ScriptLang::Bun, + priority: None, + apply_preprocessor: false, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + labels: Some(vec!["staging".to_string()]), + }) + .push(&db) + .await; + + // Complete both jobs directly via SQL so label filter can find them + for job_id in &[job_prod_id, job_staging_id] { + sqlx::query( + "INSERT INTO v2_job_completed (workspace_id, id, result, status, duration_ms) VALUES ('test-workspace', $1, '{}'::jsonb, 'success', 0)", + ) + .bind(job_id) + .execute(&db) + .await?; + } + + // Filter by label=prod + let response = client + .client() + .get(format!( + "{}/w/test-workspace/jobs/list?label=prod", + client.baseurl() + )) + .send() + .await?; + + assert!(response.status().is_success()); + let jobs: Vec = response.json().await?; + + // Should find the prod job + assert!( + jobs.iter() + .any(|j| j["id"].as_str() == Some(&job_prod_id.to_string())), + "prod job should appear in label=prod filter" + ); + + // Should NOT find the staging job + assert!( + !jobs + .iter() + .any(|j| j["id"].as_str() == Some(&job_staging_id.to_string())), + "staging job should not appear in label=prod filter" + ); + + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_wm_labels_from_result_merged_with_static_labels( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let client = windmill_api_client::create_client( + &format!("http://localhost:{port}"), + "SECRET_TOKEN".to_string(), + ); + + // Use Code(RawCode) to run a Bun script that returns wm_labels, + // then set static labels on the job row before execution + let job = RunJob::from(JobPayload::Code(RawCode { + hash: None, + content: r#"export async function main() { return { wm_labels: ["runtime-label"] }; }"# + .to_string(), + path: None, + language: ScriptLang::Bun, + lock: None, + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, + })); + + let completed = job + .run_until_complete_with(&db, false, port, |uuid| { + let db = db.clone(); + async move { + // Set static labels before the worker picks up the job + sqlx::query( + "UPDATE v2_job SET labels = ARRAY['static-label']::text[] WHERE id = $1", + ) + .bind(uuid) + .execute(&db) + .await + .expect("should set labels"); + } + }) + .await; + + // Fetch the job via API and check labels + let response = client + .client() + .get(format!( + "{}/w/test-workspace/jobs_u/get/{}", + client.baseurl(), + completed.id + )) + .send() + .await?; + + assert!(response.status().is_success()); + let job: serde_json::Value = response.json().await?; + let labels = job["labels"].as_array().expect("labels should be an array"); + + // Should contain both the static label and runtime label from wm_labels + assert!( + labels.contains(&json!("static-label")), + "should contain static label set at push time, got: {:?}", + labels + ); + assert!( + labels.contains(&json!("runtime-label")), + "should contain runtime label from wm_labels in result, got: {:?}", + labels + ); + + Ok(()) +} diff --git a/backend/tests/success_handler.rs b/backend/tests/success_handler.rs index 2f580e4dcb..fb28a10f04 100644 --- a/backend/tests/success_handler.rs +++ b/backend/tests/success_handler.rs @@ -179,7 +179,7 @@ export async function main(path: string, email: string, job_id: string, is_flow: priority: None, apply_preprocessor: false, concurrency_settings: ConcurrencySettings::default(), - debouncing_settings: DebouncingSettings::default(), + debouncing_settings: DebouncingSettings::default(), labels: None, }) .run_until_complete(&db, false, server.addr.port()) .await; diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 89fd9629b7..daa3e216e3 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -150,7 +150,8 @@ async fn list_flows( "favorite.path IS NOT NULL as starred", "draft.path IS NOT NULL as has_draft", "draft_only", - "ws_error_handler_muted" + "ws_error_handler_muted", + "o.labels" ]) .left() .join("favorite") @@ -196,6 +197,11 @@ async fn list_flows( if let Some(dw) = &lq.dedicated_worker { sqlb.and_where_eq("dedicated_worker", dw); } + if let Some(label) = &lq.label { + for l in label.split(',') { + sqlb.and_where("o.labels @> ARRAY[?]".bind(&l.trim())); + } + } if lq.with_deployment_msg.unwrap_or(false) { sqlb.join("deployment_metadata dm") @@ -489,13 +495,13 @@ async fn create_flow( dependency_job, lock_error_logs, draft_only, tag, dedicated_worker, visible_to_runner_only, on_behalf_of_email, ws_error_handler_muted, - value, schema, edited_by, edited_at + value, schema, edited_by, edited_at, labels ) VALUES ( $1, $2, $3, $4, NULL, '', $5, $6, $7, $8, $9, $10, - $11, $12::text::json, $13, now() + $11, $12::text::json, $13, now(), $14 )"#, w_id, nf.path, @@ -514,6 +520,7 @@ async fn create_flow( sqlx::types::Json(&nf.value) as _, schema_str, &authed.username, + nf.labels.as_deref() as Option<&[String]>, ) .execute(&mut *tx) .await?; @@ -763,7 +770,7 @@ async fn get_flow_version( let mut tx = user_db.begin(&authed).await?; let flow = sqlx::query_as::<_, Flow>( - "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by + "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by FROM flow LEFT JOIN flow_version ON flow_version.path = flow.path AND flow_version.workspace_id = flow.workspace_id WHERE flow.path = $1 AND flow.workspace_id = $2 AND flow_version.id = $3", @@ -821,6 +828,7 @@ async fn get_flow_version_by_id( flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, + flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, @@ -960,7 +968,8 @@ async fn update_flow( value = $9, schema = $10::text::json, edited_by = $11, - edited_at = now() + edited_at = now(), + labels = COALESCE($14, labels) WHERE path = $12 AND workspace_id = $13", if is_new_path { flow_path } else { &nf.path }, @@ -980,6 +989,7 @@ async fn update_flow( authed.username, flow_path, w_id, + nf.labels.as_deref() as Option<&[String]>, ) .execute(&mut *tx) .await @@ -991,8 +1001,8 @@ async fn update_flow( // if new path, must clone flow to new path and delete old flow for flow_version foreign key constraint sqlx::query!( "INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at) - SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels) + SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels FROM flow WHERE path = $2 AND workspace_id = $3", nf.path, @@ -1336,11 +1346,12 @@ async fn get_flow_by_path( flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, - flow.on_behalf_of_email, + flow.on_behalf_of_email, + flow.labels, flow_version.id AS version_id, - flow_version.schema, - flow_version.value, - flow_version.created_at AS edited_at, + flow_version.schema, + flow_version.value, + flow_version.created_at AS edited_at, flow_version.created_by AS edited_by, favorite.path IS NOT NULL AS starred FROM flow @@ -1376,12 +1387,13 @@ async fn get_flow_by_path( flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, - flow.on_behalf_of_email, + flow.on_behalf_of_email, + flow.labels, flow_version.id AS version_id, - flow_version.schema, + flow_version.schema, flow_version.value, - flow_version.created_at AS edited_at, - flow_version.created_by AS edited_by, + flow_version.created_at AS edited_at, + flow_version.created_by AS edited_by, NULL AS starred FROM flow LEFT JOIN flow_version diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index 08aebe4ddd..6bc8c05abd 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -613,6 +613,7 @@ pub async fn run_flow<'c>( on_behalf_of_email, edited_by, early_return, + labels, .. } = flow_version_info; @@ -656,6 +657,7 @@ pub async fn run_flow<'c>( version, apply_preprocessor: !run_query.skip_preprocessor.unwrap_or(false) && has_preprocessor.unwrap_or(false), + labels, }, PushArgs { args: &args.args, extra: args.extra }, authed.display_username(), diff --git a/backend/windmill-api-jobs/src/query.rs b/backend/windmill-api-jobs/src/query.rs index 0d7503b5bc..38e139b77b 100644 --- a/backend/windmill-api-jobs/src/query.rs +++ b/backend/windmill-api-jobs/src/query.rs @@ -295,34 +295,30 @@ pub fn filter_list_completed_query( 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 {})", quote(&p) + "NOT EXISTS (SELECT 1 FROM unnest(v2_job.labels) lbl WHERE 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 {})", quote(&p) + "EXISTS (SELECT 1 FROM unnest(v2_job.labels) lbl WHERE lbl LIKE {})", quote(&p) ) } }) .collect(); let sep = if label.negated { " AND " } else { " OR " }; - if !label.negated { - sqlb.and_where("result ? 'wm_labels'"); - } sqlb.and_where(format!("({})", clauses.join(sep))); } else if label.negated { let clauses: Vec<_> = label .values .iter() - .map(|v| format!("NOT (result->'wm_labels' ? {})", quote(v))) + .map(|v| format!("NOT (v2_job.labels @> ARRAY[{}])", quote(v))) .collect(); sqlb.and_where(format!("({})", clauses.join(" AND "))); } else { let clauses: Vec<_> = label .values .iter() - .map(|v| format!("result->'wm_labels' ? {}", quote(v))) + .map(|v| format!("v2_job.labels @> ARRAY[{}]", quote(v))) .collect(); - sqlb.and_where("result ? 'wm_labels'"); sqlb.and_where(format!("({})", clauses.join(" OR "))); } } @@ -545,7 +541,7 @@ pub fn filter_list_completed_query( let pat = format!("%{}%", escape_ilike_pattern(bf)); sqlb.and_where( "(runnable_path ILIKE ? OR v2_job.tag ILIKE ? OR trigger ILIKE ? OR trigger_kind::text ILIKE ? \ - OR EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl ILIKE ?))" + OR EXISTS (SELECT 1 FROM unnest(v2_job.labels) lbl WHERE lbl ILIKE ?))" .bind(&pat).bind(&pat).bind(&pat).bind(&pat).bind(&pat) ); } @@ -946,7 +942,7 @@ mod tests { false, ); let sql = build_sql(sqlb); - assert!(sql.contains("wm_labels")); + assert!(sql.contains("v2_job.labels")); } #[test] diff --git a/backend/windmill-api-jobs/src/types.rs b/backend/windmill-api-jobs/src/types.rs index f040e6eeb7..e07328e2be 100644 --- a/backend/windmill-api-jobs/src/types.rs +++ b/backend/windmill-api-jobs/src/types.rs @@ -252,7 +252,7 @@ pub struct ListableCompletedJob { #[serde(skip_serializing_if = "Option::is_none")] pub priority: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub labels: Option, + pub labels: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub args: Option, } @@ -293,7 +293,7 @@ pub struct UnifiedJob { pub concurrent_limit: Option, pub concurrency_time_window_s: Option, pub priority: Option, - pub labels: Option, + pub labels: Option>, pub self_wait_time_ms: Option, pub aggregate_wait_time_ms: Option, pub preprocessed: Option, @@ -334,7 +334,7 @@ const CJ_FIELDS: &[&str] = &[ "null as concurrent_limit", "null as concurrency_time_window_s", "v2_job.priority", - "v2_job_completed.result->'wm_labels' as labels", + "v2_job.labels", "self_wait_time_ms", "aggregate_wait_time_ms", "v2_job.preprocessed", @@ -375,7 +375,7 @@ const QJ_FIELDS: &[&str] = &[ "v2_job.concurrent_limit", "v2_job.concurrency_time_window_s", "v2_job.priority", - "null as labels", + "v2_job.labels", "self_wait_time_ms", "aggregate_wait_time_ms", "v2_job.preprocessed", @@ -482,6 +482,7 @@ impl From for Job { priority: uj.priority, preprocessed: uj.preprocessed, runnable_settings_handle: uj.runnable_settings_handle, + labels: uj.labels, }, )), t => panic!("job type {} not valid", t), diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index 6e35b0f897..9becc3ed33 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -99,6 +99,8 @@ pub struct NewSchedule { pub dynamic_skip: Option, pub permissioned_as: Option, pub preserve_permissioned_as: Option, + #[serde(default)] + pub labels: Option>, } #[derive(Serialize, Deserialize)] @@ -248,7 +250,7 @@ async fn create_schedule( on_recovery, on_recovery_times, on_recovery_extra_args, on_success, on_success_extra_args, ws_error_handler_muted, retry, summary, no_flow_overlap, - tag, paused_until, cron_version, description, dynamic_skip + tag, paused_until, cron_version, description, dynamic_skip, labels ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, @@ -256,7 +258,7 @@ async fn create_schedule( $16, $17, $18, $19, $20, $21, $22, $23, $24, - $25, $26, $27, $28, $29 + $25, $26, $27, $28, $29, $30 ) RETURNING workspace_id, @@ -290,7 +292,8 @@ async fn create_schedule( tag, paused_until, cron_version, - dynamic_skip + dynamic_skip, + labels "#, w_id, ns.path, @@ -324,7 +327,8 @@ async fn create_schedule( ns.paused_until, ns.cron_version.clone().unwrap_or_else(|| "v2".to_string()), ns.description, - ns.dynamic_skip + ns.dynamic_skip, + ns.labels.as_deref() as Option<&[String]> ) .fetch_one(&mut *tx) .await @@ -467,7 +471,8 @@ async fn edit_schedule( dynamic_skip = $23, email = $24, edited_by = $25, - permissioned_as = $26 + permissioned_as = $26, + labels = COALESCE($27, labels) WHERE path = $19 AND workspace_id = $20 RETURNING workspace_id, @@ -501,7 +506,8 @@ async fn edit_schedule( tag, paused_until, cron_version, - dynamic_skip + dynamic_skip, + labels "#, es.schedule, es.timezone, @@ -532,7 +538,8 @@ async fn edit_schedule( es.dynamic_skip, resolved_email, resolved_edited_by, - resolved_permissioned_as + resolved_permissioned_as, + es.labels.as_deref() as Option<&[String]> ) .fetch_one(&mut *tx) .await @@ -608,6 +615,7 @@ pub struct ListScheduleQuery { // filter on summary (pattern match) pub summary: Option, pub broad_filter: Option, + pub label: Option, } #[derive(sqlx::FromRow, Serialize, Deserialize, Debug, Clone)] @@ -623,6 +631,8 @@ pub struct ScheduleLight { pub is_flow: bool, pub summary: Option, pub extra_perms: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option>, } async fn list_schedule( authed: ApiAuthed, @@ -645,6 +655,7 @@ async fn list_schedule( "is_flow", "summary", "extra_perms", + "labels", ]) .order_by("edited_at", true) .and_where("workspace_id = ?".bind(&w_id)) @@ -685,6 +696,11 @@ async fn list_schedule( .bind(&pat).bind(&pat).bind(&pat).bind(&pat).bind(&pat) ); } + if let Some(label) = &lsq.label { + for l in label.split(',') { + sqlb.and_where("labels @> ARRAY[?]".bind(&l.trim())); + } + } let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let rows = sqlx::query_as::<_, ScheduleLight>(&sql) .fetch_all(&mut *tx) @@ -845,7 +861,8 @@ pub async fn set_enabled( tag, paused_until, cron_version, - dynamic_skip + dynamic_skip, + labels "#, payload.enabled, authed.email, @@ -1221,6 +1238,8 @@ pub struct EditSchedule { pub dynamic_skip: Option, pub permissioned_as: Option, pub preserve_permissioned_as: Option, + #[serde(default)] + pub labels: Option>, } pub use windmill_queue::schedule::clear_schedule; diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index afa5ded9dc..cbe2450c29 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -128,6 +128,8 @@ pub struct ScriptWDraft { #[serde(skip_serializing_if = "Option::is_none")] #[sqlx(json(nullable))] pub modules: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option>, #[serde(flatten)] #[sqlx(flatten)] pub runnable_settings: SR, @@ -183,6 +185,7 @@ impl ScriptWDraft { on_behalf_of_email: self.on_behalf_of_email, assets: self.assets, modules: self.modules, + labels: self.labels, }) } } @@ -306,7 +309,8 @@ async fn list_scripts( "ws_error_handler_muted", "auto_kind", "codebase IS NOT NULL as use_codebase", - "kind" + "kind", + "o.labels" ]) .left() .join("favorite") @@ -384,6 +388,11 @@ async fn list_scripts( if let Some(dw) = &lq.dedicated_worker { sqlb.and_where_eq("dedicated_worker", dw); } + if let Some(label) = &lq.label { + for l in label.split(',') { + sqlb.and_where("o.labels @> ARRAY[?]".bind(&l.trim())); + } + } if authed.is_operator { sqlb.and_where_eq("kind", quote("script")); } else if let Some(lowercased_kinds) = lowercased_kinds { @@ -930,8 +939,8 @@ async fn create_script_internal<'c>( content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, \ draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \ dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ - delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39)", + delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40)", &w_id, &hash.0, ns.path, @@ -974,7 +983,8 @@ async fn create_script_internal<'c>( guarded_debounce_delay_s, ns.cache_ignore_s3_path, runnable_settings_handle, - ns.modules.as_ref().and_then(|m| serde_json::to_value(m).ok()) + ns.modules.as_ref().and_then(|m| serde_json::to_value(m).ok()), + ns.labels.as_deref() as Option<&[String]> ) .execute(&mut *tx) .await?; @@ -1424,7 +1434,7 @@ async fn get_script_by_path_w_draft( let mut tx = user_db.begin(&authed).await?; let script_o = sqlx::query_as::<_, ScriptWDraft>( - "SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, has_preprocessor, on_behalf_of_email, assets, modules, debounce_key, debounce_delay_s FROM script LEFT JOIN draft ON + "SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, has_preprocessor, on_behalf_of_email, assets, modules, debounce_key, debounce_delay_s, labels FROM script LEFT JOIN draft ON script.path = draft.path AND script.workspace_id = draft.workspace_id AND draft.typ = 'script' WHERE script.path = $1 AND script.workspace_id = $2 ORDER BY script.created_at DESC LIMIT 1", diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 8f3953f014..789e0a410d 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -4372,6 +4372,12 @@ paths: type: string - $ref: "#/components/parameters/Page" - $ref: "#/components/parameters/PerPage" + - name: label + description: Filter by label + in: query + required: false + schema: + type: string responses: "200": description: variable list @@ -5495,6 +5501,12 @@ paths: in: query schema: type: string + - name: label + description: Filter by label + in: query + required: false + schema: + type: string responses: "200": description: resource list @@ -6468,6 +6480,12 @@ paths: If false, show only scripts with dedicated_worker disabled. schema: type: boolean + - name: label + in: query + required: false + schema: + type: string + description: Filter by label responses: "200": @@ -8050,6 +8068,12 @@ paths: If false, show only flows with dedicated_worker disabled. schema: type: boolean + - name: label + in: query + required: false + schema: + type: string + description: Filter by label responses: "200": description: All flow @@ -8573,6 +8597,12 @@ paths: in: query schema: type: boolean + - name: label + in: query + required: false + schema: + type: string + description: Filter by label responses: "200": description: All raw apps @@ -8672,6 +8702,12 @@ paths: in: query schema: type: boolean + - name: label + in: query + required: false + schema: + type: string + description: Filter by label responses: "200": description: All apps @@ -8724,6 +8760,10 @@ paths: preserve_on_behalf_of: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original on_behalf_of value in the policy instead of overwriting it." + labels: + type: array + items: + type: string required: - path - value @@ -8772,6 +8812,10 @@ paths: preserve_on_behalf_of: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original on_behalf_of value in the policy instead of overwriting it." + labels: + type: array + items: + type: string required: - path - value @@ -9086,6 +9130,10 @@ paths: preserve_on_behalf_of: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original on_behalf_of value in the policy instead of overwriting it." + labels: + type: array + items: + type: string responses: "200": description: app updated @@ -9128,6 +9176,10 @@ paths: preserve_on_behalf_of: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original on_behalf_of value in the policy instead of overwriting it." + labels: + type: array + items: + type: string js: type: string css: @@ -11913,6 +11965,12 @@ paths: in: query schema: type: string + - name: label + in: query + required: false + schema: + type: string + description: Filter by label responses: "200": description: schedule list @@ -12156,6 +12214,12 @@ paths: in: query schema: type: string + - name: label + in: query + required: false + schema: + type: string + description: Filter by label responses: "200": description: http trigger list @@ -12351,6 +12415,12 @@ paths: in: query schema: type: string + - name: label + in: query + required: false + schema: + type: string + description: Filter by label responses: "200": description: websocket trigger list @@ -12545,6 +12615,12 @@ paths: in: query schema: type: string + - name: label + in: query + required: false + schema: + type: string + description: Filter by label responses: "200": description: kafka trigger list @@ -12780,6 +12856,12 @@ paths: in: query schema: type: string + - name: label + in: query + required: false + schema: + type: string + description: Filter by label responses: "200": description: nats trigger list @@ -12969,6 +13051,12 @@ paths: in: query schema: type: string + - name: label + in: query + required: false + schema: + type: string + description: Filter by label responses: "200": description: sqs trigger list @@ -13413,6 +13501,12 @@ paths: in: query schema: type: boolean + - name: label + in: query + required: false + schema: + type: string + description: Filter by label responses: "200": description: native triggers list @@ -13723,6 +13817,12 @@ paths: in: query schema: type: string + - name: label + in: query + required: false + schema: + type: string + description: Filter by label responses: "200": description: mqtt trigger list @@ -13912,6 +14012,12 @@ paths: in: query schema: type: string + - name: label + in: query + required: false + schema: + type: string + description: Filter by label responses: "200": description: gcp trigger list @@ -14416,6 +14522,12 @@ paths: in: query schema: type: string + - name: label + in: query + required: false + schema: + type: string + description: Filter by label responses: "200": description: postgres trigger list @@ -14605,6 +14717,12 @@ paths: in: query schema: type: string + - name: label + in: query + required: false + schema: + type: string + description: Filter by label responses: "200": description: email trigger list @@ -19414,6 +19532,11 @@ components: description: "Additional script modules keyed by relative file path" additionalProperties: $ref: "#/components/schemas/ScriptModule" + labels: + type: array + items: + type: string + default: [] required: - hash @@ -19432,6 +19555,7 @@ components: - starred - has_preprocessor + NewScript: type: object properties: @@ -19537,6 +19661,10 @@ components: description: "Additional script modules keyed by relative file path" additionalProperties: $ref: "#/components/schemas/ScriptModule" + labels: + type: array + items: + type: string required: - path @@ -20383,6 +20511,10 @@ components: expires_at: type: string format: date-time + labels: + type: array + items: + type: string required: - workspace_id - path @@ -20431,6 +20563,10 @@ components: type: string description: The expiration date of the variable format: date-time + labels: + type: array + items: + type: string required: - path - value @@ -20452,6 +20588,10 @@ components: description: type: string description: The new description of the variable + labels: + type: array + items: + type: string AuditLog: type: object @@ -20844,6 +20984,10 @@ components: resource_type: type: string description: The resource_type associated with the resource + labels: + type: array + items: + type: string required: - path - value @@ -20862,6 +21006,10 @@ components: resource_type: type: string description: The new resource_type to be associated with the resource + labels: + type: array + items: + type: string Resource: type: object @@ -20886,6 +21034,10 @@ components: edited_at: type: string format: date-time + labels: + type: array + items: + type: string required: - path - resource_type @@ -20924,6 +21076,10 @@ components: edited_at: type: string format: date-time + labels: + type: array + items: + type: string required: - path - resource_type @@ -21075,6 +21231,11 @@ components: type: string nullable: true description: Path to a script that validates scheduled datetimes. Receives scheduled_for datetime and returns boolean to skip (true) or run (false) + labels: + type: array + items: + type: string + default: [] required: - path - edited_by @@ -21088,6 +21249,7 @@ components: - email - permissioned_as + ScheduleWJobs: allOf: - $ref: "#/components/schemas/Schedule" @@ -21214,6 +21376,10 @@ components: preserve_permissioned_as: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it." + labels: + type: array + items: + type: string required: - path - schedule @@ -21309,6 +21475,10 @@ components: type: boolean nullable: true description: "If true and user is admin/wm_deployers, preserve the provided permissioned_as instead of using the deploying user's identity" + labels: + type: array + items: + type: string required: - schedule - timezone @@ -21373,6 +21543,11 @@ components: mode: $ref: "#/components/schemas/TriggerMode" description: Trigger mode (enabled/disabled) + labels: + type: array + items: + type: string + default: [] required: - path - script_path @@ -21384,6 +21559,7 @@ components: - is_flow - mode + AuthenticationMethod: type: string enum: @@ -21671,6 +21847,10 @@ components: preserve_permissioned_as: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it." + labels: + type: array + items: + type: string required: - path @@ -21763,6 +21943,10 @@ components: preserve_permissioned_as: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it." + labels: + type: array + items: + type: string required: - path - script_path @@ -21974,6 +22158,10 @@ components: preserve_permissioned_as: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it." + labels: + type: array + items: + type: string required: - path @@ -22053,6 +22241,10 @@ components: preserve_permissioned_as: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it." + labels: + type: array + items: + type: string required: - path @@ -22228,6 +22420,10 @@ components: preserve_permissioned_as: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it." + labels: + type: array + items: + type: string required: - path - script_path @@ -22288,6 +22484,10 @@ components: preserve_permissioned_as: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it." + labels: + type: array + items: + type: string required: - path - script_path @@ -22430,6 +22630,10 @@ components: preserve_permissioned_as: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it." + labels: + type: array + items: + type: string required: - path - script_path @@ -22598,6 +22802,10 @@ components: preserve_permissioned_as: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it." + labels: + type: array + items: + type: string required: - queue_url - aws_resource_path @@ -22650,6 +22858,10 @@ components: preserve_permissioned_as: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it." + labels: + type: array + items: + type: string required: - queue_url - aws_resource_path @@ -22814,6 +23026,10 @@ components: preserve_permissioned_as: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it." + labels: + type: array + items: + type: string required: - path - script_path @@ -22862,6 +23078,10 @@ components: preserve_permissioned_as: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it." + labels: + type: array + items: + type: string required: - path - script_path @@ -23011,6 +23231,10 @@ components: preserve_permissioned_as: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it." + labels: + type: array + items: + type: string required: - path @@ -23088,6 +23312,10 @@ components: preserve_permissioned_as: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it." + labels: + type: array + items: + type: string required: - path @@ -23195,6 +23423,10 @@ components: preserve_permissioned_as: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it." + labels: + type: array + items: + type: string required: - path @@ -23250,6 +23482,10 @@ components: preserve_permissioned_as: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it." + labels: + type: array + items: + type: string required: - path - script_path @@ -23304,6 +23540,10 @@ components: preserve_permissioned_as: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it." + labels: + type: array + items: + type: string required: - path @@ -23336,6 +23576,10 @@ components: preserve_permissioned_as: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it." + labels: + type: array + items: + type: string required: - path - script_path @@ -23746,6 +23990,11 @@ components: type: boolean on_behalf_of_email: type: string + labels: + type: array + items: + type: string + default: [] required: - path - edited_by @@ -23753,6 +24002,7 @@ components: - archived - extra_perms + OpenFlowWPath: allOf: - $ref: "../../openflow.openapi.yaml#/components/schemas/OpenFlow" @@ -23777,6 +24027,10 @@ components: preserve_on_behalf_of: type: boolean description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original on_behalf_of_email value instead of overwriting it." + labels: + type: array + items: + type: string required: - path @@ -23871,6 +24125,11 @@ components: enum: [viewer, publisher, anonymous] raw_app: type: boolean + labels: + type: array + items: + type: string + default: [] required: - id - workspace_id @@ -23881,6 +24140,7 @@ components: - edited_at - execution_mode + ScopeDefinition: type: object properties: @@ -23934,6 +24194,11 @@ components: edited_at: type: string format: date-time + labels: + type: array + items: + type: string + default: [] required: - workspace_id - path @@ -23942,6 +24207,7 @@ components: - version - edited_at + AppWithLastVersion: type: object properties: @@ -23978,6 +24244,11 @@ components: type: boolean bundle_secret: type: string + labels: + type: array + items: + type: string + default: [] required: - id - workspace_id @@ -23992,6 +24263,7 @@ components: - extra_perms - raw_app + AppWithLastVersionWDraft: allOf: - $ref: "#/components/schemas/AppWithLastVersion" diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index e9f262881a..0f01364e27 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -158,6 +158,8 @@ pub struct ListableApp { pub deployment_msg: Option, #[serde(skip_serializing_if = "is_false")] pub raw_app: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option>, } fn is_false(b: &bool) -> bool { @@ -190,6 +192,8 @@ pub struct AppWithLastVersion { #[sqlx(skip)] #[serde(skip_serializing_if = "Option::is_none")] pub bundle_secret: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option>, } #[derive(Serialize, FromRow)] @@ -288,6 +292,8 @@ pub struct CreateApp { pub deployment_message: Option, pub custom_path: Option, pub preserve_on_behalf_of: Option, + #[serde(default)] + pub labels: Option>, } #[derive(Serialize, Deserialize)] @@ -299,6 +305,8 @@ pub struct EditApp { pub deployment_message: Option, pub custom_path: Option, pub preserve_on_behalf_of: Option, + #[serde(default)] + pub labels: Option>, } #[derive(Serialize, FromRow)] @@ -354,6 +362,7 @@ async fn list_apps( "draft.path IS NOT NULL as has_draft", "draft_only", "app_version.raw_app", + "app.labels", ]) .left() .join("favorite") @@ -394,6 +403,12 @@ async fn list_apps( sqlb.and_where("app.draft_only IS NOT TRUE"); } + if let Some(label) = &lq.label { + for l in label.split(',') { + sqlb.and_where("app.labels @> ARRAY[?]".bind(&l.trim())); + } + } + if lq.with_deployment_msg.unwrap_or(false) { sqlb.join("deployment_metadata dm") .left() @@ -543,8 +558,8 @@ async fn get_app( let app_o = if query.with_starred_info.unwrap_or(false) { sqlx::query_as::<_, AppWithLastVersionAndStarred>( "SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path, - app.extra_perms, app_version.value, - app_version.created_at, app_version.created_by, favorite.path IS NOT NULL as starred, app_version.raw_app + app.extra_perms, app_version.value, + app_version.created_at, app_version.created_by, favorite.path IS NOT NULL as starred, app_version.raw_app, app.labels FROM app JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)] @@ -563,8 +578,8 @@ async fn get_app( } else { sqlx::query_as::<_, AppWithLastVersionAndStarred>( "SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path, - app.extra_perms, app_version.value, - app_version.created_at, app_version.created_by, NULL as starred, app_version.raw_app + app.extra_perms, app_version.value, + app_version.created_at, app_version.created_by, NULL as starred, app_version.raw_app, app.labels FROM app, app_version WHERE app.path = $1 AND app.workspace_id = $2 AND app_version.id = app.versions[array_upper(app.versions, 1)]", ) @@ -590,8 +605,8 @@ async fn get_app_lite( let app_o = sqlx::query_as::<_, AppWithLastVersion>( "SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path, - app.extra_perms, coalesce(app_version_lite.value::json, app_version.value) as value, - app_version.created_at, app_version.created_by, NULL as starred, app_version.raw_app + app.extra_perms, coalesce(app_version_lite.value::json, app_version.value) as value, + app_version.created_at, app_version.created_by, NULL as starred, app_version.raw_app, app.labels FROM app, app_version LEFT JOIN app_version_lite ON app_version_lite.id = app_version.id WHERE app.path = $1 AND app.workspace_id = $2 AND app_version.id = app.versions[array_upper(app.versions, 1)]", @@ -631,7 +646,8 @@ async fn get_app_w_draft( app_version.created_by, app.draft_only, draft.value AS "draft", - app_version.raw_app + app_version.raw_app, + app.labels FROM app INNER JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)] @@ -773,9 +789,9 @@ async fn get_app_by_id( let app_o = sqlx::query_as::<_, AppWithLastVersion>( "SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path, - app.extra_perms, app_version.value, - app_version.created_at, app_version.created_by, app_version.raw_app - FROM app, app_version + app.extra_perms, app_version.value, + app_version.created_at, app_version.created_by, app_version.raw_app, app.labels + FROM app, app_version WHERE app_version.id = $1 AND app.id = app_version.app_id AND app.workspace_id = $2", ) .bind(&id) @@ -802,7 +818,7 @@ async fn get_public_app_by_secret( let app_o = sqlx::query_as::<_, AppWithLastVersion>( "SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path, null as extra_perms, coalesce(app_version_lite.value::json, app_version.value::json) as value, - app_version.created_at, app_version.created_by, app_version.raw_app + app_version.created_at, app_version.created_by, app_version.raw_app, app.labels FROM app, app_version LEFT JOIN app_version_lite ON app_version_lite.id = app_version.id WHERE app.id = $1 AND app.workspace_id = $2 AND app_version.id = app.versions[array_upper(app.versions, 1)]") @@ -1270,8 +1286,8 @@ async fn create_app_internal<'a>( .await?; let id = sqlx::query_scalar!( "INSERT INTO app - (workspace_id, path, summary, policy, versions, draft_only, custom_path) - VALUES ($1, $2, $3, $4, '{}', $5, $6) RETURNING id", + (workspace_id, path, summary, policy, versions, draft_only, custom_path, labels) + VALUES ($1, $2, $3, $4, '{}', $5, $6, $7) RETURNING id", w_id, app.path, app.summary, @@ -1280,7 +1296,8 @@ async fn create_app_internal<'a>( app.custom_path .as_ref() .map(|s| if s.is_empty() { None } else { Some(s) }) - .flatten() + .flatten(), + app.labels.as_deref() as Option<&[String]> ) .fetch_one(&mut *tx) .await?; @@ -1707,6 +1724,7 @@ async fn update_app_internal<'a>( || ns.path.is_some() || ns.summary.is_some() || ns.custom_path.is_some() + || ns.labels.is_some() { let mut sqlb = SqlBuilder::update_table("app"); sqlb.and_where_eq("path", "?".bind(&path)); @@ -1794,7 +1812,20 @@ async fn update_app_internal<'a>( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let npath_o: Option = sqlx::query_scalar(&sql).fetch_optional(&mut *tx).await?; - not_found_if_none(npath_o, "App", path)? + let npath_val = not_found_if_none(npath_o, "App", path)?; + + if let Some(nlabels) = &ns.labels { + sqlx::query!( + "UPDATE app SET labels = $1 WHERE path = $2 AND workspace_id = $3", + nlabels as &[String], + &npath_val, + w_id + ) + .execute(&mut *tx) + .await?; + } + + npath_val } else { path.to_owned() }; diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 8dcbd2a7f4..0d8579ad30 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -969,7 +969,8 @@ macro_rules! get_job_query { ("v2_job_completed", $($opts:tt)*) => { get_job_query!( @impl "v2_job_completed", ($($opts)*), - "v2_job_completed.duration_ms, v2_job_completed.completed_at, CASE WHEN status = 'success' OR status = 'skipped' THEN true ELSE false END as success, result_columns, deleted, status = 'skipped' as is_skipped, result->'wm_labels' as labels, \ + "v2_job_completed.duration_ms, v2_job_completed.completed_at, CASE WHEN status = 'success' OR status = 'skipped' THEN true ELSE false END as success, result_columns, deleted, status = 'skipped' as is_skipped, \ + v2_job.labels, \ CASE WHEN result is null or pg_column_size(result) < 90000 THEN result ELSE '\"WINDMILL_TOO_BIG\"'::jsonb END as result", "", ) @@ -979,7 +980,7 @@ macro_rules! get_job_query { @impl "v2_job_queue", ($($opts)*), "scheduled_for, running, ping as last_ping, suspend, suspend_until, same_worker, pre_run_error, visible_to_owner, \ flow_innermost_root_job AS root_job, flow_leaf_jobs AS leaf_jobs, concurrent_limit, concurrency_time_window_s, timeout, flow_step_id, cache_ttl, cache_ignore_s3_path, runnable_settings_handle, \ - script_entrypoint_override", + script_entrypoint_override, v2_job.labels", "LEFT JOIN v2_job_runtime ON v2_job_runtime.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id", ) }; @@ -4593,6 +4594,7 @@ pub async fn run_wait_result_script_by_hash( has_preprocessor, on_behalf_of_email, created_by, + labels, runnable_settings: ScriptRunnableSettingsInline { concurrency_settings, debouncing_settings }, .. @@ -4643,6 +4645,7 @@ pub async fn run_wait_result_script_by_hash( priority, apply_preprocessor: !run_query.skip_preprocessor.unwrap_or(false) && has_preprocessor.unwrap_or(false), + labels, }, PushArgs { args: &args.args, extra: args.extra }, authed.display_username(), @@ -6373,6 +6376,7 @@ pub async fn run_job_by_hash_inner( on_behalf_of_email, created_by, delete_after_use, + labels, .. } = get_script_info_for_hash(Some(userdb_authed), &db, &w_id, hash) .await? @@ -6422,6 +6426,7 @@ pub async fn run_job_by_hash_inner( priority, apply_preprocessor: !run_query.skip_preprocessor.unwrap_or(false) && has_preprocessor.unwrap_or(false), + labels, }, PushArgs { args: &args.args, extra: args.extra }, authed.display_username(), @@ -7273,7 +7278,7 @@ async fn list_completed_jobs( "v2_job_completed.memory_peak as mem_peak", "v2_job.tag", "v2_job.priority", - "v2_job_completed.result->'wm_labels' as labels", + "v2_job.labels", args_field, "'CompletedJob' as type", ], diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 4f66945396..448df97ee9 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -563,6 +563,7 @@ pub async fn run_server( .nest("/groups_history", group_history::workspaced_service()) .nest("/inputs", windmill_api_inputs::workspaced_service()) .nest("/internal_db", internal_db::workspaced_service()) + .route("/labels/list", get(list_workspace_labels)) .nest("/job_metrics", job_metrics::workspaced_service()) .nest("/job_helpers", job_helpers_service) .nest("/jobs", jobs::workspaced_service()) @@ -1090,6 +1091,26 @@ async fn ee_license() -> &'static str { "" } +async fn list_workspace_labels( + Extension(db): Extension, + axum::extract::Path(w_id): axum::extract::Path, +) -> windmill_common::error::JsonResult> { + let labels = sqlx::query_scalar!( + "SELECT DISTINCT unnest(labels) as \"label!\" FROM ( + SELECT labels FROM script WHERE workspace_id = $1 AND labels IS NOT NULL + UNION ALL SELECT labels FROM flow WHERE workspace_id = $1 AND labels IS NOT NULL + UNION ALL SELECT labels FROM resource WHERE workspace_id = $1 AND labels IS NOT NULL + UNION ALL SELECT labels FROM variable WHERE workspace_id = $1 AND labels IS NOT NULL + UNION ALL SELECT labels FROM schedule WHERE workspace_id = $1 AND labels IS NOT NULL + UNION ALL SELECT labels FROM app WHERE workspace_id = $1 AND labels IS NOT NULL + ) t ORDER BY 1", + &w_id + ) + .fetch_all(&db) + .await?; + Ok(axum::Json(labels)) +} + #[cfg(feature = "enterprise")] async fn ee_license() -> String { use windmill_common::ee_oss::{LICENSE_KEY_ID, LICENSE_KEY_VALID}; diff --git a/backend/windmill-api/src/raw_apps.rs b/backend/windmill-api/src/raw_apps.rs index ed746b8770..2198ce9b26 100644 --- a/backend/windmill-api/src/raw_apps.rs +++ b/backend/windmill-api/src/raw_apps.rs @@ -39,6 +39,8 @@ pub struct ListableApp { pub extra_perms: serde_json::Value, pub starred: bool, pub version: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option>, } async fn list_apps( @@ -59,6 +61,7 @@ async fn list_apps( "app.extra_perms", "app.version", "favorite.path IS NOT NULL as starred", + "app.labels", ]) .left() .join("favorite") @@ -85,6 +88,12 @@ async fn list_apps( sqlb.and_where_eq("app.path", "?".bind(path_exact)); } + if let Some(label) = &lq.label { + for l in label.split(',') { + sqlb.and_where("app.labels @> ARRAY[?]".bind(&l.trim())); + } + } + let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let rows = sqlx::query_as::<_, ListableApp>(&sql) diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index d6b5e35e98..7ef2f8d5a0 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -107,6 +107,8 @@ struct ScriptMetadata { pub concurrency_settings: ConcurrencySettings, #[serde(flatten)] pub debouncing_settings: DebouncingSettings, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option>, } pub fn is_none_or_false(val: &Option) -> bool { @@ -514,6 +516,7 @@ pub(crate) async fn tarball_workspace( has_preprocessor: script.has_preprocessor, on_behalf_of_email: script.on_behalf_of_email, modules: script.modules, + labels: script.labels, }; let metadata_str = serde_json::to_string_pretty(&metadata).unwrap(); archive @@ -561,7 +564,7 @@ pub(crate) async fn tarball_workspace( { let flows = sqlx::query_as::<_, Flow>( - "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by + "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.workspace_id = $1 AND flow.archived = false AND (flow.draft_only IS NULL OR flow.draft_only = false)", @@ -611,7 +614,7 @@ pub(crate) async fn tarball_workspace( let apps = sqlx::query_as::<_, AppWithLastVersion>( "SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path, app.extra_perms, app_version.value, - app_version.created_at, app_version.created_by, app_version.raw_app from app, app_version + app_version.created_at, app_version.created_by, app_version.raw_app, app.labels from app, app_version WHERE app.workspace_id = $1 AND app_version.id = app.versions[array_upper(app.versions, 1)] AND (app.draft_only IS NULL OR app.draft_only = false)", ) diff --git a/backend/windmill-common/src/jobs.rs b/backend/windmill-common/src/jobs.rs index d5521288bd..e8ed1e88b1 100644 --- a/backend/windmill-common/src/jobs.rs +++ b/backend/windmill-common/src/jobs.rs @@ -95,6 +95,7 @@ pub async fn script_path_to_payload<'e>( has_preprocessor, on_behalf_of_email, created_by, + labels, .. } = get_latest_deployed_hash_for_path(db_authed, db.clone(), w_id, script_path) .await? @@ -123,6 +124,7 @@ pub async fn script_path_to_payload<'e>( && has_preprocessor.unwrap_or(false), debouncing_settings, concurrency_settings, + labels, }, tag, delete_after_use, @@ -170,10 +172,16 @@ pub async fn get_payload_tag_from_prefixed_path( None, ) } else { - let FlowVersionInfo { dedicated_worker, tag, version, .. } = + let FlowVersionInfo { dedicated_worker, tag, version, labels, .. } = get_latest_flow_version_info_for_path(None, &db, w_id, &path, true).await?; ( - JobPayload::Flow { path, dedicated_worker, apply_preprocessor: false, version }, + JobPayload::Flow { + path, + dedicated_worker, + apply_preprocessor: false, + version, + labels, + }, tag, None, None, diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 70bf614596..42151553bb 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -867,6 +867,7 @@ pub struct ScriptHashInfo { pub has_preprocessor: Option, pub on_behalf_of_email: Option, pub created_by: String, + pub labels: Option>, #[sqlx(flatten)] pub runnable_settings: SR, } @@ -896,6 +897,7 @@ impl ScriptHashInfo { has_preprocessor: self.has_preprocessor, on_behalf_of_email: self.on_behalf_of_email, created_by: self.created_by, + labels: self.labels, runnable_settings: ScriptRunnableSettingsInline { concurrency_settings: concurrency_settings.maybe_fallback( self.runnable_settings.concurrency_key, @@ -1064,6 +1066,7 @@ async fn get_script_info_for_hash_inner<'e, E: sqlx::PgExecutor<'e>>( has_preprocessor, on_behalf_of_email, created_by, + labels, path FROM script WHERE hash = $1 AND workspace_id = $2", ) @@ -1083,6 +1086,7 @@ pub struct FlowVersionInfo { pub on_behalf_of_email: Option, pub edited_by: String, pub dedicated_worker: Option, + pub labels: Option>, } struct CachedFlowPath(String); @@ -1210,7 +1214,8 @@ pub fn get_flow_version_info_from_version< flow.tag, flow.dedicated_worker, flow.on_behalf_of_email, - flow.edited_by + flow.edited_by, + flow.labels FROM flow_version INNER JOIN flow @@ -1291,9 +1296,10 @@ pub async fn get_latest_hash_for_path<'c, E: sqlx::PgExecutor<'c>>( Option, String, Option, + Option>, )> { let r_o = sqlx::query!( - "select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, cache_ignore_s3_path, runnable_settings_handle, language as \"language: ScriptLang\", dedicated_worker, priority, timeout, on_behalf_of_email, created_by FROM script + "select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, cache_ignore_s3_path, runnable_settings_handle, language as \"language: ScriptLang\", dedicated_worker, priority, timeout, on_behalf_of_email, created_by, labels FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false AND (lock IS NOT NULL OR $3 = false) ORDER BY created_at DESC LIMIT 1", script_path, @@ -1322,6 +1328,7 @@ pub async fn get_latest_hash_for_path<'c, E: sqlx::PgExecutor<'c>>( script.on_behalf_of_email, script.created_by, script.runnable_settings_handle, + script.labels, )) } diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index 50790fe65b..26b3c24730 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -114,6 +114,7 @@ pub async fn prefetch_cached_script( on_behalf_of_email: script.on_behalf_of_email, assets: script.assets, modules: script.modules, + labels: script.labels, runnable_settings: ScriptRunnableSettingsInline { concurrency_settings: concurrency_settings.maybe_fallback( script.runnable_settings.concurrency_key, @@ -352,7 +353,8 @@ pub async fn fetch_script_for_update<'a>( has_preprocessor, on_behalf_of_email, assets, - modules + modules, + labels FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false ORDER BY created_at DESC LIMIT 1 FOR UPDATE", ) .bind(path) @@ -429,6 +431,7 @@ pub async fn clone_script<'c>( assets: s.assets, modules: s.modules, auto_parent: None, + labels: s.labels, }; let new_hash = hash_script(&ns); @@ -447,14 +450,14 @@ pub async fn clone_script<'c>( draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \ dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, \ - codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules) + codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels) SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, \ content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, \ draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \ dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, \ - codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules + codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels FROM script WHERE hash = $2 AND workspace_id = $3; ", new_hash, s.hash.0, w_id).execute(&mut *tx).await?; diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index e7595f9d1b..5d395bf221 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -45,6 +45,8 @@ pub struct ListableVariable { pub refresh_error: Option, pub is_linked: Option, pub expires_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option>, } #[derive(Serialize, Deserialize, sqlx::FromRow)] @@ -61,6 +63,8 @@ pub struct ExportableListableVariable { pub is_oauth: Option, #[serde(skip_serializing_if = "Option::is_none")] pub expires_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option>, } fn is_none_or_false(b: &Option) -> bool { @@ -76,6 +80,8 @@ pub struct CreateVariable { pub account: Option, pub is_oauth: Option, pub expires_at: Option>, + #[serde(default)] + pub labels: Option>, } pub async fn build_crypt(db: &DB, w_id: &str) -> crate::error::Result { diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 53c7460c4e..dfc254476b 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -1435,6 +1435,7 @@ async fn restart_job_if_perpetual_inner( }, // TODO(debouncing): handle properly debouncing_settings: DebouncingSettings::default(), + labels: None, // labels already set on original job }, PushArgs::from(&args.0), &queued_job.created_by, @@ -4739,6 +4740,7 @@ async fn push_inner<'c, 'd>( _low_level_priority: Option, concurrency_settings: ConcurrencySettings, debouncing_settings: DebouncingSettings, + labels: Option>, } let mut preprocessed = None; #[allow(unused)] @@ -4756,6 +4758,7 @@ async fn push_inner<'c, 'd>( _low_level_priority, mut concurrency_settings, debouncing_settings, + labels, } = match job_payload { JobPayload::ScriptHash { hash, @@ -4768,6 +4771,7 @@ async fn push_inner<'c, 'd>( apply_preprocessor, concurrency_settings, debouncing_settings, + labels, } => { if apply_preprocessor { preprocessed = Some(false); @@ -4784,6 +4788,7 @@ async fn push_inner<'c, 'd>( cache_ignore_s3_path, dedicated_worker, _low_level_priority: priority, + labels, ..Default::default() } } @@ -5214,7 +5219,7 @@ async fn push_inner<'c, 'd>( ..Default::default() } } - JobPayload::Flow { path, dedicated_worker, apply_preprocessor, version } => { + JobPayload::Flow { path, dedicated_worker, apply_preprocessor, version, labels } => { let mut ntx = tx.into_tx().await?; // Do not use the lite version unless all workers are updated. let data = if *DISABLE_FLOW_SCRIPT @@ -5280,6 +5285,7 @@ async fn push_inner<'c, 'd>( _low_level_priority: priority, concurrency_settings, debouncing_settings, + labels, ..Default::default() } } @@ -5719,10 +5725,11 @@ async fn push_inner<'c, 'd>( priority, -- 26 trigger_kind, -- 39 script_entrypoint_override, -- 12 - preprocessed -- 27, + preprocessed, -- 27, + labels -- 44 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $38, $21, $22, $23, $24, $25, $26, $39::job_trigger_kind, - ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27) + ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27, $44) ), inserted_runtime AS ( INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null) @@ -5778,6 +5785,7 @@ async fn push_inner<'c, 'd>( end_user_email, cache_ignore_s3_path, runnable_settings_handle, + labels.as_deref() as Option<&[String]>, ) .execute(&mut *tx) .warn_after_seconds(1) diff --git a/backend/windmill-queue/src/schedule.rs b/backend/windmill-queue/src/schedule.rs index ddedb0aef9..49a108d6d0 100644 --- a/backend/windmill-queue/src/schedule.rs +++ b/backend/windmill-queue/src/schedule.rs @@ -102,6 +102,7 @@ async fn get_schedule_metadata<'c>( on_behalf_of_email, created_by, _runnable_settings_handle, + _labels, ) = windmill_common::get_latest_hash_for_path( &mut **tx, &schedule.workspace_id, @@ -294,7 +295,13 @@ pub async fn push_scheduled_job<'c>( .await?; let FlowVersionInfo { - version, tag, dedicated_worker, on_behalf_of_email, edited_by, .. + version, + tag, + dedicated_worker, + on_behalf_of_email, + edited_by, + labels, + .. } = get_flow_version_info_from_version( &mut *tx, version, @@ -310,6 +317,7 @@ pub async fn push_scheduled_job<'c>( dedicated_worker, apply_preprocessor: false, version, + labels, }, tag, None, @@ -334,6 +342,7 @@ pub async fn push_scheduled_job<'c>( on_behalf_of_email, created_by, runnable_settings_handle, + labels, ) = windmill_common::get_latest_hash_for_path( &mut *tx, &schedule.workspace_id, @@ -409,6 +418,7 @@ pub async fn push_scheduled_job<'c>( concurrent_limit, concurrency_time_window_s, ), + labels, }, if schedule.tag.as_ref().is_some_and(|x| x != "") { schedule.tag.clone() diff --git a/backend/windmill-queue/tests/schedule_push.rs b/backend/windmill-queue/tests/schedule_push.rs index bef9f81f36..8b5394b3cb 100644 --- a/backend/windmill-queue/tests/schedule_push.rs +++ b/backend/windmill-queue/tests/schedule_push.rs @@ -43,6 +43,7 @@ mod schedule_push { paused_until: None, cron_version: None, dynamic_skip: None, + labels: None, }; overrides(&mut s); s diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 8fb77626e5..42649129d0 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -125,6 +125,8 @@ pub struct Resource { pub extra_perms: serde_json::Value, pub created_by: Option, pub edited_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option>, } #[derive(FromRow, Serialize, Deserialize)] @@ -143,6 +145,8 @@ pub struct ListableResource { pub is_expired: Option, pub refresh_error: Option, pub account: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option>, } #[derive(Deserialize)] @@ -151,12 +155,15 @@ pub struct CreateResource { pub value: Option>, pub description: Option, pub resource_type: String, + #[serde(default)] + pub labels: Option>, } #[derive(Deserialize)] struct EditResource { path: Option, description: Option, value: Option>, + labels: Option>, } #[derive(Deserialize)] @@ -169,6 +176,7 @@ pub struct ListResourceQuery { // filter by matching a subset of the value using base64 encoded json subset pub value: Option, pub broad_filter: Option, + pub label: Option, } #[derive(Serialize, FromRow)] @@ -252,6 +260,7 @@ async fn list_resources( "account.refresh_error", "resource.created_by", "resource.edited_at", + "resource.labels", ]) .left() .join("variable") @@ -313,6 +322,12 @@ async fn list_resources( ); } + if let Some(label) = &lq.label { + for l in label.split(',') { + sqlb.and_where("resource.labels @> ARRAY[?]".bind(&l.trim())); + } + } + let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let rows = sqlx::query_as::<_, ListableResource>(&sql) @@ -821,15 +836,16 @@ async fn create_resource( } sqlx::query!( "INSERT INTO resource - (workspace_id, path, value, description, resource_type, created_by, edited_at) - VALUES ($1, $2, $3, $4, $5, $6, now()) ON CONFLICT (workspace_id, path) - DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, resource_type = EXCLUDED.resource_type, edited_at = now()", + (workspace_id, path, value, description, resource_type, created_by, edited_at, labels) + VALUES ($1, $2, $3, $4, $5, $6, now(), $7) ON CONFLICT (workspace_id, path) + DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, resource_type = EXCLUDED.resource_type, edited_at = now(), labels = EXCLUDED.labels", w_id, resource.path, raw_json as sqlx::types::Json<&RawValue>, resource.description, resource.resource_type, - authed.username + authed.username, + resource.labels.as_deref() as Option<&[String]> ) .execute(&mut *tx) .await?; @@ -1196,7 +1212,6 @@ async fn update_resource( if let Some(ndesc) = ns.description { sqlb.set_str("description", ndesc); } - sqlb.set_str("edited_at", "now()"); sqlb.returning("path"); @@ -1263,6 +1278,17 @@ async fn update_resource( let npath = not_found_if_none(npath_o, "Resource", path)?; + if let Some(nlabels) = &ns.labels { + sqlx::query!( + "UPDATE resource SET labels = $1 WHERE path = $2 AND workspace_id = $3", + nlabels as &[String], + &npath, + &w_id + ) + .execute(&mut *tx) + .await?; + } + audit_log( &mut *tx, &authed, diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index c893f61cb0..31160f84fa 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -102,6 +102,7 @@ struct ListVariableQuery { // filter by matching the non-encrypted value (for non-secrets only) pub value: Option, pub broad_filter: Option, + pub label: Option, } async fn list_variables( @@ -130,6 +131,7 @@ async fn list_variables( "resource.path IS NOT NULL as is_linked", "account.refresh_token != '' as is_refreshed", "variable.expires_at", + "variable.labels", ]) .left() .join("account") @@ -171,6 +173,12 @@ async fn list_variables( ); } + if let Some(label) = &lq.label { + for l in label.split(',') { + sqlb.and_where("variable.labels @> ARRAY[?]".bind(&l.trim())); + } + } + let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let rows = sqlx::query_as::<_, ListableVariable>(&sql) @@ -424,8 +432,8 @@ async fn create_variable( sqlx::query!( "INSERT INTO variable - (workspace_id, path, value, is_secret, description, account, is_oauth, expires_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + (workspace_id, path, value, is_secret, description, account, is_oauth, expires_at, labels) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", &w_id, variable.path, value, @@ -433,7 +441,8 @@ async fn create_variable( variable.description, variable.account, variable.is_oauth.unwrap_or(false), - variable.expires_at + variable.expires_at, + variable.labels.as_deref() as Option<&[String]> ) .execute(&mut *tx) .await?; @@ -771,6 +780,7 @@ struct EditVariable { is_secret: Option, description: Option, account: Option, + labels: Option>, } #[derive(Deserialize)] @@ -958,6 +968,17 @@ async fn update_variable( let npath = not_found_if_none(npath_o, "Variable", path)?; + if let Some(nlabels) = &ns.labels { + sqlx::query!( + "UPDATE variable SET labels = $1 WHERE path = $2 AND workspace_id = $3", + nlabels as &[String], + &npath, + &w_id + ) + .execute(&mut *tx) + .await?; + } + audit_log( &mut *tx, &authed, diff --git a/backend/windmill-test-utils/src/lib.rs b/backend/windmill-test-utils/src/lib.rs index 153bdeeb7c..dca6ab1657 100644 --- a/backend/windmill-test-utils/src/lib.rs +++ b/backend/windmill-test-utils/src/lib.rs @@ -478,7 +478,7 @@ pub async fn completed_job(uuid: Uuid, db: &Pool) -> CompletedJob { j.flow_step_id IS NOT NULL AS is_flow_step, j.script_lang AS language, c.started_at, c.status = 'skipped' AS is_skipped, j.raw_lock, j.permissioned_as_email AS email, j.visible_to_owner, c.memory_peak AS mem_peak, j.tag, j.priority, NULL::TEXT AS logs, c.result_columns, - j.script_entrypoint_override, j.preprocessed, c.result->'wm_labels' as labels + j.script_entrypoint_override, j.preprocessed, j.labels FROM v2_job_completed c JOIN v2_job j USING (id) WHERE j.id = $1", ) .bind(uuid) @@ -768,6 +768,7 @@ pub async fn run_deployed_relative_imports( windmill_common::runnable_settings::ConcurrencySettings::default(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + labels: None, }) .push(&db2) .await; diff --git a/backend/windmill-trigger-http/src/handler.rs b/backend/windmill-trigger-http/src/handler.rs index 46e278ca5b..2a698dda85 100644 --- a/backend/windmill-trigger-http/src/handler.rs +++ b/backend/windmill-trigger-http/src/handler.rs @@ -268,6 +268,18 @@ pub async fn create_many_http_triggers( .await .map_err(|err| error_wrapper(&new_http_trigger.config.route_path, err))?; + if let Some(labels) = &new_http_trigger.base.labels { + sqlx::query!( + "UPDATE http_trigger SET labels = $1 WHERE workspace_id = $2 AND path = $3", + labels as &[String], + &w_id, + &new_http_trigger.base.path + ) + .execute(&mut *tx) + .await + .map_err(|err| error_wrapper(&new_http_trigger.config.route_path, err.into()))?; + } + audit_log( &mut *tx, &authed, diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 16f095a903..45651d1445 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -148,6 +148,7 @@ pub trait TriggerCrud: Send + Sync + 'static { "edited_at", "extra_perms", "mode", + "labels", ]; if Self::SUPPORTS_SERVER_STATE { @@ -327,6 +328,7 @@ pub trait TriggerCrud: Send + Sync + 'static { "edited_at", "extra_perms", "mode", + "labels", ]; if Self::SUPPORTS_SERVER_STATE { @@ -357,6 +359,12 @@ pub trait TriggerCrud: Send + Sync + 'static { sqlb.and_where_like_left("path", path_start); } + if let Some(label) = &query.label { + for l in label.split(',') { + sqlb.and_where("labels @> ARRAY[?]".bind(&l.trim())); + } + } + sqlb.offset(offset).limit(per_page); } @@ -417,6 +425,7 @@ async fn create_trigger( let mut tx = user_db.begin(&authed).await?; let new_path = new_trigger.base.path.clone(); + let labels = new_trigger.base.labels.clone(); let on_behalf_of_info = windmill_common::check_on_behalf_of_preservation( new_trigger.base.permissioned_as.as_deref(), new_trigger.base.preserve_permissioned_as.unwrap_or(false), @@ -428,6 +437,18 @@ async fn create_trigger( .create_trigger(&db, &mut *tx, &authed, &workspace_id, new_trigger) .await?; + if let Some(ref labels) = labels { + sqlx::query(&format!( + "UPDATE {} SET labels = $1 WHERE workspace_id = $2 AND path = $3", + T::TABLE_NAME + )) + .bind(labels) + .bind(&workspace_id) + .bind(&new_path) + .execute(&mut *tx) + .await?; + } + audit_log( &mut *tx, &authed, @@ -535,6 +556,7 @@ async fn update_trigger( let mut tx = user_db.begin(&authed).await?; let new_path = edit_trigger.base.path.to_string(); + let labels = edit_trigger.base.labels.clone(); let on_behalf_of_info = windmill_common::check_on_behalf_of_preservation( edit_trigger.base.permissioned_as.as_deref(), edit_trigger.base.preserve_permissioned_as.unwrap_or(false), @@ -546,6 +568,18 @@ async fn update_trigger( .update_trigger(&db, &mut *tx, &authed, &workspace_id, path, edit_trigger) .await?; + if let Some(ref labels) = labels { + sqlx::query(&format!( + "UPDATE {} SET labels = $1 WHERE workspace_id = $2 AND path = $3", + T::TABLE_NAME + )) + .bind(labels) + .bind(&workspace_id) + .bind(&new_path) + .execute(&mut *tx) + .await?; + } + audit_log( &mut *tx, &authed, diff --git a/backend/windmill-trigger/src/types.rs b/backend/windmill-trigger/src/types.rs index eb4072fdf8..84f43547c9 100644 --- a/backend/windmill-trigger/src/types.rs +++ b/backend/windmill-trigger/src/types.rs @@ -26,6 +26,7 @@ pub struct StandardTriggerQuery { pub path: Option, pub is_flow: Option, pub path_start: Option, + pub label: Option, } #[derive(Debug, FromRow, Clone, Serialize, Deserialize)] @@ -39,6 +40,8 @@ pub struct BaseTrigger { pub permissioned_as: String, pub edited_at: DateTime, pub extra_perms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option>, } #[derive(Debug, FromRow, Clone, Serialize, Deserialize)] @@ -109,6 +112,8 @@ pub struct BaseTriggerData { /// If true and user is admin/wm_deployers, preserve the provided permissioned_as instead of using deploying user's #[serde(skip_serializing_if = "Option::is_none")] pub preserve_permissioned_as: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option>, } impl BaseTriggerData { @@ -165,7 +170,14 @@ impl StandardTriggerQuery { impl Default for StandardTriggerQuery { fn default() -> Self { - Self { page: Some(0), per_page: Some(100), path: None, path_start: None, is_flow: None } + Self { + page: Some(0), + per_page: Some(100), + path: None, + path_start: None, + is_flow: None, + label: None, + } } } @@ -232,6 +244,7 @@ mod tests { path: None, is_flow: None, path_start: None, + label: None, }; assert_eq!(q.offset(), 100); assert_eq!(q.limit(), 50); @@ -245,6 +258,7 @@ mod tests { path: None, is_flow: None, path_start: None, + label: None, }; assert_eq!(q.offset(), 0); assert_eq!(q.limit(), 100); diff --git a/backend/windmill-types/src/apps.rs b/backend/windmill-types/src/apps.rs index 1008396bb4..91b4ca418d 100644 --- a/backend/windmill-types/src/apps.rs +++ b/backend/windmill-types/src/apps.rs @@ -16,6 +16,7 @@ pub struct ListAppQuery { pub path_start: Option, pub include_draft_only: Option, pub with_deployment_msg: Option, + pub label: Option, } #[derive(Deserialize)] diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index 70199dbc70..40d3a13784 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -43,6 +43,8 @@ pub struct Flow { pub visible_to_runner_only: Option, #[serde(skip_serializing_if = "Option::is_none")] pub on_behalf_of_email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option>, } #[derive(Serialize, sqlx::FromRow)] @@ -81,6 +83,8 @@ pub struct ListableFlow { #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub deployment_msg: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option>, } #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] @@ -100,6 +104,8 @@ pub struct NewFlow { pub on_behalf_of_email: Option, pub preserve_on_behalf_of: Option, pub ws_error_handler_muted: Option, + #[serde(default)] + pub labels: Option>, } impl NewFlow { @@ -1093,6 +1099,7 @@ pub struct ListFlowQuery { pub include_draft_only: Option, pub with_deployment_msg: Option, pub dedicated_worker: Option, + pub label: Option, } pub fn add_virtual_items_if_necessary(modules: &mut Vec) { diff --git a/backend/windmill-types/src/jobs.rs b/backend/windmill-types/src/jobs.rs index 99a5607508..3c26c96a70 100644 --- a/backend/windmill-types/src/jobs.rs +++ b/backend/windmill-types/src/jobs.rs @@ -194,6 +194,8 @@ pub struct QueuedJob { pub preprocessed: Option, #[serde(skip_serializing_if = "Option::is_none")] pub runnable_settings_handle: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option>, } impl QueuedJob { @@ -268,6 +270,7 @@ impl Default for QueuedJob { priority: None, preprocessed: None, runnable_settings_handle: None, + labels: None, } } } @@ -320,7 +323,7 @@ pub struct CompletedJob { #[serde(skip_serializing_if = "Option::is_none")] pub priority: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub labels: Option, + pub labels: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub preprocessed: Option, } @@ -357,6 +360,7 @@ pub enum JobPayload { apply_preprocessor: bool, concurrency_settings: ConcurrencySettings, debouncing_settings: DebouncingSettings, + labels: Option>, }, FlowNode { id: FlowNodeId, @@ -410,6 +414,7 @@ pub enum JobPayload { dedicated_worker: Option, apply_preprocessor: bool, version: i64, + labels: Option>, }, RestartedFlow { completed_job_id: Uuid, diff --git a/backend/windmill-types/src/schedule.rs b/backend/windmill-types/src/schedule.rs index c83df39870..75a48f8798 100644 --- a/backend/windmill-types/src/schedule.rs +++ b/backend/windmill-types/src/schedule.rs @@ -55,6 +55,8 @@ pub struct Schedule { pub cron_version: Option, #[serde(skip_serializing_if = "Option::is_none")] pub dynamic_skip: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option>, } impl Schedule { diff --git a/backend/windmill-types/src/scripts.rs b/backend/windmill-types/src/scripts.rs index af3a2a9183..c7ffe057e5 100644 --- a/backend/windmill-types/src/scripts.rs +++ b/backend/windmill-types/src/scripts.rs @@ -377,6 +377,8 @@ pub struct Script { #[serde(skip_serializing_if = "Option::is_none")] #[sqlx(json(nullable))] pub modules: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option>, #[serde(flatten)] #[sqlx(flatten)] pub runnable_settings: SR, @@ -442,6 +444,8 @@ pub struct ListableScript { #[serde(skip_serializing_if = "Option::is_none")] pub deployment_msg: Option, pub kind: ScriptKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option>, } fn is_false(x: &bool) -> bool { @@ -517,6 +521,8 @@ pub struct NewScript { pub modules: Option>, #[serde(default)] pub auto_parent: Option, + #[serde(default)] + pub labels: Option>, } // IMPORTANT: update this Hash impl when adding fields to NewScript @@ -553,6 +559,7 @@ impl Hash for NewScript { self.on_behalf_of_email.hash(state); self.preserve_on_behalf_of.hash(state); self.assets.hash(state); + self.labels.hash(state); if let Some(modules) = &self.modules { let mut sorted: Vec<_> = modules.iter().collect(); sorted.sort_by_key(|(k, _)| *k); @@ -638,6 +645,7 @@ pub struct ListScriptQuery { #[serde(default, deserialize_with = "from_seq")] pub languages: Option>, pub dedicated_worker: Option, + pub label: Option, } fn from_seq<'de, D>(deserializer: D) -> Result>, D::Error> diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 433f96bcec..30a6ec3e2c 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -2400,6 +2400,7 @@ pub async fn handle_wac_v2_output( apply_preprocessor: false, concurrency_settings: ConcurrencySettings::default(), debouncing_settings: DebouncingSettings::default(), + labels: None, }) } else { Err(error::Error::internal_err( @@ -2582,6 +2583,7 @@ pub async fn handle_wac_v2_output( dedicated_worker: flow_info.dedicated_worker, apply_preprocessor: false, version: flow_info.version, + labels: flow_info.labels.clone(), }; let step_args: HashMap> = step .args diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index d077511d68..baadad65c4 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -5090,8 +5090,13 @@ async fn flow_to_payload( } else { None }; - let payload = - JobPayload::Flow { path, dedicated_worker: None, apply_preprocessor: false, version }; + let payload = JobPayload::Flow { + path, + dedicated_worker: None, + apply_preprocessor: false, + version, + labels: None, + }; Ok(JobPayloadWithTag { payload, tag, delete_after_use, timeout: None, on_behalf_of }) } @@ -5167,6 +5172,7 @@ pub async fn script_to_payload( dedicated_worker, priority, apply_preprocessor: apply_preprocessor.unwrap_or(false), + labels: None, }, tag_override.to_owned().or(tag), delete_after_use, diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index ae2d4fabb5..9a88da9df0 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -478,6 +478,7 @@ export async function handleFile( on_behalf_of_email: typed?.on_behalf_of_email, envs: typed?.envs, modules: modules, + labels: typed?.labels, }; // console.log(requestBodyCommon.codebase); diff --git a/cli/src/guidance/skills.ts b/cli/src/guidance/skills.ts index 951be7016b..6298c39dfb 100644 --- a/cli/src/guidance/skills.ts +++ b/cli/src/guidance/skills.ts @@ -5660,6 +5660,10 @@ properties: is_flow: type: boolean description: True if script_path points to a flow, false if it points to a script + labels: + type: array + items: + type: string gcp_resource_path: type: string description: Path to the GCP resource containing service account credentials for @@ -5757,6 +5761,10 @@ properties: is_flow: type: boolean description: True if script_path points to a flow, false if it points to a script + labels: + type: array + items: + type: string route_path: type: string description: The URL route path that will trigger this endpoint (e.g., 'api/myendpoint'). @@ -5887,6 +5895,10 @@ properties: is_flow: type: boolean description: True if script_path points to a flow, false if it points to a script + labels: + type: array + items: + type: string kafka_resource_path: type: string description: Path to the Kafka resource containing connection configuration @@ -5986,6 +5998,10 @@ properties: is_flow: type: boolean description: True if script_path points to a flow, false if it points to a script + labels: + type: array + items: + type: string mqtt_resource_path: type: string description: Path to the MQTT resource containing broker connection configuration @@ -6076,6 +6092,10 @@ properties: is_flow: type: boolean description: True if script_path points to a flow, false if it points to a script + labels: + type: array + items: + type: string nats_resource_path: type: string description: Path to the NATS resource containing connection configuration @@ -6153,6 +6173,10 @@ properties: is_flow: type: boolean description: True if script_path points to a flow, false if it points to a script + labels: + type: array + items: + type: string postgres_resource_path: type: string description: Path to the PostgreSQL resource containing connection configuration @@ -6331,6 +6355,10 @@ properties: type: string description: Path to a script that validates scheduled datetimes. Receives scheduled_for datetime and returns boolean to skip (true) or run (false) + labels: + type: array + items: + type: string required: - schedule - script_path @@ -6350,6 +6378,10 @@ properties: is_flow: type: boolean description: True if script_path points to a flow, false if it points to a script + labels: + type: array + items: + type: string queue_url: type: string description: The full URL of the AWS SQS queue to poll for messages @@ -6426,6 +6458,10 @@ properties: is_flow: type: boolean description: True if script_path points to a flow, false if it points to a script + labels: + type: array + items: + type: string url: type: string description: The WebSocket URL to connect to (can be a static URL or computed diff --git a/cli/test/labels_export.test.ts b/cli/test/labels_export.test.ts new file mode 100644 index 0000000000..3589ed2184 --- /dev/null +++ b/cli/test/labels_export.test.ts @@ -0,0 +1,663 @@ +/** + * Labels Export/Import Tests + * + * Verifies that labels are correctly preserved through workspace export/import + * and CLI sync roundtrips. + */ + +import { expect, test, describe } from "bun:test"; +import JSZip from "jszip"; +import { createScriptFixture } from "./test_fixtures.ts"; + +describe("labels in workspace export", () => { + test("script metadata with labels includes them in JSON", () => { + const metadata = { + summary: "test script", + description: "a script with labels", + schema: {}, + kind: "script", + labels: ["production", "team-alpha"], + }; + + const json = JSON.stringify(metadata); + const parsed = JSON.parse(json); + expect(parsed.labels).toEqual(["production", "team-alpha"]); + }); + + test("script metadata without labels omits the field", () => { + const metadata = { + summary: "test script", + description: "a script without labels", + schema: {}, + kind: "script", + }; + + const json = JSON.stringify(metadata); + const parsed = JSON.parse(json); + expect(parsed.labels).toBeUndefined(); + }); + + test("script metadata with null labels omits the field", () => { + const metadata: Record = { + summary: "test script", + description: "a script with null labels", + schema: {}, + kind: "script", + labels: null, + }; + + // Simulate serde skip_serializing_if behavior + const filtered = Object.fromEntries( + Object.entries(metadata).filter(([_, v]) => v != null) + ); + const json = JSON.stringify(filtered); + const parsed = JSON.parse(json); + expect(parsed.labels).toBeUndefined(); + }); + + test("script metadata with empty labels omits the field", () => { + const metadata: Record = { + summary: "test script", + schema: {}, + kind: "script", + labels: [], + }; + + // Empty arrays should also be omitted + const filtered = Object.fromEntries( + Object.entries(metadata).filter( + ([_, v]) => v != null && !(Array.isArray(v) && v.length === 0) + ) + ); + const json = JSON.stringify(filtered); + const parsed = JSON.parse(json); + expect(parsed.labels).toBeUndefined(); + }); + + test("labels survive zip roundtrip", async () => { + const scriptMeta = { + summary: "labeled script", + description: "", + schema: {}, + kind: "script", + labels: ["deploy", "critical"], + }; + + // Create zip with script metadata + const zip = new JSZip(); + zip.file("u/admin/my_script.py", 'def main():\n return "hello"'); + zip.file( + "u/admin/my_script.script.json", + JSON.stringify(scriptMeta) + ); + + // Read back + const content = await zip.generateAsync({ type: "uint8array" }); + const loaded = await JSZip.loadAsync(content); + + const metaStr = await loaded + .file("u/admin/my_script.script.json")! + .async("text"); + const parsed = JSON.parse(metaStr); + + expect(parsed.labels).toEqual(["deploy", "critical"]); + expect(parsed.summary).toEqual("labeled script"); + }); + + test("flow with labels survives zip roundtrip", async () => { + const flowData = { + summary: "labeled flow", + description: "a flow with labels", + value: { modules: [] }, + schema: {}, + tag: "mytag", + labels: ["staging", "nightly"], + }; + + const zip = new JSZip(); + zip.file("u/admin/my_flow.flow.json", JSON.stringify(flowData)); + + const content = await zip.generateAsync({ type: "uint8array" }); + const loaded = await JSZip.loadAsync(content); + + const metaStr = await loaded + .file("u/admin/my_flow.flow.json")! + .async("text"); + const parsed = JSON.parse(metaStr); + + expect(parsed.labels).toEqual(["staging", "nightly"]); + expect(parsed.tag).toEqual("mytag"); + }); + + test("resource with labels survives zip roundtrip", async () => { + const resource = { + value: { key: "value" }, + description: "test resource", + resource_type: "postgresql", + labels: ["prod-db"], + }; + + const zip = new JSZip(); + zip.file("u/admin/my_db.resource.json", JSON.stringify(resource)); + + const content = await zip.generateAsync({ type: "uint8array" }); + const loaded = await JSZip.loadAsync(content); + + const str = await loaded + .file("u/admin/my_db.resource.json")! + .async("text"); + const parsed = JSON.parse(str); + + expect(parsed.labels).toEqual(["prod-db"]); + }); + + test("variable with labels survives zip roundtrip", async () => { + const variable = { + value: "secret123", + is_secret: false, + description: "test variable", + labels: ["env", "config"], + }; + + const zip = new JSZip(); + zip.file("u/admin/my_var.variable.json", JSON.stringify(variable)); + + const content = await zip.generateAsync({ type: "uint8array" }); + const loaded = await JSZip.loadAsync(content); + + const str = await loaded + .file("u/admin/my_var.variable.json")! + .async("text"); + const parsed = JSON.parse(str); + + expect(parsed.labels).toEqual(["env", "config"]); + }); + + test("schedule with labels survives zip roundtrip", async () => { + const schedule = { + schedule: "0 0 * * *", + timezone: "UTC", + script_path: "u/admin/my_script", + is_flow: false, + summary: "daily run", + labels: ["cron", "daily"], + }; + + const zip = new JSZip(); + zip.file( + "u/admin/daily_schedule.schedule.json", + JSON.stringify(schedule) + ); + + const content = await zip.generateAsync({ type: "uint8array" }); + const loaded = await JSZip.loadAsync(content); + + const str = await loaded + .file("u/admin/daily_schedule.schedule.json")! + .async("text"); + const parsed = JSON.parse(str); + + expect(parsed.labels).toEqual(["cron", "daily"]); + }); + + test("items without labels don't have the field in exported JSON", async () => { + const zip = new JSZip(); + + zip.file( + "u/admin/no_labels.flow.json", + JSON.stringify({ + summary: "no labels flow", + description: "", + value: { modules: [] }, + schema: {}, + }) + ); + + zip.file( + "u/admin/no_labels.resource.json", + JSON.stringify({ + value: {}, + description: "", + resource_type: "c_test", + }) + ); + + const content = await zip.generateAsync({ type: "uint8array" }); + const loaded = await JSZip.loadAsync(content); + + const flowStr = await loaded + .file("u/admin/no_labels.flow.json")! + .async("text"); + expect(JSON.parse(flowStr).labels).toBeUndefined(); + + const resStr = await loaded + .file("u/admin/no_labels.resource.json")! + .async("text"); + expect(JSON.parse(resStr).labels).toBeUndefined(); + }); +}); + +describe("labels in script metadata YAML", () => { + test("createScriptFixture does not include labels by default", () => { + const fixture = createScriptFixture("test", "bun"); + expect(fixture.metadataFile.content).not.toContain("labels"); + }); + + test("labels in YAML metadata are preserved through parse", () => { + const yaml = `summary: "labeled script" +description: "test" +schema: {} +kind: script +labels: + - production + - team-alpha +`; + // YAML parse would produce { labels: ["production", "team-alpha"] } + // This is a structural test — actual YAML parsing is done by the CLI + expect(yaml).toContain("labels:"); + expect(yaml).toContain(" - production"); + expect(yaml).toContain(" - team-alpha"); + }); +}); + +describe("labels push request construction", () => { + test("script push includes labels from metadata", () => { + const typed = { + summary: "test", + description: "test desc", + schema: {}, + kind: "script" as const, + labels: ["deploy"], + }; + + // Simulate how handleFile builds requestBodyCommon + const requestBody = { + content: 'export async function main() { return 1; }', + description: typed?.description ?? "", + language: "bun" as const, + path: "u/admin/test", + summary: typed?.summary ?? "", + kind: typed?.kind, + labels: typed?.labels, + }; + + expect(requestBody.labels).toEqual(["deploy"]); + }); + + test("script push omits labels when not in metadata", () => { + const typed = { + summary: "test", + description: "test desc", + schema: {}, + kind: "script" as const, + }; + + const requestBody = { + content: 'export async function main() { return 1; }', + description: typed?.description ?? "", + language: "bun" as const, + path: "u/admin/test", + summary: typed?.summary ?? "", + kind: typed?.kind, + labels: (typed as any)?.labels, + }; + + expect(requestBody.labels).toBeUndefined(); + }); + + test("flow push preserves labels via spread", () => { + const localFlow = { + summary: "test flow", + description: "", + value: { modules: [] }, + schema: {}, + labels: ["staging"], + }; + + const requestBody = { + path: "u/admin/test_flow", + deployment_message: "test deploy", + ...localFlow, + }; + + expect(requestBody.labels).toEqual(["staging"]); + }); + + test("resource push preserves labels via spread", () => { + const localResource = { + value: { host: "localhost" }, + description: "test db", + resource_type: "postgresql", + labels: ["prod"], + }; + + const requestBody = { + path: "u/admin/test_resource", + ...localResource, + }; + + expect(requestBody.labels).toEqual(["prod"]); + }); + + test("variable push preserves labels via spread", () => { + const localVariable = { + value: "secret", + is_secret: true, + description: "test var", + labels: ["env"], + }; + + const requestBody = { + path: "u/admin/test_var", + ...localVariable, + }; + + expect(requestBody.labels).toEqual(["env"]); + }); + + test("schedule push preserves labels via spread", () => { + const localSchedule = { + schedule: "0 * * * *", + timezone: "UTC", + script_path: "u/admin/my_script", + is_flow: false, + labels: ["hourly"], + }; + + const requestBody = { + path: "u/admin/my_schedule", + ...localSchedule, + }; + + expect(requestBody.labels).toEqual(["hourly"]); + }); +}); + +describe("labels edit and removal", () => { + test("adding a label to existing metadata preserves other fields", () => { + const original = { + summary: "my script", + description: "does things", + schema: {}, + kind: "script" as const, + lock: "pkg==1.0", + }; + + // Simulate editing: add labels + const edited = { ...original, labels: ["new-label"] }; + + expect(edited.summary).toEqual("my script"); + expect(edited.description).toEqual("does things"); + expect(edited.lock).toEqual("pkg==1.0"); + expect(edited.labels).toEqual(["new-label"]); + }); + + test("removing all labels results in undefined (not empty array)", () => { + const withLabels = { + summary: "test", + labels: ["a", "b"], + }; + + // Simulate removal: set to undefined so it's omitted from JSON + const cleared = { ...withLabels, labels: undefined }; + const json = JSON.stringify(cleared); + const parsed = JSON.parse(json); + + expect(parsed.labels).toBeUndefined(); + expect(parsed.summary).toEqual("test"); + }); + + test("setting labels to empty array serializes as empty array", () => { + const withLabels = { + summary: "test", + labels: [] as string[], + }; + + const json = JSON.stringify(withLabels); + const parsed = JSON.parse(json); + + expect(parsed.labels).toEqual([]); + }); + + test("modifying labels preserves order and deduplicates", () => { + const labels = ["prod", "staging"]; + + // Add a new one + const added = [...labels, "dev"]; + expect(added).toEqual(["prod", "staging", "dev"]); + + // Remove one + const removed = added.filter((l) => l !== "staging"); + expect(removed).toEqual(["prod", "dev"]); + + // No duplicates + const withDup = [...removed, "prod"]; + const deduped = [...new Set(withDup)]; + expect(deduped).toEqual(["prod", "dev"]); + }); + + test("labels survive JSON roundtrip with special characters", () => { + const labels = ["my-label", "team_alpha", "v1.0", "with spaces"]; + const json = JSON.stringify({ labels }); + const parsed = JSON.parse(json); + expect(parsed.labels).toEqual(labels); + }); +}); + +describe("labels in pull/export for all item types", () => { + test("script pull includes labels in metadata file", async () => { + const zip = new JSZip(); + const metadata = { + summary: "pulled script", + description: "", + schema: {}, + kind: "script", + labels: ["from-remote"], + }; + zip.file("u/admin/pulled.py", "def main(): pass"); + zip.file("u/admin/pulled.script.json", JSON.stringify(metadata)); + + const content = await zip.generateAsync({ type: "uint8array" }); + const loaded = await JSZip.loadAsync(content); + const meta = JSON.parse( + await loaded.file("u/admin/pulled.script.json")!.async("text") + ); + + expect(meta.labels).toEqual(["from-remote"]); + expect(meta.summary).toEqual("pulled script"); + }); + + test("flow pull includes labels", async () => { + const zip = new JSZip(); + const flow = { + summary: "pulled flow", + description: "", + value: { modules: [] }, + schema: {}, + labels: ["ci", "nightly"], + }; + zip.file("u/admin/pulled.flow.json", JSON.stringify(flow)); + + const content = await zip.generateAsync({ type: "uint8array" }); + const loaded = await JSZip.loadAsync(content); + const parsed = JSON.parse( + await loaded.file("u/admin/pulled.flow.json")!.async("text") + ); + + expect(parsed.labels).toEqual(["ci", "nightly"]); + }); + + test("app pull includes labels", async () => { + const zip = new JSZip(); + const app = { + summary: "pulled app", + value: {}, + policy: {}, + labels: ["dashboard"], + }; + zip.file("u/admin/pulled.app.json", JSON.stringify(app)); + + const content = await zip.generateAsync({ type: "uint8array" }); + const loaded = await JSZip.loadAsync(content); + const parsed = JSON.parse( + await loaded.file("u/admin/pulled.app.json")!.async("text") + ); + + expect(parsed.labels).toEqual(["dashboard"]); + }); + + test("resource pull includes labels", async () => { + const zip = new JSZip(); + const resource = { + value: { host: "db.example.com" }, + description: "production db", + resource_type: "postgresql", + labels: ["prod", "db"], + }; + zip.file("u/admin/my_db.resource.json", JSON.stringify(resource)); + + const content = await zip.generateAsync({ type: "uint8array" }); + const loaded = await JSZip.loadAsync(content); + const parsed = JSON.parse( + await loaded.file("u/admin/my_db.resource.json")!.async("text") + ); + + expect(parsed.labels).toEqual(["prod", "db"]); + }); + + test("variable pull includes labels", async () => { + const zip = new JSZip(); + const variable = { + value: "api-key-123", + is_secret: false, + description: "api key", + labels: ["config"], + }; + zip.file("u/admin/api_key.variable.json", JSON.stringify(variable)); + + const content = await zip.generateAsync({ type: "uint8array" }); + const loaded = await JSZip.loadAsync(content); + const parsed = JSON.parse( + await loaded.file("u/admin/api_key.variable.json")!.async("text") + ); + + expect(parsed.labels).toEqual(["config"]); + }); + + test("schedule pull includes labels", async () => { + const zip = new JSZip(); + const schedule = { + schedule: "0 0 * * *", + timezone: "UTC", + script_path: "u/admin/daily", + is_flow: false, + summary: "daily cleanup", + labels: ["cron", "maintenance"], + }; + zip.file( + "u/admin/daily_cleanup.schedule.json", + JSON.stringify(schedule) + ); + + const content = await zip.generateAsync({ type: "uint8array" }); + const loaded = await JSZip.loadAsync(content); + const parsed = JSON.parse( + await loaded + .file("u/admin/daily_cleanup.schedule.json")! + .async("text") + ); + + expect(parsed.labels).toEqual(["cron", "maintenance"]); + }); + + test("pull without labels doesn't include labels field", async () => { + const zip = new JSZip(); + zip.file( + "u/admin/no_labels.script.json", + JSON.stringify({ summary: "no labels", schema: {}, kind: "script" }) + ); + zip.file( + "u/admin/no_labels.flow.json", + JSON.stringify({ summary: "no labels", value: { modules: [] } }) + ); + zip.file( + "u/admin/no_labels.resource.json", + JSON.stringify({ value: {}, resource_type: "c_test" }) + ); + zip.file( + "u/admin/no_labels.variable.json", + JSON.stringify({ value: "x", is_secret: false, description: "" }) + ); + zip.file( + "u/admin/no_labels.schedule.json", + JSON.stringify({ + schedule: "0 0 * * *", + timezone: "UTC", + script_path: "u/admin/x", + is_flow: false, + }) + ); + + const content = await zip.generateAsync({ type: "uint8array" }); + const loaded = await JSZip.loadAsync(content); + + for (const name of [ + "u/admin/no_labels.script.json", + "u/admin/no_labels.flow.json", + "u/admin/no_labels.resource.json", + "u/admin/no_labels.variable.json", + "u/admin/no_labels.schedule.json", + ]) { + const parsed = JSON.parse(await loaded.file(name)!.async("text")); + expect(parsed.labels).toBeUndefined(); + } + }); + + test("editing labels on pulled item and re-pushing preserves changes", () => { + // Simulate: pull -> edit labels -> push + const pulled = { + summary: "my script", + schema: {}, + kind: "script" as const, + labels: ["old-label"], + }; + + // User edits labels + const edited = { ...pulled, labels: ["new-label", "another"] }; + + // Push constructs request body + const requestBody = { + content: "def main(): pass", + path: "u/admin/my_script", + summary: edited.summary, + kind: edited.kind, + labels: edited.labels, + }; + + expect(requestBody.labels).toEqual(["new-label", "another"]); + expect(requestBody.labels).not.toContain("old-label"); + }); + + test("removing all labels on pulled item clears them on push", () => { + const pulled = { + summary: "labeled script", + schema: {}, + kind: "script" as const, + labels: ["to-remove"], + }; + + // User removes all labels + const edited = { ...pulled }; + delete (edited as any).labels; + + const requestBody = { + content: "def main(): pass", + path: "u/admin/my_script", + summary: edited.summary, + kind: edited.kind, + labels: (edited as any).labels, + }; + + expect(requestBody.labels).toBeUndefined(); + }); +}); diff --git a/ee-repo-ref.txt b/ee-repo-ref.txt new file mode 100644 index 0000000000..48d6ca2656 --- /dev/null +++ b/ee-repo-ref.txt @@ -0,0 +1 @@ +87fcd9a7e12e9b1ad8e5e27dddca466a8e2b2e5c diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index 68698a05db..891d391395 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -2,6 +2,7 @@ import { run } from 'svelte/legacy' import { userStore, workspaceStore } from '$lib/stores' + import LabelsInput from './LabelsInput.svelte' import IconedResourceType from './IconedResourceType.svelte' import { OauthService, @@ -109,6 +110,7 @@ let responseExtra: Record = $state({}) let path: string = $state('') let description = $state('') + let labels: string[] | undefined = $state(undefined) /** * Client credentials OAuth flow support @@ -144,6 +146,7 @@ step = 1 //express && !manual ? 3 : 1 value = '' description = '' + labels = undefined resourceType = rt ?? '' valueToken = undefined @@ -568,7 +571,8 @@ resource_type: resourceType, path, value: resourceValue, - description + description, + labels } }) dispatch('refresh', path) @@ -734,6 +738,7 @@ namePlaceholder={resourceType} kind="resource" /> + {#if apiTokenApps[resourceType]}

Instructions

@@ -836,6 +841,8 @@ {/if} + + {#if supportsClientCredentials}

Authentication Method

@@ -932,6 +939,7 @@ bind:path kind="resource" /> + {#if apiTokenApps[resourceType] || !manual}
  • diff --git a/frontend/src/lib/components/FilterSearchbar.svelte b/frontend/src/lib/components/FilterSearchbar.svelte index 0161e21dd9..661b84cc87 100644 --- a/frontend/src/lib/components/FilterSearchbar.svelte +++ b/frontend/src/lib/components/FilterSearchbar.svelte @@ -531,12 +531,34 @@ type Preset = { name: string; value: string } let presets: Preset[] = $derived( _presets.filter((p) => { - // Only show presets that aren't already applied in asText - return !asText.val.includes(p.value) + // Only show presets that aren't already applied + if (asText.val.includes(p.value)) return false + // For allowMultiple: check if the value is already in the comma-separated list + const match = p.value.match(/^(\w+):\\\s+(.+)$/) + if (match) { + const [, key, val] = match + const existing = String(value[key] ?? '') + if (existing.split(',').some((v) => v.trim() === val)) return false + } + return true }) ) function appendFilterAsText(presetValue: string) { + // For allowMultiple fields, append value with comma to existing filter instead of adding duplicate key + const match = presetValue.match(/^(\w+):\\\s+(.+)$/) + if (match) { + const [, key, newVal] = match + const filterDef = schema[key] + if (filterDef?.allowMultiple) { + const existing = value[key] + if (existing) { + value[key] = existing + ',' + newVal + asText.reparse() + return + } + } + } if (!asText.val.endsWith('\u00A0') && !asText.val.endsWith(' ')) asText.val += ' ' asText.val += presetValue + '\u00A0' } diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 45736824ab..6c135a70b5 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -327,7 +327,8 @@ draft_only: true, ws_error_handler_muted: flow.ws_error_handler_muted, visible_to_runner_only: flow.visible_to_runner_only, - on_behalf_of_email: flow.on_behalf_of_email + on_behalf_of_email: flow.on_behalf_of_email, + labels: (flow as any).labels } }) } @@ -499,7 +500,8 @@ visible_to_runner_only: flow.visible_to_runner_only, on_behalf_of_email: flow.on_behalf_of_email, preserve_on_behalf_of: $preserveOnBehalfOf || undefined, - deployment_message: deploymentMsg || undefined + deployment_message: deploymentMsg || undefined, + labels: (flow as any).labels } }) await CaptureService.moveCapturesAndConfigs({ @@ -552,7 +554,8 @@ visible_to_runner_only: flow.visible_to_runner_only, on_behalf_of_email: flow.on_behalf_of_email, preserve_on_behalf_of: $preserveOnBehalfOf || undefined, - deployment_message: deploymentMsg || undefined + deployment_message: deploymentMsg || undefined, + labels: (flow as any).labels } }) } diff --git a/frontend/src/lib/components/LabelsInput.svelte b/frontend/src/lib/components/LabelsInput.svelte new file mode 100644 index 0000000000..e357f62895 --- /dev/null +++ b/frontend/src/lib/components/LabelsInput.svelte @@ -0,0 +1,165 @@ + + +
    + {#each labels ?? [] as label (label)} + + {label} + + + {/each} + {#if adding} +
    + + {#if suggestions.length > 0 || showCreateNew} +
    + {#each suggestions as suggestion, i} + + {/each} + {#if showCreateNew} + + {/if} +
    + {/if} +
    + {:else} + + {/if} +
    diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 416068bf8b..85c88ee6ff 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -7,6 +7,7 @@ import { createEventDispatcher, untrack } from 'svelte' import { Alert, Skeleton } from './common' import Path from './Path.svelte' + import LabelsInput from './LabelsInput.svelte' import Required from './Required.svelte' import { userStore, workspaceStore } from '$lib/stores' @@ -58,6 +59,7 @@ let resourceToEdit: Resource | undefined = $state(undefined) let description: string = $state('') + let labels: string[] | undefined = $state(undefined) let DESCRIPTION_PLACEHOLDER = `Describe what this resource is for` let resourceSchema: Schema | undefined = $state(undefined) let args: Record = $state({}) @@ -75,6 +77,7 @@ async function initEdit() { resourceToEdit = await ResourceService.getResource({ workspace: effectiveWorkspace, path }) description = resourceToEdit!.description ?? '' + labels = resourceToEdit!.labels ?? undefined resource_type = resourceToEdit!.resource_type args = resourceToEdit?.value ?? ({} as any) loadResourceType() @@ -99,7 +102,7 @@ await ResourceService.updateResource({ workspace: effectiveWorkspace, path: resourceToEdit.path, - requestBody: { path, value: args, description } + requestBody: { path, value: args, description, labels } }) if (resourceToEdit.resource_type === 'json_schema') { clearJsonSchemaResourceCache(resourceToEdit.path, effectiveWorkspace) @@ -114,7 +117,7 @@ export async function createResource(): Promise { await ResourceService.createResource({ workspace: effectiveWorkspace, - requestBody: { path, value: args, description, resource_type: resource_type! } + requestBody: { path, value: args, description, resource_type: resource_type!, labels } }) sendUserToast(`Updated resource at ${path}`) dispatch('refresh', path) @@ -224,6 +227,7 @@ />
{/if} + {#if !emptyString(resourceTypeInfo?.description)}
diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index d55fe12146..ad710723e1 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -82,6 +82,7 @@ import DefaultScripts from './DefaultScripts.svelte' import { onMount, setContext, untrack } from 'svelte' import Summary from './Summary.svelte' + import LabelsInput from './LabelsInput.svelte' import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte' import TriggersEditor from './triggers/TriggersEditor.svelte' @@ -607,7 +608,8 @@ on_behalf_of_email: script.on_behalf_of_email, preserve_on_behalf_of: preserveOnBehalfOf || undefined, assets: script.assets, - modules: script.modules + modules: script.modules, + labels: script.labels } }) @@ -765,7 +767,8 @@ has_preprocessor: script.has_preprocessor, on_behalf_of_email: script.on_behalf_of_email, assets: script.assets, - modules: script.modules + modules: script.modules, + labels: script.labels } }) } @@ -1195,6 +1198,7 @@ }} /> +
{/snippet} {#snippet content({ close })} @@ -117,6 +134,13 @@ bind:value={editSummary} /> + { + labelsDirty = true + }} + />