From c258928ab62adc1327913c21556520dfd1e5c24c Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 9 Jun 2026 10:09:04 +0200 Subject: [PATCH 1/5] fix(cli): reconcile case-only path drift during sync on case-insensitive filesystems (WIN-2020) (#9485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): reconcile case-only path drift during sync on case-insensitive filesystems Windmill paths are case-sensitive, but Windows (and the default macOS setup) use case-insensitive filesystems. The real-world failure behind WIN-2020 is not a user authoring both f/Caps and f/caps — it is a single capitalized folder whose on-disk casing silently drifts (Windows stores and reports whatever case the directory was first created with, regardless of the server's path). The diff then sees the drifted local path as a brand-new item and emits a destructive "delete f/Caps + add f/caps" pair, so a capitalized folder appears to vanish and a lowercase clone shows up out of nowhere — and a push can clobber the real server item. Fix: on a case-insensitive filesystem, reconcile case-only drift before diffing. The server's path casing is authoritative, so compareDynFSElement now rewrites local keys that differ from a remote key only by case to the server's casing (canonicalizeCaseInsensitiveKeys), making the diff treat them as the same item. Case-insensitivity is auto-detected by probing the sync directory, with a WMILL_CASE_INSENSITIVE_FS=true/false override to force Windows behaviour (or emulate it for tests / cross-platform repos) on any host. Reconciled paths are summarized in a single info line. Genuinely unrepresentable collisions — two DISTINCT server paths that differ only by case — cannot be canonicalized to one target; those are detected and warned about on every platform so a case-sensitive-Linux author learns their tree won't round-trip for a Windows/macOS teammate. Tests: - Pure unit tests for findCaseInsensitiveCollisions, canonicalizeCaseInsensitiveKeys and summarizeCaseRewrites (platform independent). - An end-to-end drift test that runs on BOTH CI jobs: on the Windows runner it exercises the real case-insensitive NTFS + auto-probe; on Linux it reproduces the drift via rename, asserts the destructive phantom appears without the fix, and asserts a clean no-op push with the fix forced on. Fixes WIN-2020 Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cli): canonicalize local-only descendants of drifted folders; dedupe nested case collisions Address two review findings on the WIN-2020 case-insensitive sync fix: P1 (correctness): canonicalizeCaseInsensitiveKeys previously only rewrote local keys with an exact full-path remote match. A brand-new local file under a drifted folder (e.g. adding f/caps/New.ts when the server has f/Caps but no f/caps/New.ts) had no exact match, so it kept its lowercase casing and push uploaded it as-is — recreating f/caps beside f/Caps and reintroducing the very collision the fix prevents. Canonicalization is now segment-by-segment against a trie of remote paths, so local-only descendants inherit the longest unambiguous server folder casing. A segment is only adopted when the server casing is unambiguous; at the first ambiguous/unknown segment the remainder keeps local casing. The original key's separator style is preserved so rewritten keys still round-trip. P2 (nit): findCaseInsensitiveCollisions reported the folder group AND a nested per-file group when case-variant folders held same-named files, inflating the "Found N path(s)" count. It now reports only the shallowest clash (drops a group whose ancestor prefix is itself a collision). Tests: add unit coverage for the new-file-under-drifted-folder rewrite, the stop-at-first-unguided-segment behavior, and shallowest-only collision reporting; extend the e2e drift test to assert a new item added under the drifted folder is pushed under the server's folder casing. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- cli/src/commands/sync/sync.ts | 286 +++++++++++++++++- .../case_insensitive_collisions_unit.test.ts | 232 ++++++++++++++ cli/test/mixed_case_paths.test.ts | 169 ++++++++++- 3 files changed, 685 insertions(+), 2 deletions(-) create mode 100644 cli/test/case_insensitive_collisions_unit.test.ts diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index ed3b7828f1..863ccb4142 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( @@ -3361,6 +3644,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 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); From 66c0334e7030a972992c232fb34cc1beba3e8826 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 9 Jun 2026 10:14:02 +0200 Subject: [PATCH 2/5] chore(main): release 1.721.0 (#9480) * chore(main): release 1.721.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 14 ++ backend/Cargo.lock | 156 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/core/constants.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 132 insertions(+), 118 deletions(-) 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/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/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/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/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/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/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 From 136c88a2318659ecd02ffb302fdd71f292c787e7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 9 Jun 2026 16:05:50 +0200 Subject: [PATCH 3/5] docs(skills): decouple safe local commands from destructive sync push (#9467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(skills): decouple safe local commands from destructive sync push The schedules, triggers, and resources skill templates lumped every CLI command under a blunt "do NOT run them yourself" directive. This conflated two very different risk profiles and forbade the agent from running even read-only/local commands, creating needless friction. Align these three with the nuanced policy flow-cli.md already uses: keep `wmill sync push` defensive (it deploys and can be destructive to remote state — only run when the user explicitly asks to deploy/publish/push), while letting read-only commands (`sync pull`, `schedule`, `resource list`) be run freely. Regenerated auto-generated skills + skills.gen.ts. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cli): warn that sync push is destructive in dry-run output Co-Authored-By: Claude Opus 4.8 (1M context) * docs(skills): clarify sync pull mutates local files, not read-only Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: centdix --- cli/src/commands/sync/sync.ts | 12 ++++++++++++ cli/src/guidance/skills.gen.ts | 11 ++++++----- system_prompts/auto-generated/prompts.ts | 3 ++- .../auto-generated/skills/resources/SKILL.md | 3 ++- .../auto-generated/skills/schedules/SKILL.md | 4 ++-- .../auto-generated/skills/triggers/SKILL.md | 4 ++-- system_prompts/base/resources.md | 3 ++- system_prompts/base/schedules.md | 4 ++-- system_prompts/base/triggers.md | 4 ++-- 9 files changed, 32 insertions(+), 16 deletions(-) diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 863ccb4142..10331ed668 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -3465,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 }, ) { @@ -4001,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; @@ -4044,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/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/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 From 4c22e3b712a74828cf654ea7d89aeab5b50cfbd7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 9 Jun 2026 18:33:01 +0200 Subject: [PATCH 4/5] fix: inherit container NO_PROXY into MITM tracing proxy job exclusions (#9492) When HTTP request tracing is enabled, the NO_PROXY injected into traced jobs was built solely from the no_proxy_hosts instance setting, ignoring the worker container's own NO_PROXY. Enabling tracing therefore silently dropped every exclusion an operator had already configured at the container level, funneling those hosts into the MITM proxy (and on to any upstream corporate proxy). The upstream-relay side already honored the container NO_PROXY; this makes the injected-into-jobs side symmetric by merging both sources. Co-authored-by: Claude Opus 4.8 (1M context) --- backend/windmill-worker/src/worker.rs | 74 +++++++++++++++++++++------ 1 file changed, 58 insertions(+), 16 deletions(-) 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" + ); } } From cfe51190356a9e922f6398dd875abc368accbd15 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:34:36 +0200 Subject: [PATCH 5/5] feat(ai): add list_runs and get_job_logs tools to global chat mode (#9488) * feat(ai): add list_runs and get_job_logs tools to global chat mode Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(ai): always suppress ansi hint in get_job_logs, drop misnamed param Co-Authored-By: Claude Opus 4.8 (1M context) * test(ai_evals): add global list_runs and get_job_logs eval cases Co-Authored-By: Claude Opus 4.8 (1M context) * style(ai): trim get_job_logs description and format global core Co-Authored-By: Claude Opus 4.8 (1M context) * fix(ai): surface list_runs/get_job_logs output as tool result The tools set showDetails but never set message.result, so the details panel rendered "No result yet" even on success. Set result in setToolStatus (logs go in result for get_job_logs). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- ai_evals/adapters/frontend/mockBackend.ts | 78 ++++++++- .../adapters/frontend/vitestAdapter.test.ts | 12 +- ai_evals/cases/global.yaml | 49 ++++++ .../frontend/global/initial/jobs_seed.json | 30 ++++ .../copilot/chat/global/core.test.ts | 107 ++++++++++++- .../components/copilot/chat/global/core.ts | 148 ++++++++++++++++-- 6 files changed, 400 insertions(+), 24 deletions(-) create mode 100644 ai_evals/fixtures/frontend/global/initial/jobs_seed.json 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/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)