diff --git a/CHANGELOG.md b/CHANGELOG.md index bf2306e00c..fe1f07c8bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.721.0](https://github.com/windmill-labs/windmill/compare/v1.720.0...v1.721.0) (2026-06-09) + + +### Features + +* deployed↔draft compare + AI-session draft bar ([#9435](https://github.com/windmill-labs/windmill/issues/9435)) ([b0b330c](https://github.com/windmill-labs/windmill/commit/b0b330c7864d0159af4b0f17dbb3c09bd015145b)) + + +### Bug Fixes + +* **cli:** reconcile case-only path drift during sync on case-insensitive filesystems (WIN-2020) ([#9485](https://github.com/windmill-labs/windmill/issues/9485)) ([c258928](https://github.com/windmill-labs/windmill/commit/c258928ab62adc1327913c21556520dfd1e5c24c)) +* drop archived items from fork compare (spurious 'not visible' warning) ([#9481](https://github.com/windmill-labs/windmill/issues/9481)) ([92c21bb](https://github.com/windmill-labs/windmill/commit/92c21bbe6586f3c285796a98692e976515a629d5)) +* require auth to view approval details when user_auth_required ([#9482](https://github.com/windmill-labs/windmill/issues/9482)) ([5f41ddd](https://github.com/windmill-labs/windmill/commit/5f41ddd3a592bcd504f94fc99060ca5d79c36190)) + ## [1.720.0](https://github.com/windmill-labs/windmill/compare/v1.719.0...v1.720.0) (2026-06-08) diff --git a/ai_evals/adapters/frontend/mockBackend.ts b/ai_evals/adapters/frontend/mockBackend.ts index 2750058a68..622c79bf46 100644 --- a/ai_evals/adapters/frontend/mockBackend.ts +++ b/ai_evals/adapters/frontend/mockBackend.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto' -import type { CompletedJob, Flow, Script } from '../../../frontend/src/lib/gen' +import type { CompletedJob, Flow, Job, Script } from '../../../frontend/src/lib/gen' import type { DataTableTables, DataTableTableSchema, @@ -29,18 +29,36 @@ export interface BenchmarkWorkspaceFlow { value: Flow['value'] } +export interface BenchmarkWorkspaceJob { + /** Stable id so a case prompt can reference a specific run (e.g. for get_job_logs). */ + id?: string + jobKind?: CompletedJob['job_kind'] + scriptPath?: string + createdBy?: string + label?: string + success?: boolean + logs?: string +} + export interface BenchmarkWorkspaceRunnables { scripts?: BenchmarkWorkspaceScript[] flows?: BenchmarkWorkspaceFlow[] datatables?: BenchmarkDatatableSeed[] + jobs?: BenchmarkWorkspaceJob[] } type BenchmarkCompletedJob = CompletedJob & { type: 'CompletedJob' } const benchmarkWorkspaces = new Set() const benchmarkWorkspaceRunnables = new Map() +// Keyed by `${workspace}::${jobId}` so concurrent attempts (or distinct cases) +// can seed the same fixed job id without clobbering each other's entry. const benchmarkJobs = new Map() +function benchmarkJobKey(workspace: string, jobId: string): string { + return `${workspace}::${jobId}` +} + export function resetBenchmarkMockBackend(): void { benchmarkWorkspaces.clear() benchmarkWorkspaceRunnables.clear() @@ -62,6 +80,19 @@ export function registerBenchmarkWorkspaceRunnables( ...runnables, datatables: runnables.datatables ? structuredClone(runnables.datatables) : undefined }) + // Seed any fixture jobs so list_runs / get_job_logs have data to return. + for (const seed of runnables.jobs ?? []) { + createBenchmarkCompletedJob({ + workspace, + id: seed.id, + jobKind: seed.jobKind ?? 'script', + success: seed.success, + scriptPath: seed.scriptPath, + createdBy: seed.createdBy, + label: seed.label, + logs: seed.logs + }) + } } export function unregisterBenchmarkWorkspace(workspace: string): void { @@ -131,14 +162,17 @@ export function createBenchmarkCompletedJob(input: { scriptPath?: string scriptHash?: string args?: Record + id?: string + createdBy?: string + label?: string }): string { - const jobId = `benchmark-job-${randomUUID()}` + const jobId = input.id ?? `benchmark-job-${randomUUID()}` const now = new Date().toISOString() const job: BenchmarkCompletedJob = { type: 'CompletedJob', id: jobId, workspace_id: input.workspace, - created_by: 'ai-evals', + created_by: input.createdBy ?? 'ai-evals', created_at: now, started_at: now, completed_at: now, @@ -156,10 +190,11 @@ export function createBenchmarkCompletedJob(input: { is_skipped: false, email: 'ai-evals@local', visible_to_owner: true, - tag: 'benchmark' + tag: 'benchmark', + labels: input.label ? [input.label] : undefined } - benchmarkJobs.set(jobId, { workspace: input.workspace, job }) + benchmarkJobs.set(benchmarkJobKey(input.workspace, jobId), { workspace: input.workspace, job }) return jobId } @@ -167,13 +202,42 @@ export function getBenchmarkCompletedJob( workspace: string, jobId: string ): BenchmarkCompletedJob | null { - const entry = benchmarkJobs.get(jobId) - if (!entry || entry.workspace !== workspace) { + const entry = benchmarkJobs.get(benchmarkJobKey(workspace, jobId)) + if (!entry) { return null } return structuredClone(entry.job) } +/** + * List seeded/recorded jobs for a benchmark workspace, most recent first — + * the shape `JobService.listJobs` returns. Returns `null` for a non-benchmark + * workspace so the caller can fall through to the real backend. Server-side + * filters (path/creator/status/limit) are intentionally not applied: global + * eval cases assert on the recorded `list_runs` tool call, not on filtering. + */ +export function listBenchmarkJobs(workspace: string): Job[] | null { + if (!hasBenchmarkWorkspace(workspace)) { + return null + } + return [...benchmarkJobs.values()] + .filter((entry) => entry.workspace === workspace) + .map((entry) => structuredClone(entry.job) as Job) + .sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? '')) +} + +/** + * Mirror `JobService.getJobLogs` (response is the raw log string). Throws a + * "not found" error for an unknown id, matching the backend 404. + */ +export function getBenchmarkJobLogs(workspace: string, jobId: string): string { + const job = getBenchmarkCompletedJob(workspace, jobId) + if (!job) { + throw new Error(`Job Logs not found for "${jobId}"`) + } + return job.logs ?? '' +} + // ============= Datatables (best-effort in-memory SQL) ============= /** diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index a84f990483..ebbbac8d11 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -37,11 +37,13 @@ vi.mock('$lib/gen', async () => { getBenchmarkCompletedJobResultMaybe, getBenchmarkDatatableSchema, getBenchmarkFlowByPath, + getBenchmarkJobLogs, getBenchmarkScriptByHash, getBenchmarkScriptByPath, hasBenchmarkWorkspace, listBenchmarkDatatables, listBenchmarkFlows, + listBenchmarkJobs, listBenchmarkScripts, createBenchmarkHttpTrigger, createBenchmarkSchedule, @@ -199,7 +201,15 @@ vi.mock('$lib/gen', async () => { getCompletedJobResultMaybe: async (data: { workspace: string; id: string }) => hasBenchmarkWorkspace(data.workspace) ? getBenchmarkCompletedJobResultMaybe({ workspace: data.workspace, id: data.id }) - : actual.JobService.getCompletedJobResultMaybe(data) + : actual.JobService.getCompletedJobResultMaybe(data), + listJobs: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) + ? (listBenchmarkJobs(data.workspace) ?? []) + : actual.JobService.listJobs(data), + getJobLogs: async (data: { workspace: string; id: string }) => + hasBenchmarkWorkspace(data.workspace) + ? getBenchmarkJobLogs(data.workspace, data.id) + : actual.JobService.getJobLogs(data) }), WorkspaceService: wrapService(actual.WorkspaceService, { listDataTableTables: async (data: { workspace: string }) => diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 8d58248d62..28839b0594 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -821,3 +821,52 @@ - uses the wmill.datatable() SDK to query the orders table and sum the order totals - returns the total revenue from the script - leaves the result as an AI draft and does not deploy or save it + +- id: global-test27-list-recent-runs + prompt: |- + What are the most recent runs in this workspace? Give me a quick rundown of what ran and whether it succeeded. + initial: ai_evals/fixtures/frontend/global/initial/jobs_seed.json + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - list_runs + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + - write_script + # Read-only job inspection produces no draft, so the global judge (which only + # sees the drafts artifact) would score it empty — validate via tool use. + skipJudge: true + judgeChecklist: + - lists the recent runs from the workspace rather than fabricating them + - summarizes each run's path and success/failure from the returned data + +- id: global-test28-fetch-failed-run-logs + prompt: |- + The run with id 01920000-0000-7000-8000-0000000000f1 failed. Pull its logs and tell me what went wrong. + initial: ai_evals/fixtures/frontend/global/initial/jobs_seed.json + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - get_job_logs + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + - write_script + toolCallArgs: + - tool: get_job_logs + field: id + stringIncludesAnyOf: + - 01920000-0000-7000-8000-0000000000f1 + # Same as above — no draft is produced, so rely on the deterministic tool-use + # and argument checks rather than the judge. + skipJudge: true + judgeChecklist: + - fetches the logs for the requested job id + - explains the failure from the returned logs (connection refused to the upstream API) diff --git a/ai_evals/fixtures/frontend/global/initial/jobs_seed.json b/ai_evals/fixtures/frontend/global/initial/jobs_seed.json new file mode 100644 index 0000000000..b075d6df26 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/jobs_seed.json @@ -0,0 +1,30 @@ +{ + "workspace": { + "jobs": [ + { + "id": "01920000-0000-7000-8000-0000000000f1", + "scriptPath": "f/etl/sync_customers", + "jobKind": "script", + "createdBy": "alice", + "success": false, + "logs": "Starting customer sync...\nFetched 0 records\nERROR: connection refused to https://api.upstream.example.com\n at fetchCustomers (sync_customers.ts:42)\nJob failed with exit code 1" + }, + { + "id": "01920000-0000-7000-8000-0000000000f2", + "scriptPath": "f/reports/daily_digest", + "jobKind": "script", + "createdBy": "bob", + "success": true, + "logs": "Generating daily digest...\nDigest emailed to 12 recipients\nDone in 1.2s" + }, + { + "id": "01920000-0000-7000-8000-0000000000f3", + "scriptPath": "f/billing/charge_invoices", + "jobKind": "flow", + "createdBy": "alice", + "success": true, + "logs": "Processing invoices...\nCharged 8 invoices totalling $1,240.00\nDone" + } + ] + } +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 90eb8d984a..a37ee0f26f 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -13782,7 +13782,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-nats", @@ -13864,7 +13864,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.720.0" +version = "1.721.0" dependencies = [ "async-stream", "async-trait", @@ -13897,7 +13897,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13910,7 +13910,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "argon2", @@ -14048,7 +14048,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14071,7 +14071,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14084,7 +14084,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14110,7 +14110,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.720.0" +version = "1.721.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14120,7 +14120,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14137,7 +14137,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14159,7 +14159,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14182,7 +14182,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14198,7 +14198,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14219,7 +14219,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14240,7 +14240,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14254,7 +14254,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-nats", @@ -14289,7 +14289,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14314,7 +14314,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14332,7 +14332,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14354,7 +14354,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14374,7 +14374,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14405,7 +14405,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14433,7 +14433,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.720.0" +version = "1.721.0" dependencies = [ "lazy_static", "serde", @@ -14445,7 +14445,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.720.0" +version = "1.721.0" dependencies = [ "argon2", "axum 0.8.9", @@ -14470,7 +14470,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14484,7 +14484,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.720.0" +version = "1.721.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14517,7 +14517,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.720.0" +version = "1.721.0" dependencies = [ "chrono", "lazy_static", @@ -14531,7 +14531,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14550,7 +14550,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.720.0" +version = "1.721.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14651,7 +14651,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.720.0" +version = "1.721.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14670,7 +14670,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.720.0" +version = "1.721.0" dependencies = [ "regex", "serde", @@ -14685,7 +14685,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14709,7 +14709,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "futures", @@ -14726,7 +14726,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.720.0" +version = "1.721.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14742,7 +14742,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -14763,7 +14763,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -14794,7 +14794,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "arc-swap", @@ -14819,7 +14819,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-stream", @@ -14853,7 +14853,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "futures", @@ -14871,7 +14871,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.720.0" +version = "1.721.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14880,7 +14880,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "lazy_static", @@ -14892,7 +14892,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde_json", @@ -14904,7 +14904,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "gosyn", @@ -14916,7 +14916,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "lazy_static", @@ -14928,7 +14928,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde_json", @@ -14940,7 +14940,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "nu-parser", @@ -14951,7 +14951,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14962,7 +14962,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14974,7 +14974,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "rustpython-ast", @@ -14985,7 +14985,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-recursion", @@ -15007,7 +15007,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde_json", @@ -15019,7 +15019,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "lazy_static", @@ -15033,7 +15033,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15050,7 +15050,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "lazy_static", @@ -15063,7 +15063,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde", @@ -15075,7 +15075,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "lazy_static", @@ -15093,7 +15093,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15109,7 +15109,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15125,7 +15125,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde", @@ -15136,7 +15136,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-recursion", @@ -15174,7 +15174,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "const_format", @@ -15212,7 +15212,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.720.0" +version = "1.721.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15223,7 +15223,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-recursion", @@ -15255,7 +15255,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -15279,7 +15279,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -15312,7 +15312,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -15345,7 +15345,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -15365,7 +15365,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -15399,7 +15399,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -15435,7 +15435,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -15458,7 +15458,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -15482,7 +15482,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-nats", @@ -15506,7 +15506,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -15541,7 +15541,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -15569,7 +15569,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-trait", @@ -15594,7 +15594,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -15613,7 +15613,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-once-cell", @@ -15723,7 +15723,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.720.0" +version = "1.721.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index d702cae2b1..5a9f761a35 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.720.0" +version = "1.721.0" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.720.0" +version = "1.721.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 96ebbb46c5..97628e2d6e 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.720.0" +version = "1.721.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.720.0" +version = "1.721.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.720.0" +version = "1.721.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.720.0" +version = "1.721.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 8610456c91..4de32388cc 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.720.0" +version = "1.721.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 56b1884ed6..a286cce5e4 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.720.0 + version: 1.721.0 title: Windmill API contact: diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index db4d24e75c..8964beb6be 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -1077,6 +1077,15 @@ async fn get_otel_tracing_proxy_envs( /// through the proxy without TLS interception, so clients that pin their own CA (kubectl, /// helm, terraform, etc.) keep working. Empty when unset, matching the prior behavior of /// intercepting all destinations including loopback. +/// +/// The worker's own `NO_PROXY` env is merged in so that enabling HTTP request tracing never +/// silently narrows exclusions an operator already configured at the container level: hosts +/// reachable directly before tracing was turned on stay reachable directly afterwards. The +/// upstream-relay side (`build_no_proxy_intercept`) already honors the container `NO_PROXY`; +/// this keeps the injected-into-jobs side symmetric. Note that job runtimes match `NO_PROXY` +/// by hostname/suffix, not by resolving against CIDR ranges, so CIDR-only entries (e.g. +/// `10.0.0.0/8`) carry over but won't match a hostname target — operators still need an +/// explicit host/suffix entry for those. #[cfg(all(feature = "private", feature = "enterprise"))] async fn build_tracing_proxy_no_proxy() -> String { let configured = OTEL_TRACING_PROXY_SETTINGS @@ -1084,22 +1093,23 @@ async fn build_tracing_proxy_no_proxy() -> String { .await .no_proxy_hosts .clone(); - normalize_no_proxy_hosts(configured.as_deref()) + normalize_no_proxy_hosts([configured.as_deref(), NO_PROXY.as_deref()]) } -/// Split a comma-separated NO_PROXY value, trim whitespace, drop empty entries, and -/// deduplicate while preserving order. `None` returns an empty string. +/// Split comma-separated NO_PROXY sources, trim whitespace, drop empty entries, and +/// deduplicate while preserving first-occurrence order. Sources are concatenated in the +/// order given (earlier sources win on ordering). Returns an empty string when all sources +/// are `None`/empty. #[cfg(all(feature = "private", feature = "enterprise"))] -fn normalize_no_proxy_hosts(configured: Option<&str>) -> String { - let Some(configured) = configured else { - return String::new(); - }; +fn normalize_no_proxy_hosts<'a>(sources: impl IntoIterator>) -> String { let mut seen = std::collections::HashSet::new(); let mut out: Vec<&str> = Vec::new(); - for entry in configured.split(',') { - let trimmed = entry.trim(); - if !trimmed.is_empty() && seen.insert(trimmed) { - out.push(trimmed); + for source in sources.into_iter().flatten() { + for entry in source.split(',') { + let trimmed = entry.trim(); + if !trimmed.is_empty() && seen.insert(trimmed) { + out.push(trimmed); + } } } out.join(",") @@ -1111,26 +1121,58 @@ mod no_proxy_tests { #[test] fn unset_returns_empty() { - assert_eq!(normalize_no_proxy_hosts(None), ""); + assert_eq!(normalize_no_proxy_hosts([None]), ""); + assert_eq!(normalize_no_proxy_hosts([None, None]), ""); } #[test] fn empty_and_whitespace_only_returns_empty() { - assert_eq!(normalize_no_proxy_hosts(Some("")), ""); - assert_eq!(normalize_no_proxy_hosts(Some(" , ,\t")), ""); + assert_eq!(normalize_no_proxy_hosts([Some("")]), ""); + assert_eq!(normalize_no_proxy_hosts([Some(" , ,\t")]), ""); } #[test] fn trims_and_skips_empties() { assert_eq!( - normalize_no_proxy_hosts(Some(" *.eks.amazonaws.com ,, *.internal ")), + normalize_no_proxy_hosts([Some(" *.eks.amazonaws.com ,, *.internal ")]), "*.eks.amazonaws.com,*.internal" ); } #[test] fn dedupes_preserving_first_occurrence_order() { - assert_eq!(normalize_no_proxy_hosts(Some("a,b,a,c,b,d")), "a,b,c,d"); + assert_eq!(normalize_no_proxy_hosts([Some("a,b,a,c,b,d")]), "a,b,c,d"); + } + + #[test] + fn merges_configured_with_container_no_proxy() { + // Configured tracing hosts come first, then the container NO_PROXY is appended. + assert_eq!( + normalize_no_proxy_hosts([ + Some("gitlab.internal"), + Some("localhost,127.0.0.1,10.0.0.0/8,.cluster.local") + ]), + "gitlab.internal,localhost,127.0.0.1,10.0.0.0/8,.cluster.local" + ); + } + + #[test] + fn merges_dedupe_across_sources() { + // Entries present in both sources are not duplicated. + assert_eq!( + normalize_no_proxy_hosts([Some("localhost,gitlab.internal"), Some("localhost,.svc")]), + "localhost,gitlab.internal,.svc" + ); + } + + #[test] + fn container_no_proxy_carries_over_when_unconfigured() { + // Tracing setting unset but container NO_PROXY present: exclusions still carry over, + // so enabling tracing does not silently drop them. + assert_eq!( + normalize_no_proxy_hosts([None, Some(".cluster.local,gitlab.internal")]), + ".cluster.local,gitlab.internal" + ); } } diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index bd305f80e3..19058c6cb8 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.720.0"; +export const VERSION = "v1.721.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index ed3b7828f1..10331ed668 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -1836,6 +1836,247 @@ export interface Skips { includeKey?: boolean | undefined; } +// Detect paths (and their parent directories) within a single set that differ +// only by letter case — e.g. a remote workspace that genuinely holds both +// f/Caps/a and f/caps/b. On a case-insensitive filesystem (Windows, default +// macOS) these cannot be represented as two distinct files/directories at all, +// so we can only warn. Returns one group per collision, each listing the +// distinct casings sorted for stable output. +// +// Only the shallowest clash is reported: when two case-variant folders also +// contain same-named files (f/Caps/main.ts + f/caps/main.ts), the folder clash +// is the root cause, so the nested per-file group is suppressed rather than +// inflating the count with one entry per duplicated leaf. +export function findCaseInsensitiveCollisions( + paths: Iterable, +): string[][] { + // lowercased prefix -> set of distinct original casings observed + const byLower = new Map>(); + for (const full of paths) { + // Compare on normalized forward-slash prefixes so a Windows-style "\" map + // key and a remote "/" key collapse to the same prefix. + const segs = full.split(/[\\/]/).filter((s) => s.length > 0); + let acc = ""; + for (let i = 0; i < segs.length; i++) { + // Accumulate every directory prefix as well as the full file path, so a + // "f/Caps" vs "f/caps" folder clash is caught even when the leaf files + // (e.g. a.ts vs b.ts) don't themselves collide. + acc = i === 0 ? segs[i] : `${acc}/${segs[i]}`; + const lower = acc.toLowerCase(); + let set = byLower.get(lower); + if (!set) { + set = new Set(); + byLower.set(lower, set); + } + set.add(acc); + } + } + const collidingLowers = new Set(); + for (const [lower, set] of byLower) { + if (set.size > 1) collidingLowers.add(lower); + } + const collisions: string[][] = []; + for (const lower of collidingLowers) { + // Drop this group if any ancestor prefix is itself a collision — the + // shallower folder clash already names the root cause. + const parts = lower.split("/"); + let hasCollidingAncestor = false; + for (let i = 1; i < parts.length; i++) { + if (collidingLowers.has(parts.slice(0, i).join("/"))) { + hasCollidingAncestor = true; + break; + } + } + if (!hasCollidingAncestor) { + collisions.push([...byLower.get(lower)!].sort()); + } + } + return collisions; +} + +type CaseTrieNode = { + // lowercased segment -> child, recording the canonical (server) casing and + // whether the server holds more than one casing of that segment (ambiguous). + children: Map< + string, + { canonical: string; ambiguous: boolean; node: CaseTrieNode } + >; +}; + +// Rewrite `localMap` keys to the canonical casing recorded on the server +// (`remoteMap`) when they differ only by letter case. This is the core +// WIN-2020 fix: on a case-insensitive filesystem a folder such as `f/Caps` +// can have its on-disk casing silently drift (e.g. to `f/caps`) — Windows +// stores whatever case the directory was first created with and reports that +// from readdir, regardless of the server's path. Without this, the diff sees +// the drifted local path as an entirely different item and emits a destructive +// "delete f/Caps + add f/caps" pair, so a single capitalized folder appears to +// vanish and a lowercase clone shows up out of nowhere. Adopting the server +// casing collapses that phantom and leaves the canonical path on the server +// untouched. +// +// Canonicalization is segment-by-segment against a trie of remote paths, so it +// also applies the server's folder casing to brand-new local files that have no +// exact remote match (e.g. adding f/caps/New.ts under a drifted f/Caps folder +// becomes f/Caps/New.ts) — otherwise the push would recreate the case-only +// collision the fix is meant to prevent. A segment is only adopted when the +// server casing is unambiguous; at the first ambiguous or unknown segment the +// remainder of the path keeps its local casing. +// +// Returns the rewritten map, the per-key rewrites, and any genuinely ambiguous +// server-side groups (two distinct remote paths differing only by case) — those +// can't be canonicalized to a single target and are left for the caller to warn +// about. +export function canonicalizeCaseInsensitiveKeys( + localMap: Record, + remoteMap: Record, +): { + map: Record; + ambiguous: string[][]; + rewritten: { from: string; to: string }[]; +} { + const root: CaseTrieNode = { children: new Map() }; + for (const k of Object.keys(remoteMap)) { + let node = root; + for (const seg of k.split(/[\\/]/)) { + if (seg.length === 0) continue; + const lk = seg.toLowerCase(); + let entry = node.children.get(lk); + if (!entry) { + entry = { canonical: seg, ambiguous: false, node: { children: new Map() } }; + node.children.set(lk, entry); + } else if (entry.canonical !== seg) { + entry.ambiguous = true; + } + node = entry.node; + } + } + + const out: Record = {}; + const rewritten: { from: string; to: string }[] = []; + for (const [k, v] of Object.entries(localMap)) { + // Preserve the key's own separator style so the rewritten key still matches + // the rest of the map (and round-trips through push) on every platform. + const sep = k.includes("\\") ? "\\" : "/"; + const segs = k.split(/[\\/]/); + const canonSegs: string[] = []; + let node: CaseTrieNode | undefined = root; + let changed = false; + for (const seg of segs) { + if (seg.length === 0) { + canonSegs.push(seg); + continue; + } + const entry = node?.children.get(seg.toLowerCase()); + if (entry && !entry.ambiguous) { + if (entry.canonical !== seg) changed = true; + canonSegs.push(entry.canonical); + node = entry.node; + } else { + // No unambiguous server guidance for this segment: keep the local + // casing here and below (deeper server structure is unknown). + canonSegs.push(seg); + node = undefined; + } + } + const canonKey = canonSegs.join(sep); + if (changed && canonKey !== k) { + out[canonKey] = v; + rewritten.push({ from: k, to: canonKey }); + } else { + out[k] = v; + } + } + + return { + map: out, + ambiguous: findCaseInsensitiveCollisions(Object.keys(remoteMap)), + rewritten, + }; +} + +// Summarize case-only key rewrites by their differing path prefix (typically a +// folder such as f/caps -> f/Caps) so a folder whose casing drifted is reported +// once instead of once per contained file. +export function summarizeCaseRewrites( + rewritten: { from: string; to: string }[], +): string[] { + const seen = new Set(); + const out: string[] = []; + for (const { from, to } of rewritten) { + const fromSegs = from.split(/[\\/]/); + const toSegs = to.split(/[\\/]/); + // Find the shortest prefix at which the two casings first differ; that is + // the folder (or file) whose casing actually changed. + let i = 0; + while ( + i < fromSegs.length && + i < toSegs.length && + fromSegs[i] === toSegs[i] + ) { + i++; + } + const fromPrefix = fromSegs.slice(0, i + 1).join("/"); + const toPrefix = toSegs.slice(0, i + 1).join("/"); + const key = `${fromPrefix} -> ${toPrefix}`; + if (!seen.has(key)) { + seen.add(key); + out.push(key); + } + } + return out; +} + +// Emit a single grouped warning for case-only collisions that cannot be +// represented on a case-insensitive filesystem (two distinct server paths +// differing only by case). Unlike the drift handled by +// canonicalizeCaseInsensitiveKeys, these require the user to rename one side. +function warnUnrepresentableCaseCollisions(collisions: string[][]): void { + if (collisions.length === 0) return; + const groups = collisions.map((g) => ` - ${g.join(" <-> ")}`).join("\n"); + log.warn( + `Found ${collisions.length} path(s) that differ only by letter case:\n` + + `${groups}\n` + + `On case-insensitive filesystems (Windows, default macOS) these collapse ` + + `into a single file/directory and cannot both be synced. Rename one side ` + + `to a distinct path to make the tree sync reliably across platforms.`, + ); +} + +// Probe (and cache) whether `dir` lives on a case-insensitive filesystem. +// Auto-detected by round-tripping a probe file under two casings, with an +// explicit WMILL_CASE_INSENSITIVE_FS=true/false override so Windows behaviour +// can be forced (or emulated for cross-platform repos / tests) on any host. +let _caseInsensitiveFsCache: boolean | undefined; +export async function isCaseInsensitiveFilesystem( + dir: string, +): Promise { + const override = (process.env.WMILL_CASE_INSENSITIVE_FS ?? "") + .trim() + .toLowerCase(); + if (override === "true" || override === "1") return true; + if (override === "false" || override === "0") return false; + if (_caseInsensitiveFsCache !== undefined) return _caseInsensitiveFsCache; + let result = false; + try { + const upper = path.join(dir, `.wmill-CASEPROBE-${process.pid}.tmp`); + const lower = path.join(dir, `.wmill-caseprobe-${process.pid}.tmp`); + await writeFile(upper, "", "utf-8"); + try { + await stat(lower); + result = true; // lowercase name resolves to the file we wrote uppercase + } catch { + result = false; + } + await rm(upper).catch(() => {}); + await rm(lower).catch(() => {}); + } catch { + result = false; + } + _caseInsensitiveFsCache = result; + return result; +} + async function compareDynFSElement( els1: DynFSElement, els2: DynFSElement | undefined, @@ -1848,14 +2089,55 @@ async function compareDynFSElement( specificItems?: SpecificItemsConfig, branchOverride?: string, isEls1Remote?: boolean, + caseInsensitiveFs?: boolean, ): Promise<{ changes: Change[]; localMap: Record }> { - const [m1, m2] = els2 + let [m1, m2] = els2 ? await Promise.all([ elementsToMap(els1, ignore, json, skips, specificItems, branchOverride, isEls1Remote), elementsToMap(els2, ignore, json, skips, specificItems, branchOverride, !isEls1Remote), ]) : [await elementsToMap(els1, ignore, json, skips, specificItems, branchOverride, isEls1Remote), {}]; + // Reconcile letter-case differences between the local tree and the + // authoritative server casing. Only meaningful for an actual two-sided diff + // (els2 defined) where we know which side is the remote. + if (els2 && isEls1Remote !== undefined) { + const remoteMap = isEls1Remote ? m1 : m2; + + // Always warn about server paths that differ only by case (e.g. f/Caps and + // f/caps as two distinct items). These cannot coexist on a case-insensitive + // filesystem, so flag them on every platform — a Linux author needs to know + // their tree won't round-trip for a Windows/macOS teammate. + warnUnrepresentableCaseCollisions( + findCaseInsensitiveCollisions(Object.keys(remoteMap)), + ); + + // On a case-insensitive filesystem, the local on-disk casing of a folder + // can drift from the server's (Windows reports the case the directory was + // first created with). Rewrite those drifted local keys to the server + // casing so the diff treats them as the same item instead of a destructive + // delete+add pair. This is the WIN-2020 fix. + if (caseInsensitiveFs) { + const { map, rewritten } = canonicalizeCaseInsensitiveKeys( + isEls1Remote ? m2 : m1, + remoteMap, + ); + if (isEls1Remote) { + m2 = map; + } else { + m1 = map; + } + const summary = summarizeCaseRewrites(rewritten); + if (summary.length > 0) { + log.info( + `Reconciled ${summary.length} local path(s) to the server's casing ` + + `(case-insensitive filesystem):\n` + + summary.map((s) => ` ${s}`).join("\n"), + ); + } + } + } + const changes: Change[] = []; function parseYaml(k: string, v: string) { @@ -2613,6 +2895,7 @@ export async function pull( specificItems, wsNameForFiles, true, // els1 (remote) is the remote source + await isCaseInsensitiveFilesystem(process.cwd()), ); log.info( @@ -3182,6 +3465,14 @@ function removeSuffix(str: string, suffix: string) { return str.slice(0, str.length - suffix.length); } +// Shown after a `wmill sync push --dry-run` preview that has changes. `sync push` +// deploys to the remote workspace and is destructive (it overwrites and prunes +// remote items that differ from or are absent locally), so the preview reminds +// the caller — especially an AI agent that ran the dry-run to inspect changes — +// to get explicit user confirmation before applying it for real. +const SYNC_PUSH_DESTRUCTIVE_WARNING = + "`wmill sync push` is destructive: applying it deploys these changes to the remote workspace and overwrites or deletes remote items that differ from or are absent locally — this is not automatically reversible. If you are an AI agent, do NOT run `wmill sync push` (without --dry-run) until the user has explicitly confirmed this deploy, unless your custom instructions explicitly allow bypassing that confirmation."; + export async function push( opts: GlobalOptions & SyncOptions & { repository?: string; branch?: string; acceptOverridingPermissionedAsWithSelf?: boolean }, ) { @@ -3361,6 +3652,7 @@ export async function push( specificItems, wsNameForFiles, false, // els1 (local) is not the remote source + await isCaseInsensitiveFilesystem(process.cwd()), ); // Detect resources/variables that the local config flags as ws_specific @@ -3717,6 +4009,9 @@ export async function push( : {}), })), total: changes.length, + ...(changes.length > 0 + ? { warning: SYNC_PUSH_DESTRUCTIVE_WARNING } + : {}), }; console.log(JSON.stringify(result, null, 2)); return; @@ -3760,6 +4055,7 @@ export async function push( if (opts.dryRun) { log.info(colors.gray(`Dry run complete.`)); + log.warn(colors.yellow(`\n⚠ ${SYNC_PUSH_DESTRUCTIVE_WARNING}`)); return; } diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 06f6982e2f..43d063c326 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.720.0"; +export const VERSION = "1.721.0"; diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 61d92755fb..750b0df0ec 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -5266,10 +5266,10 @@ Text/HTML/inline parts are placed inline in \`body\` as strings. ## CLI Commands -After writing, tell the user they can run these commands (do NOT run them yourself): +\`wmill sync push\` deploys local changes to the workspace and can be destructive to remote state — only suggest/run it when the user explicitly asks to deploy/publish/push, not when they say "run", "try", or "test". \`sync pull\` is safe to run yourself — it never mutates remote state, though it does overwrite local files to match the remote (use \`sync pull --dry-run\` to only preview). \`\`\`bash -# Push trigger configuration +# Push trigger configuration — only when the user explicitly asks to deploy wmill sync push # Pull triggers from Windmill @@ -5317,10 +5317,10 @@ Windmill uses 6-field cron expressions (includes seconds): ## CLI Commands -After writing, tell the user they can run these commands (do NOT run them yourself): +\`wmill sync push\` deploys local changes to the workspace and can be destructive to remote state — only suggest/run it when the user explicitly asks to deploy/publish/push, not when they say "run", "try", or "test". The commands below never mutate remote state, so they're safe to run yourself — note that \`sync pull\` does overwrite local files to match the remote (use \`sync pull --dry-run\` to only preview), while \`schedule\` just lists. \`\`\`bash -# Push schedules to Windmill +# Push schedules to Windmill — only when the user explicitly asks to deploy wmill sync push # Pull schedules from Windmill @@ -5574,7 +5574,8 @@ wmill resource-type list --schema # Get specific resource type schema wmill resource-type get postgresql -# Push resources (tell the user to run this, do NOT run it yourself) +# Push resources to Windmill — deploys to the workspace and can be destructive to +# remote state, so only run it when the user explicitly asks to deploy/publish/push wmill sync push \`\`\` `, diff --git a/cli/test/case_insensitive_collisions_unit.test.ts b/cli/test/case_insensitive_collisions_unit.test.ts new file mode 100644 index 0000000000..511a5975ca --- /dev/null +++ b/cli/test/case_insensitive_collisions_unit.test.ts @@ -0,0 +1,232 @@ +import { expect, test } from "bun:test"; + +import { + findCaseInsensitiveCollisions, + canonicalizeCaseInsensitiveKeys, + summarizeCaseRewrites, +} from "../src/commands/sync/sync.ts"; + +// ============================================================================= +// Case-insensitive sync handling (WIN-2020) +// +// Windmill paths are case-sensitive, but Windows and the default macOS setup +// use case-insensitive filesystems. The real-world failure is NOT a user +// deliberately authoring both f/Caps and f/caps — it is a single capitalized +// folder whose on-disk casing drifts (Windows reports the case the directory +// was first created with), so the diff sees a brand-new lowercase path and a +// destructive delete of the real one. These tests pin: +// 1. findCaseInsensitiveCollisions — warn about genuinely unrepresentable +// server-side collisions (two distinct remote paths differing only by +// case). +// 2. canonicalizeCaseInsensitiveKeys — the fix: rewrite drifted local keys +// to the server's casing so no phantom delete+add is produced. +// ============================================================================= + +function normalize(groups: string[][]): string[][] { + return groups + .map((g) => [...g]) + .sort((a, b) => a.join().localeCompare(b.join())); +} + +// --------------------------------------------------------------------------- +// findCaseInsensitiveCollisions +// --------------------------------------------------------------------------- + +test("collision detection: sibling folders differing only by case", () => { + const collisions = findCaseInsensitiveCollisions([ + "f/Caps/a.script.ts", + "f/caps/b.script.ts", + ]); + expect(normalize(collisions)).toEqual([["f/Caps", "f/caps"]]); +}); + +test("collision detection: leaf files differing only by case", () => { + const collisions = findCaseInsensitiveCollisions([ + "f/team/Report.script.ts", + "f/team/report.script.ts", + ]); + expect(normalize(collisions)).toEqual([ + ["f/team/Report.script.ts", "f/team/report.script.ts"], + ]); +}); + +test("collision detection: none for case-consistent distinct paths", () => { + const collisions = findCaseInsensitiveCollisions([ + "f/caps/foo.script.ts", + "f/caps/bar.script.ts", + "f/other/baz.script.ts", + ]); + expect(collisions).toEqual([]); +}); + +test("collision detection: normalizes mixed forward/back slashes", () => { + const collisions = findCaseInsensitiveCollisions([ + "f\\Caps\\a.script.ts", + "f/caps/b.script.ts", + ]); + expect(normalize(collisions)).toEqual([["f/Caps", "f/caps"]]); +}); + +test("collision detection: reports only the shallowest clash for same-named leaves", () => { + // Two case-variant folders that ALSO hold a same-named file must report the + // single folder clash, not the folder group plus a nested per-file group. + const collisions = findCaseInsensitiveCollisions([ + "f/Caps/main.script.ts", + "f/caps/main.script.ts", + ]); + expect(normalize(collisions)).toEqual([["f/Caps", "f/caps"]]); +}); + +test("collision detection: groups three distinct casings together", () => { + const collisions = findCaseInsensitiveCollisions([ + "f/caps/a.script.ts", + "f/CAPS/b.script.ts", + "f/Caps/c.script.ts", + ]); + expect(normalize(collisions)).toEqual([["f/CAPS", "f/Caps", "f/caps"]]); +}); + +// --------------------------------------------------------------------------- +// canonicalizeCaseInsensitiveKeys (the WIN-2020 fix) +// --------------------------------------------------------------------------- + +test("canonicalize: drifted local folder casing adopts the server casing", () => { + // Server (remote) is authoritative: f/Caps. The local tree drifted to + // f/caps on a case-insensitive FS. + const remote = { + "f/Caps/x.script.ts": "content", + "f/Caps/x.script.yaml": "meta", + }; + const local = { + "f/caps/x.script.ts": "content", + "f/caps/x.script.yaml": "meta", + }; + const { map, ambiguous, rewritten } = canonicalizeCaseInsensitiveKeys( + local, + remote, + ); + // Local keys are rewritten to the server casing, so a subsequent exact-key + // diff sees identical paths — no phantom delete+add. + expect(Object.keys(map).sort()).toEqual([ + "f/Caps/x.script.ts", + "f/Caps/x.script.yaml", + ]); + expect(ambiguous).toEqual([]); + expect(rewritten).toEqual([ + { from: "f/caps/x.script.ts", to: "f/Caps/x.script.ts" }, + { from: "f/caps/x.script.yaml", to: "f/Caps/x.script.yaml" }, + ]); +}); + +test("canonicalize: preserves content values while rewriting keys", () => { + const remote = { "f/MyFolder/Script.script.ts": "remote" }; + const local = { "f/myfolder/Script.script.ts": "LOCAL EDIT" }; + const { map } = canonicalizeCaseInsensitiveKeys(local, remote); + expect(map["f/MyFolder/Script.script.ts"]).toEqual("LOCAL EDIT"); + expect(map["f/myfolder/Script.script.ts"]).toBeUndefined(); +}); + +test("canonicalize: leaves keys with no case-insensitive remote match", () => { + const remote = { "f/Caps/x.script.ts": "a" }; + const local = { + "f/Caps/x.script.ts": "a", + "f/brand_new/y.script.ts": "b", // genuinely local-only add + }; + const { map, rewritten } = canonicalizeCaseInsensitiveKeys(local, remote); + expect(rewritten).toEqual([]); + expect(Object.keys(map).sort()).toEqual([ + "f/Caps/x.script.ts", + "f/brand_new/y.script.ts", + ]); +}); + +test("canonicalize: does NOT rewrite when the server casing is ambiguous", () => { + // The server itself holds two paths differing only by case — we must not + // silently pick one. Leave the local key untouched and report the ambiguity. + const remote = { + "f/Caps/x.script.ts": "a", + "f/caps/x.script.ts": "b", + }; + const local = { "f/CAPS/x.script.ts": "local" }; + const { map, ambiguous, rewritten } = canonicalizeCaseInsensitiveKeys( + local, + remote, + ); + expect(rewritten).toEqual([]); + expect(Object.keys(map)).toEqual(["f/CAPS/x.script.ts"]); + // Reported as the shallowest (folder) clash, not the nested per-file group. + expect(normalize(ambiguous)).toEqual([["f/Caps", "f/caps"]]); +}); + +test("canonicalize: new local file under a drifted folder adopts the server folder casing", () => { + // Regression for the P1 review finding: a brand-new local file has no exact + // remote match, but it still lives under a folder whose casing drifted. It + // must inherit the server's folder casing (f/Caps), otherwise push would + // create f/caps/New beside f/Caps/* and reintroduce the case-only collision. + const remote = { "f/Caps/Existing.script.ts": "remote" }; + const local = { + "f/caps/Existing.script.ts": "remote", // drifted, existing + "f/caps/New.script.ts": "brand new", // drifted folder, local-only file + }; + const { map, rewritten } = canonicalizeCaseInsensitiveKeys(local, remote); + expect(Object.keys(map).sort()).toEqual([ + "f/Caps/Existing.script.ts", + "f/Caps/New.script.ts", + ]); + expect(map["f/Caps/New.script.ts"]).toEqual("brand new"); + expect(rewritten).toContainEqual({ + from: "f/caps/New.script.ts", + to: "f/Caps/New.script.ts", + }); +}); + +test("canonicalize: stops at the first segment with no server guidance", () => { + // Only the folder prefix that exists on the server is canonicalized; deeper + // local-only directories keep their own casing. + const remote = { "f/Caps/x.script.ts": "a" }; + const local = { "f/caps/SubDir/y.script.ts": "b" }; + const { map } = canonicalizeCaseInsensitiveKeys(local, remote); + expect(Object.keys(map)).toEqual(["f/Caps/SubDir/y.script.ts"]); +}); + +test("canonicalize: identical casing is a no-op", () => { + const remote = { "f/Caps/x.script.ts": "a" }; + const local = { "f/Caps/x.script.ts": "a" }; + const { map, rewritten, ambiguous } = canonicalizeCaseInsensitiveKeys( + local, + remote, + ); + expect(rewritten).toEqual([]); + expect(ambiguous).toEqual([]); + expect(map).toEqual({ "f/Caps/x.script.ts": "a" }); +}); + +// --------------------------------------------------------------------------- +// summarizeCaseRewrites +// --------------------------------------------------------------------------- + +test("summarize: collapses per-file rewrites into one folder entry", () => { + const summary = summarizeCaseRewrites([ + { from: "f/caps/x.script.ts", to: "f/Caps/x.script.ts" }, + { from: "f/caps/x.script.yaml", to: "f/Caps/x.script.yaml" }, + { from: "f/caps/y.script.ts", to: "f/Caps/y.script.ts" }, + ]); + expect(summary).toEqual(["f/caps -> f/Caps"]); +}); + +test("summarize: reports a leaf-file casing change at file granularity", () => { + const summary = summarizeCaseRewrites([ + { from: "f/team/report.script.ts", to: "f/team/Report.script.ts" }, + ]); + expect(summary).toEqual([ + "f/team/report.script.ts -> f/team/Report.script.ts", + ]); +}); + +test("summarize: reports independent folder drifts separately", () => { + const summary = summarizeCaseRewrites([ + { from: "f/caps/x.script.ts", to: "f/Caps/x.script.ts" }, + { from: "u/alice/y.script.ts", to: "u/Alice/y.script.ts" }, + ]); + expect(summary.sort()).toEqual(["f/caps -> f/Caps", "u/alice -> u/Alice"]); +}); diff --git a/cli/test/mixed_case_paths.test.ts b/cli/test/mixed_case_paths.test.ts index 872c6f94ff..47b1e4e272 100644 --- a/cli/test/mixed_case_paths.test.ts +++ b/cli/test/mixed_case_paths.test.ts @@ -14,7 +14,7 @@ import { expect, test } from "bun:test"; import * as path from "node:path"; -import { writeFile, readFile, stat } from "node:fs/promises"; +import { writeFile, readFile, stat, rename } from "node:fs/promises"; import { withTestBackend } from "./test_backend.ts"; import { addWorkspace } from "../workspace.ts"; import { parseJsonFromCLIOutput } from "./test_config_helpers.ts"; @@ -686,6 +686,173 @@ excludes: [] }); }); +// The core WIN-2020 reproduction. The real failure is NOT a user authoring +// both f/Caps and f/caps — it is a SINGLE capitalized folder whose on-disk +// casing drifts on a case-insensitive filesystem (Windows stores/reports the +// case the directory was first created with). The diff then sees a brand-new +// lowercase path plus a destructive delete of the real one. +// +// This test runs on BOTH the Linux and Windows CI jobs: +// - On the Windows runner (real case-insensitive NTFS) the CLI auto-detects +// case-insensitivity via its filesystem probe — no override, real behavior. +// - On Linux (case-sensitive) we reproduce the drift with an explicit rename +// and force the same code path with WMILL_CASE_INSENSITIVE_FS=true, and we +// additionally assert that WITHOUT the fix the destructive phantom appears +// (which can only be observed on a case-sensitive FS). +test("Mixed Case Paths: case-only folder drift reconciles to server casing (WIN-2020)", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await createFolder(backend, "Caps"); + await createScript( + backend, + "f/Caps/MyScript", + 'export async function main() { return "drift repro"; }', + "Drift Script" + ); + + await writeFile( + path.join(tempDir, "wmill.yaml"), + `defaultTs: bun +includes: + - "**" +excludes: [] +`, + "utf-8" + ); + + const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); + expect(pullResult.code).toEqual(0); + + const upperDir = path.join(tempDir, "f", "Caps"); + const lowerDir = path.join(tempDir, "f", "caps"); + const pulledExists = await stat(path.join(upperDir, "MyScript.ts")) + .then(() => true) + .catch(() => false); + expect(pulledExists).toBeTruthy(); + + // Simulate the on-disk casing drift (a no-op-in-spirit case-only rename). + // On Windows this is a real case-only rename of the same directory; on + // Linux it produces a genuinely lowercase sibling. + await rename(upperDir, lowerDir); + + const onWindows = process.platform === "win32"; + + // Without case-insensitive handling, the drift is a destructive + // delete(f/Caps) + add(f/caps) phantom. Only observable on a + // case-sensitive FS (Linux), where the override defaults off. + if (!onWindows) { + const buggy = await backend.runCLICommand( + ["sync", "push", "--yes", "--dry-run", "--json-output"], + tempDir + ); + expect(buggy.code).toEqual(0); + const buggyChanges = parseJsonFromCLIOutput(buggy.stdout).changes || []; + const hasDelete = buggyChanges.some( + (c: any) => c.type === "deleted" && c.path.replace(/\\/g, "/").startsWith("f/Caps/") + ); + const hasAdd = buggyChanges.some( + (c: any) => c.type === "added" && c.path.replace(/\\/g, "/").startsWith("f/caps/") + ); + expect(hasDelete).toBeTruthy(); + expect(hasAdd).toBeTruthy(); + } + + // With case-insensitive handling in effect (auto on Windows via the FS + // probe, forced via env on Linux) the drifted local casing is reconciled + // to the server's casing, so the push is a clean no-op. + if (!onWindows) process.env.WMILL_CASE_INSENSITIVE_FS = "true"; + try { + const fixed = await backend.runCLICommand( + ["sync", "push", "--yes", "--dry-run", "--json-output"], + tempDir + ); + expect(fixed.code).toEqual(0); + const fixedChanges = parseJsonFromCLIOutput(fixed.stdout).changes || []; + if (fixedChanges.length !== 0) { + console.error( + "Expected no changes after reconciliation, got:", + JSON.stringify(fixedChanges, null, 2) + ); + } + expect(fixedChanges.length).toEqual(0); + + // P1 regression: a brand-new item added under the drifted (lowercase) + // folder must be pushed under the server's folder casing (f/Caps), not + // recreate the case-only collision as f/caps. A self-contained variable + // YAML is used so the assertion targets path canonicalization without + // dragging in script lock/metadata generation. + await writeFile( + path.join(lowerDir, "NewVar.variable.yaml"), + `value: hello\nis_secret: false\ndescription: new under drifted folder\nis_oauth: false\n`, + "utf-8" + ); + const added = await backend.runCLICommand( + ["sync", "push", "--yes", "--dry-run", "--json-output"], + tempDir + ); + expect(added.code).toEqual(0); + const addedChanges = parseJsonFromCLIOutput(added.stdout).changes || []; + const addedPaths = addedChanges.map((c: any) => + c.path.replace(/\\/g, "/") + ); + expect( + addedPaths.some((p: string) => p === "f/Caps/NewVar.variable.yaml") + ).toBeTruthy(); + expect( + addedPaths.some((p: string) => p.startsWith("f/caps/")) + ).toBeFalsy(); + } finally { + if (!onWindows) delete process.env.WMILL_CASE_INSENSITIVE_FS; + } + }); +}); + +// A genuine, unrepresentable server-side collision: two DISTINCT server folders +// that differ only by case (f/Caps and f/caps). These cannot both exist on a +// case-insensitive filesystem, so the CLI must warn. Skipped on Windows, where +// the pull physically cannot lay both folders down; the warning string itself +// is exercised platform-independently by the unit tests. +const collisionTest = process.platform === "win32" ? test.skip : test; +collisionTest("Mixed Case Paths: distinct server folders differing only by case warn", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + await createFolder(backend, "Caps"); + await createFolder(backend, "caps"); + await createScript( + backend, + "f/Caps/upper", + 'export async function main() { return "upper"; }', + "Upper" + ); + await createScript( + backend, + "f/caps/lower", + 'export async function main() { return "lower"; }', + "Lower" + ); + + await writeFile( + path.join(tempDir, "wmill.yaml"), + `defaultTs: bun +includes: + - "**" +excludes: [] +`, + "utf-8" + ); + + const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); + expect(pullResult.code).toEqual(0); + + const combinedOutput = pullResult.stdout + pullResult.stderr; + expect(combinedOutput.includes("differ only by letter case")).toBeTruthy(); + expect(combinedOutput.includes("f/Caps")).toBeTruthy(); + expect(combinedOutput.includes("f/caps")).toBeTruthy(); + }); +}); + test("Mixed Case Paths: CamelCase folder names with numbers", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9b7a0c6d02..084d5be57f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.720.0", + "version": "1.721.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.720.0", + "version": "1.721.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 07510ec7cc..bb4c3b51f1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.720.0", + "version": "1.721.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index c10efce9f6..c3efe3f35d 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -67,7 +67,40 @@ vi.mock('$lib/gen', async () => { success: true, result: { ok: true }, logs: 'test logs' - })) + })), + getJobLogs: vi.fn(async () => 'job log line 1\njob log line 2'), + listJobs: vi.fn(async () => [ + { + type: 'CompletedJob', + id: 'completed-1', + job_kind: 'script', + script_path: 'f/team/runner', + created_by: 'alice', + created_at: '2026-06-09T10:00:00Z', + started_at: '2026-06-09T10:00:01Z', + duration_ms: 1200, + success: true, + canceled: false, + is_flow_step: false, + tag: 'default', + // fields that must be stripped from the summary + logs: 'verbose logs', + args: { secret: 'do-not-leak' }, + result: { value: 42 } + }, + { + type: 'QueuedJob', + id: 'queued-1', + job_kind: 'flow', + script_path: 'f/team/pipeline', + created_by: 'bob', + created_at: '2026-06-09T10:05:00Z', + running: true, + canceled: false, + is_flow_step: false, + tag: 'default' + } + ]) }), FlowService: wrapService(actual.FlowService, { existsFlowByPath: vi.fn(async () => false), @@ -238,6 +271,78 @@ describe('global AI tools', () => { expect(names).toContain('test_run_script') expect(names).toContain('test_run_flow') expect(names).toContain('test_run_step') + expect(names).toContain('get_job_logs') + expect(names).toContain('list_runs') + }) + + it('lists recent runs with compact summaries and forwarded filters', async () => { + const result = await callGlobalTool('list_runs', { + path: 'f/team/runner', + created_by: 'alice', + success: true, + limit: 10 + }) + + expect(JobService.listJobs).toHaveBeenCalledWith({ + workspace: WORKSPACE, + scriptPathExact: 'f/team/runner', + createdBy: 'alice', + label: undefined, + success: true, + running: undefined, + perPage: 10 + }) + + const runs = JSON.parse(result) + expect(runs).toHaveLength(2) + expect(runs[0]).toMatchObject({ + id: 'completed-1', + status: 'success', + path: 'f/team/runner', + duration_ms: 1200 + }) + expect(runs[1]).toMatchObject({ id: 'queued-1', status: 'running' }) + // Heavy / sensitive fields must not leak into the summary. + expect(result).not.toContain('verbose logs') + expect(result).not.toContain('do-not-leak') + // The result must be surfaced to the tool display, otherwise the details + // panel shows "No result yet" even though the call succeeded. + expect(toolCallbacks.setToolStatus).toHaveBeenCalledWith( + 'test-list_runs', + expect.objectContaining({ result }) + ) + }) + + it('defaults list_runs to 30 results when no limit is given', async () => { + await callGlobalTool('list_runs', {}) + expect(JobService.listJobs).toHaveBeenCalledWith( + expect.objectContaining({ workspace: WORKSPACE, perPage: 30 }) + ) + }) + + it('fetches job logs by id and always suppresses the backend ansi hint line', async () => { + const result = await callGlobalTool('get_job_logs', { id: 'job-123' }) + + expect(JobService.getJobLogs).toHaveBeenCalledWith({ + workspace: WORKSPACE, + id: 'job-123', + removeAnsiWarnings: true + }) + expect(result).toBe('job log line 1\njob log line 2') + // The logs must be surfaced as the tool result so the details panel shows + // them rather than "No result yet". + expect(toolCallbacks.setToolStatus).toHaveBeenCalledWith( + 'test-get_job_logs', + expect.objectContaining({ result: 'job log line 1\njob log line 2' }) + ) + }) + + it('reports when a job has no logs', async () => { + vi.mocked(JobService.getJobLogs).mockResolvedValueOnce(' ') + + const result = await callGlobalTool('get_job_logs', { id: 'job-empty' }) + + expect(result).toBe('No logs available for this job.') }) it('searches hub scripts without fetching script contents', async () => { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 960cd7557c..3960368a6e 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -23,6 +23,7 @@ import type { CreateVariable, Flow, FlowValue, + Job, ListableApp, ListableResource, ListableVariable, @@ -140,10 +141,10 @@ const ACTIVE_GLOBAL_EDITOR_DRAFTS: readonly { itemKind: LiveEditorDraftKind type: ActiveGlobalEditorType }[] = [ - { itemKind: 'script', type: 'script' }, - { itemKind: 'flow', type: 'flow' }, - { itemKind: 'raw_app', type: 'app' } -] + { itemKind: 'script', type: 'script' }, + { itemKind: 'flow', type: 'flow' }, + { itemKind: 'raw_app', type: 'app' } + ] export type GlobalActiveEditorContext = { type: ActiveGlobalEditorType @@ -348,6 +349,34 @@ const searchResourceTypesSchema = z.object({ .describe('Max number of resource types to return. Defaults to 5.') }) +const getJobLogsSchema = z.object({ + id: z.string().describe('The UUID of the job to fetch logs for.') +}) + +const listRunsSchema = z.object({ + path: z + .string() + .optional() + .describe('Filter to runs of this exact script or flow path.'), + created_by: z + .string() + .optional() + .describe('Filter by the username that started the run.'), + label: z.string().optional().describe('Filter by job label.'), + success: z + .boolean() + .optional() + .describe('Only completed runs with this outcome (true = succeeded, false = failed).'), + running: z.boolean().optional().describe('Only currently running runs.'), + limit: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe('Max number of runs to return, most recent first. Defaults to 30.') +}) + const deleteWorkspaceItemSchema = z.object({ type: itemTypeSchema, path: z.string().describe('Workspace path of the item to delete.'), @@ -602,13 +631,13 @@ Rules: - Use search_resource_types before write_resource. - Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language. - After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer local drafts, so testing does not require deployment. +- Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_job_logs with a returned id to inspect a specific run's logs — without starting a new test run. - When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. -- Keep context targeted.${ - previewTools +- Keep context targeted.${previewTools ? ` - After writing or substantially editing a script / flow / app draft, show it via open_preview(kind, path) so the user sees the editor and live preview right next to the chat. First check whether it is already shown: if unsure, call get_preview_status. Only call open_preview (or offer to) when no preview is open or it is showing a different item — don't re-open a preview already showing the item you just edited.` : '' -} + } Flows: - read_workspace_item returns compact flow JSON. Inline script bodies appear as "inline_script.". @@ -745,6 +774,36 @@ function variableToItem(variable: ListableVariable): WorkspaceItem { } } +// Compact metadata for one run. The raw Job carries args/result/logs/raw_code +// which can be huge — list_runs returns only what's needed to identify a run. +function summarizeRun(job: Job): Record { + const base = { + id: job.id, + job_kind: job.job_kind, + path: job.script_path, + created_by: job.created_by, + created_at: job.created_at, + started_at: job.started_at, + schedule_path: job.schedule_path, + is_flow_step: job.is_flow_step, + tag: job.tag, + worker: job.worker + } + if ('success' in job) { + // CompletedJob + return { + ...base, + status: job.canceled ? 'canceled' : job.success ? 'success' : 'failure', + duration_ms: job.duration_ms + } + } + // QueuedJob (running or still waiting in the queue) + return { + ...base, + status: job.running ? 'running' : 'queued' + } +} + // ============= App helpers ============= type BackendRunnableInput = z.infer @@ -807,11 +866,11 @@ function buildPersistedRunnable( ): PersistedRunnable { const fields = input.staticInputs ? Object.fromEntries( - Object.entries(input.staticInputs).map(([k, v]) => [ - k, - { type: 'static', value: v, fieldType: 'object' } - ]) - ) + Object.entries(input.staticInputs).map(([k, v]) => [ + k, + { type: 'static', value: v, fieldType: 'object' } + ]) + ) : (existing?.fields ?? {}) if (input.type === 'inline') { @@ -1667,6 +1726,65 @@ export const globalTools: Tool<{}>[] = [ showDetails: true, autoCollapseDetails: false }, + { + def: createToolDef( + listRunsSchema, + 'list_runs', + "List recent runs (jobs), most recent first. Optionally filter by path, creator, label, or status. Returns compact metadata only — use get_job_logs with a returned id to read a run's logs." + ), + showDetails: true, + fn: async ({ args, workspace, toolId, toolCallbacks }) => { + const parsed = listRunsSchema.parse(args) + toolCallbacks.setToolStatus(toolId, { content: 'Listing runs...' }) + const jobs = await JobService.listJobs({ + workspace, + scriptPathExact: parsed.path, + createdBy: parsed.created_by, + label: parsed.label, + success: parsed.success, + running: parsed.running, + perPage: parsed.limit ?? 30 + }) + const runs = jobs.map(summarizeRun) + const result = JSON.stringify(runs, null, 2) + toolCallbacks.setToolStatus(toolId, { + content: `Listed ${runs.length} run(s)`, + result + }) + return result + } + }, + { + def: createToolDef( + getJobLogsSchema, + 'get_job_logs', + 'Fetch the logs of a job by its id. Use this to inspect the output of an existing run.' + ), + showDetails: true, + fn: async ({ args, workspace, toolId, toolCallbacks }) => { + const parsed = getJobLogsSchema.parse(args) + toolCallbacks.setToolStatus(toolId, { + content: `Fetching logs for job ${parsed.id}...` + }) + const logs = await JobService.getJobLogs({ + workspace, + id: parsed.id, + // Always suppress the "to remove ansi colors, use: sed ..." hint the + // backend otherwise prepends — it is noise for the model and is not + // actual ANSI stripping (the raw logs are returned either way). + removeAnsiWarnings: true + }) + const hasLogs = typeof logs === 'string' && logs.trim().length > 0 + const result = hasLogs ? logs : 'No logs available for this job.' + toolCallbacks.setToolStatus(toolId, { + content: hasLogs + ? `Fetched logs for job ${parsed.id}` + : `No logs available for job ${parsed.id}`, + result + }) + return result + } + }, { def: createToolDef( deployWorkspaceItemSchema, @@ -2326,9 +2444,9 @@ async function writeScheduleDraft(args: NewSchedule, ctx: WriteDraftCtx): Promis ? existingDraft : backendExists ? ((await ScheduleService.getSchedule({ - workspace, - path: args.path - })) as ScheduleDraftConfig) + workspace, + path: args.path + })) as ScheduleDraftConfig) : undefined const draft = mergeDraftConfig(base, args as DraftConfig, args.path) diff --git a/lsp/Pipfile b/lsp/Pipfile index f4c78aad4a..e554ae18ca 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.720.0" +wmill = ">=1.721.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 4be0a8b48d..9c4744ee59 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.720.0 + version: 1.721.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 4bfbb2dc9a..aa772756e7 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.720.0' + ModuleVersion = '1.721.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 90e0c68630..0386a40e3e 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.720.0" +version = "1.721.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 3d8819c4fe..4a068f2210 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -563,7 +563,8 @@ wmill resource-type list --schema # Get specific resource type schema wmill resource-type get postgresql -# Push resources (tell the user to run this, do NOT run it yourself) +# Push resources to Windmill — deploys to the workspace and can be destructive to +# remote state, so only run it when the user explicitly asks to deploy/publish/push wmill sync push \`\`\` `; diff --git a/system_prompts/auto-generated/skills/resources/SKILL.md b/system_prompts/auto-generated/skills/resources/SKILL.md index 3f78cc1b0b..113826ad63 100644 --- a/system_prompts/auto-generated/skills/resources/SKILL.md +++ b/system_prompts/auto-generated/skills/resources/SKILL.md @@ -242,6 +242,7 @@ wmill resource-type list --schema # Get specific resource type schema wmill resource-type get postgresql -# Push resources (tell the user to run this, do NOT run it yourself) +# Push resources to Windmill — deploys to the workspace and can be destructive to +# remote state, so only run it when the user explicitly asks to deploy/publish/push wmill sync push ``` diff --git a/system_prompts/auto-generated/skills/schedules/SKILL.md b/system_prompts/auto-generated/skills/schedules/SKILL.md index 24dab471e5..526740e04c 100644 --- a/system_prompts/auto-generated/skills/schedules/SKILL.md +++ b/system_prompts/auto-generated/skills/schedules/SKILL.md @@ -39,10 +39,10 @@ Windmill uses 6-field cron expressions (includes seconds): ## CLI Commands -After writing, tell the user they can run these commands (do NOT run them yourself): +`wmill sync push` deploys local changes to the workspace and can be destructive to remote state — only suggest/run it when the user explicitly asks to deploy/publish/push, not when they say "run", "try", or "test". The commands below never mutate remote state, so they're safe to run yourself — note that `sync pull` does overwrite local files to match the remote (use `sync pull --dry-run` to only preview), while `schedule` just lists. ```bash -# Push schedules to Windmill +# Push schedules to Windmill — only when the user explicitly asks to deploy wmill sync push # Pull schedules from Windmill diff --git a/system_prompts/auto-generated/skills/triggers/SKILL.md b/system_prompts/auto-generated/skills/triggers/SKILL.md index 81401f1fdf..494d5d9798 100644 --- a/system_prompts/auto-generated/skills/triggers/SKILL.md +++ b/system_prompts/auto-generated/skills/triggers/SKILL.md @@ -61,10 +61,10 @@ Text/HTML/inline parts are placed inline in `body` as strings. ## CLI Commands -After writing, tell the user they can run these commands (do NOT run them yourself): +`wmill sync push` deploys local changes to the workspace and can be destructive to remote state — only suggest/run it when the user explicitly asks to deploy/publish/push, not when they say "run", "try", or "test". `sync pull` is safe to run yourself — it never mutates remote state, though it does overwrite local files to match the remote (use `sync pull --dry-run` to only preview). ```bash -# Push trigger configuration +# Push trigger configuration — only when the user explicitly asks to deploy wmill sync push # Pull triggers from Windmill diff --git a/system_prompts/base/resources.md b/system_prompts/base/resources.md index 0f51f6d322..763c0741f8 100644 --- a/system_prompts/base/resources.md +++ b/system_prompts/base/resources.md @@ -237,6 +237,7 @@ wmill resource-type list --schema # Get specific resource type schema wmill resource-type get postgresql -# Push resources (tell the user to run this, do NOT run it yourself) +# Push resources to Windmill — deploys to the workspace and can be destructive to +# remote state, so only run it when the user explicitly asks to deploy/publish/push wmill sync push ``` diff --git a/system_prompts/base/schedules.md b/system_prompts/base/schedules.md index 8e50fb87a6..f3d9d9ee9a 100644 --- a/system_prompts/base/schedules.md +++ b/system_prompts/base/schedules.md @@ -34,10 +34,10 @@ Windmill uses 6-field cron expressions (includes seconds): ## CLI Commands -After writing, tell the user they can run these commands (do NOT run them yourself): +`wmill sync push` deploys local changes to the workspace and can be destructive to remote state — only suggest/run it when the user explicitly asks to deploy/publish/push, not when they say "run", "try", or "test". The commands below never mutate remote state, so they're safe to run yourself — note that `sync pull` does overwrite local files to match the remote (use `sync pull --dry-run` to only preview), while `schedule` just lists. ```bash -# Push schedules to Windmill +# Push schedules to Windmill — only when the user explicitly asks to deploy wmill sync push # Pull schedules from Windmill diff --git a/system_prompts/base/triggers.md b/system_prompts/base/triggers.md index d97688ca4d..37ce6fc0e6 100644 --- a/system_prompts/base/triggers.md +++ b/system_prompts/base/triggers.md @@ -56,10 +56,10 @@ Text/HTML/inline parts are placed inline in `body` as strings. ## CLI Commands -After writing, tell the user they can run these commands (do NOT run them yourself): +`wmill sync push` deploys local changes to the workspace and can be destructive to remote state — only suggest/run it when the user explicitly asks to deploy/publish/push, not when they say "run", "try", or "test". `sync pull` is safe to run yourself — it never mutates remote state, though it does overwrite local files to match the remote (use `sync pull --dry-run` to only preview). ```bash -# Push trigger configuration +# Push trigger configuration — only when the user explicitly asks to deploy wmill sync push # Pull triggers from Windmill diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 6413ffbbdf..ae801c95f5 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.720.0", + "version": "1.721.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index a5f81e2431..2a38875ecc 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.720.0", + "version": "1.721.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 51ae8492ef..a5f6b5427c 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.720.0 +1.721.0