diff --git a/CHANGELOG.md b/CHANGELOG.md index 11f5d8e97c..fbe24efa34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [1.729.0](https://github.com/windmill-labs/windmill/compare/v1.728.1...v1.729.0) (2026-06-18) + + +### Features + +* add ducklake schema support to the database manager ([#9633](https://github.com/windmill-labs/windmill/issues/9633)) ([3eeccaf](https://github.com/windmill-labs/windmill/commit/3eeccaf9682b7803fdf5be8dcbc4d243e0ba2e49)) +* **ai-chat:** self-hosted docs tools via windmill.dev llms.txt + ask benchmark ([#9578](https://github.com/windmill-labs/windmill/issues/9578)) ([f4425fc](https://github.com/windmill-labs/windmill/commit/f4425fca9fb0d02b845bd72888ade54905c5a30b)) +* **frontend:** View Diff and in-place Load for other users' drafts ([#9621](https://github.com/windmill-labs/windmill/issues/9621)) ([5508f1d](https://github.com/windmill-labs/windmill/commit/5508f1da9cd04c2583eb3f7ee6bce19d067f2227)) +* per-user draft review & deploy page (gating, badges, rename, raw-app deploy fixes) ([#9625](https://github.com/windmill-labs/windmill/issues/9625)) ([e09cd58](https://github.com/windmill-labs/windmill/commit/e09cd5862cb636e143027fe8d9a5be9c7097b031)) +* queue messages typed while ai chat is streaming ([#9525](https://github.com/windmill-labs/windmill/issues/9525)) ([51bd869](https://github.com/windmill-labs/windmill/commit/51bd8692a482850f7ac8b04dd16db5876336b5b9)) +* zero-setup oauth client credentials for registry providers ([#9559](https://github.com/windmill-labs/windmill/issues/9559)) ([e26a923](https://github.com/windmill-labs/windmill/commit/e26a9239a62a25abf90ef06ade4dde7f36e791bb)) + + +### Bug Fixes + +* **ai_evals:** adapt global eval harness to DB-backed user drafts ([#9641](https://github.com/windmill-labs/windmill/issues/9641)) ([e87ff79](https://github.com/windmill-labs/windmill/commit/e87ff79ecf6a6e0958916ed1b3756fb3addf719f)) +* **drafts:** preserve original timestamp when migrating localStorage drafts ([#9638](https://github.com/windmill-labs/windmill/issues/9638)) ([8021775](https://github.com/windmill-labs/windmill/commit/8021775f5f961ef6fd01b022639b85855326a1da)) +* **frontend:** don't save drafts on leave when auto-save is off, warn instead ([#9630](https://github.com/windmill-labs/windmill/issues/9630)) ([2523465](https://github.com/windmill-labs/windmill/commit/252346500945a9571af744c839ac0c7d6870504f)) +* **frontend:** render Modal2 dialogs above the AI chat panel ([#9636](https://github.com/windmill-labs/windmill/issues/9636)) ([b67c8cf](https://github.com/windmill-labs/windmill/commit/b67c8cf42b477575fc1bc448058ec0d3b7e54fee)) +* **frontend:** show AI sessions when AI unconfigured, with disabled chat ([#9644](https://github.com/windmill-labs/windmill/issues/9644)) ([ba69d81](https://github.com/windmill-labs/windmill/commit/ba69d8147b615e160cf3d2885fc65a0777b78b71)) +* **git-sync:** bump default sync script to hub/28719 (windmill-cli 1.728.1) for WAC modules ([#9649](https://github.com/windmill-labs/windmill/issues/9649)) ([3c0e38b](https://github.com/windmill-labs/windmill/commit/3c0e38b5890d77983cb5cf5f422a62a73e7a4f22)) + ## [1.728.1](https://github.com/windmill-labs/windmill/compare/v1.728.0...v1.728.1) (2026-06-17) diff --git a/ai_evals/adapters/frontend/benchmarkRunner.ts b/ai_evals/adapters/frontend/benchmarkRunner.ts index 1729df7170..32107eadf1 100644 --- a/ai_evals/adapters/frontend/benchmarkRunner.ts +++ b/ai_evals/adapters/frontend/benchmarkRunner.ts @@ -96,7 +96,12 @@ async function getModeRunner( } function parseMode(value: string | undefined): FrontendBenchmarkMode { - if (value === "flow" || value === "app" || value === "script" || value === "global") { + if ( + value === "flow" || + value === "app" || + value === "script" || + value === "global" + ) { return value; } throw new Error(`Unsupported frontend benchmark mode: ${String(value)}`); diff --git a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts index 058adc3644..226fba7c0a 100644 --- a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts @@ -9,6 +9,7 @@ import { } from "../../../../../frontend/src/lib/components/copilot/chat/global/core"; import { clearGlobalDrafts, + getGlobalDraft, listGlobalDrafts, } from "../../../../../frontend/src/lib/components/copilot/chat/global/userDraftAdapter"; import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared"; @@ -18,6 +19,7 @@ import type { GlobalDraftState } from "../../../../core/validators"; import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings"; import { registerBenchmarkWorkspaceRunnables, + seedBenchmarkDraft, unregisterBenchmarkWorkspaceRunnables, type BenchmarkWorkspaceRunnables, } from "../../mockBackend"; @@ -94,7 +96,7 @@ export async function runGlobalEval( tools: getGlobalEvalTools(), helpers: {}, apiKey, - getOutput: () => ({ drafts: listGlobalDrafts(workspaceRoot) }), + getOutput: () => collectGlobalDraftState(workspaceRoot), onAssistantMessageStart: options.runContext?.onAssistantMessageStart, onAssistantToken: options.runContext?.onAssistantChunk, onAssistantMessageEnd: options.runContext?.onAssistantMessageEnd, @@ -130,6 +132,32 @@ export async function runGlobalEval( } } +// Build the harness output from the DB-backed drafts. `listGlobalDrafts` returns +// metadata-only rows for backend drafts (the model's `write_script` etc. persist +// straight to the backend with no in-tab editor cell), so re-read each such row +// with `getGlobalDraft` to attach the full value the validators assert on. A row +// that already carries a value (the production in-tab cell overlay) is kept as-is. +async function collectGlobalDraftState( + workspace: string, +): Promise { + const items = await listGlobalDrafts(workspace); + const drafts = await Promise.all( + items.map(async (item) => { + if (item.value !== undefined) { + return item; + } + const full = await getGlobalDraft( + workspace, + item.type, + item.path, + item.triggerKind, + ); + return full ?? item; + }), + ); + return { drafts: drafts as GlobalDraftState["drafts"] }; +} + function seedLiveEditorDrafts( workspace: string, fixtures: GlobalLiveEditorDraftFixture[], @@ -138,7 +166,9 @@ function seedLiveEditorDrafts( const itemKind = LIVE_EDITOR_ITEM_KINDS[fixture.type]; const storagePath = fixture.storagePath ?? fixture.effectivePath ?? ""; if (fixture.value !== undefined) { - UserDraft.save(itemKind, storagePath, fixture.value, { workspace }); + // Seed as a backend draft row, not an in-tab cell: a cell would shadow the + // model's DB-backed edit when the output is read back via listGlobalDrafts. + seedBenchmarkDraft(workspace, itemKind, storagePath, fixture.value); } UserDraft.setLiveEditorDraft({ workspace, diff --git a/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts b/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts index 5f4ea2c307..faec6c51ed 100644 --- a/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts @@ -38,8 +38,9 @@ export interface RunEvalParams { helpers: THelpers; /** API key for the provider */ apiKey: string; - /** Function to get the current output state */ - getOutput: () => TOutput; + /** Function to get the current output state. May be async — global mode reads + * DB-backed drafts back through the (mocked) backend to build its output. */ + getOutput: () => TOutput | Promise; /** Model and Windmill backend configuration */ options: EvalRunnerOptions; onAssistantMessageStart?: () => void; @@ -154,7 +155,7 @@ export async function runEval( if (result.hitMaxIterations) { return { success: false, - output: getOutput(), + output: (await getOutput()) as TOutput, error: `Reached max turns (${maxIterations})`, tokenUsage: result.tokenUsage, toolCallsCount, @@ -170,7 +171,7 @@ export async function runEval( return { success: true, - output: getOutput(), + output: (await getOutput()) as TOutput, tokenUsage: result.tokenUsage, toolCallsCount, toolsCalled, @@ -191,7 +192,7 @@ export async function runEval( return { success: false, - output: getOutput(), + output: (await getOutput()) as TOutput, error: errorMessage, tokenUsage: { prompt: 0, completion: 0, total: 0 }, toolCallsCount, diff --git a/ai_evals/adapters/frontend/mockBackend.ts b/ai_evals/adapters/frontend/mockBackend.ts index 622c79bf46..74fa812883 100644 --- a/ai_evals/adapters/frontend/mockBackend.ts +++ b/ai_evals/adapters/frontend/mockBackend.ts @@ -3,7 +3,11 @@ import type { CompletedJob, Flow, Job, Script } from '../../../frontend/src/lib/ import type { DataTableTables, DataTableTableSchema, - ScriptLang + GetDraftForUserResponse, + ListDraftsResponse, + ScriptLang, + UpdateDraftResponse, + UserDraftItemKind } from '../../../frontend/src/lib/gen/types.gen' import { buildScriptLintResult } from './core/script/preview' import { applyDatatableSql, type BenchmarkDatatableSeed } from './datatableSqlEngine' @@ -63,6 +67,7 @@ export function resetBenchmarkMockBackend(): void { benchmarkWorkspaces.clear() benchmarkWorkspaceRunnables.clear() benchmarkJobs.clear() + benchmarkDrafts.clear() } export function registerBenchmarkWorkspace(workspace: string): void { @@ -74,6 +79,8 @@ export function registerBenchmarkWorkspaceRunnables( runnables: BenchmarkWorkspaceRunnables ): void { benchmarkWorkspaces.add(workspace) + // Fresh case: drop any drafts left from a prior run on this workspace id. + clearBenchmarkDrafts(workspace) // Datatables are mutated in place by exec_datatable_sql (a write must be visible // to later reads), so store an isolated deep copy — never mutate the caller's seed. benchmarkWorkspaceRunnables.set(workspace, { @@ -98,6 +105,7 @@ export function registerBenchmarkWorkspaceRunnables( export function unregisterBenchmarkWorkspace(workspace: string): void { benchmarkWorkspaces.delete(workspace) benchmarkWorkspaceRunnables.delete(workspace) + clearBenchmarkDrafts(workspace) for (const [jobId, entry] of benchmarkJobs.entries()) { if (entry.workspace === workspace) { benchmarkJobs.delete(jobId) @@ -238,6 +246,110 @@ export function getBenchmarkJobLogs(workspace: string, jobId: string): string { return job.logs ?? '' } +// ============= Drafts (per-user, DB-backed in production) ============= + +/** + * In-memory stand-in for the per-user draft backend (`DraftService`). The global + * AI chat now persists and reads drafts through the backend DB instead of an + * in-tab `UserDraft` cell, so the eval mocks the three draft endpoints it + * exercises (`updateDraft` / `getDraftForUser` / `listDrafts`) and keeps the + * saved values here, keyed by workspace + draft kind + storage path. Mirrors the + * semantics of the production unit test's mock in + * `frontend/src/lib/components/copilot/chat/global/core.test.ts`. + */ +const benchmarkDrafts = new Map< + string, + { workspace: string; kind: UserDraftItemKind; path: string; value: unknown } +>() + +// Fixed timestamp so artifacts stay deterministic. No eval simulates a +// concurrent writer, so every save is accepted and the conflict branch is +// never taken — the syncer just records this as its `last_sync` baseline. +const BENCHMARK_DRAFT_TIMESTAMP = '1970-01-01T00:00:00.000Z' + +function benchmarkDraftKey(workspace: string, kind: string, path: string): string { + return `${workspace}::${kind}::${path}` +} + +export function clearBenchmarkDrafts(workspace: string): void { + for (const [key, entry] of benchmarkDrafts.entries()) { + if (entry.workspace === workspace) { + benchmarkDrafts.delete(key) + } + } +} + +/** + * Seed a draft straight into the store — used by the eval's live-editor draft + * fixtures, which model "the user already has this draft open/saved". Writing it + * here (instead of through `UserDraft.save`) keeps it a backend draft row with no + * shadowing in-tab cell, so a model edit that persists to the backend is what the + * output read-back captures — not the stale seed. + */ +export function seedBenchmarkDraft( + workspace: string, + kind: UserDraftItemKind, + path: string, + value: unknown +): void { + benchmarkDrafts.set(benchmarkDraftKey(workspace, kind, path), { + workspace, + kind, + path, + value + }) +} + +/** Mirror `DraftService.updateDraft`: a `null`/omitted value deletes the row. */ +export function updateBenchmarkDraft(input: { + workspace: string + kind: UserDraftItemKind + path: string + requestBody?: { value?: unknown } +}): UpdateDraftResponse { + const key = benchmarkDraftKey(input.workspace, input.kind, input.path) + const value = input.requestBody?.value + if (value == null) { + benchmarkDrafts.delete(key) + } else { + benchmarkDrafts.set(key, { + workspace: input.workspace, + kind: input.kind, + path: input.path, + value + }) + } + return { status: 'saved', current_timestamp: BENCHMARK_DRAFT_TIMESTAMP } +} + +/** Mirror `DraftService.getDraftForUser`: 404-shaped throw when absent so the + * adapter's narrowed catch treats it as "no draft" instead of re-throwing. */ +export function getBenchmarkDraftForUser(input: { + workspace: string + kind: UserDraftItemKind + path: string +}): GetDraftForUserResponse { + const entry = benchmarkDrafts.get(benchmarkDraftKey(input.workspace, input.kind, input.path)) + if (!entry) { + throw Object.assign(new Error(`no draft for "${input.path}"`), { status: 404 }) + } + return { value: entry.value, created_at: BENCHMARK_DRAFT_TIMESTAMP } +} + +/** Mirror `DraftService.listDrafts`: metadata rows (no value) for a workspace. */ +export function listBenchmarkDrafts(workspace: string): ListDraftsResponse { + return [...benchmarkDrafts.values()] + .filter((entry) => entry.workspace === workspace) + .map((entry) => ({ + kind: entry.kind, + path: entry.path, + summary: (entry.value as { summary?: string } | null)?.summary, + draft_only: true, + legacy_draft: false, + created_at: BENCHMARK_DRAFT_TIMESTAMP + })) +} + // ============= Datatables (best-effort in-memory SQL) ============= /** diff --git a/ai_evals/adapters/frontend/mockBackendDrafts.test.ts b/ai_evals/adapters/frontend/mockBackendDrafts.test.ts new file mode 100644 index 0000000000..a720de5e43 --- /dev/null +++ b/ai_evals/adapters/frontend/mockBackendDrafts.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { + clearBenchmarkDrafts, + getBenchmarkDraftForUser, + listBenchmarkDrafts, + resetBenchmarkMockBackend, + seedBenchmarkDraft, + updateBenchmarkDraft +} from './mockBackend' + +const WORKSPACE = 'benchmark-drafts-ws' + +// Drives the in-memory stand-in for the per-user draft backend (`DraftService`) +// that the global AI-chat eval round-trips its drafts through. Mirrors the +// production-unit-test mock in +// `frontend/src/lib/components/copilot/chat/global/core.test.ts`. +describe('mockBackend drafts', () => { + beforeEach(() => resetBenchmarkMockBackend()) + afterEach(() => resetBenchmarkMockBackend()) + + it('round-trips a saved draft through update / get / list', () => { + const value = { summary: 'Greet a user', content: 'export async function main() {}' } + const res = updateBenchmarkDraft({ + workspace: WORKSPACE, + kind: 'script', + path: 'f/evals/greet', + requestBody: { value } + }) + expect(res.status).toBe('saved') + + expect(getBenchmarkDraftForUser({ workspace: WORKSPACE, kind: 'script', path: 'f/evals/greet' }).value).toEqual( + value + ) + + const rows = listBenchmarkDrafts(WORKSPACE) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ kind: 'script', path: 'f/evals/greet', summary: 'Greet a user', draft_only: true }) + }) + + it('treats a null value as a delete', () => { + updateBenchmarkDraft({ + workspace: WORKSPACE, + kind: 'variable', + path: 'f/evals/token', + requestBody: { value: { summary: 'token' } } + }) + updateBenchmarkDraft({ + workspace: WORKSPACE, + kind: 'variable', + path: 'f/evals/token', + requestBody: { value: null } + }) + + expect(listBenchmarkDrafts(WORKSPACE)).toHaveLength(0) + expect(() => getBenchmarkDraftForUser({ workspace: WORKSPACE, kind: 'variable', path: 'f/evals/token' })).toThrow() + }) + + it('throws a 404-shaped error when no draft exists', () => { + try { + getBenchmarkDraftForUser({ workspace: WORKSPACE, kind: 'script', path: 'f/evals/missing' }) + throw new Error('expected a throw') + } catch (e) { + expect((e as { status?: number }).status).toBe(404) + } + }) + + it('seeds a draft as a backend row that a later edit overwrites', () => { + seedBenchmarkDraft(WORKSPACE, 'script', 'f/evals/current', { content: 'seed' }) + expect(getBenchmarkDraftForUser({ workspace: WORKSPACE, kind: 'script', path: 'f/evals/current' }).value).toEqual({ + content: 'seed' + }) + + // A model edit persists the same path and must win over the seed. + updateBenchmarkDraft({ + workspace: WORKSPACE, + kind: 'script', + path: 'f/evals/current', + requestBody: { value: { content: 'edited' } } + }) + expect(getBenchmarkDraftForUser({ workspace: WORKSPACE, kind: 'script', path: 'f/evals/current' }).value).toEqual({ + content: 'edited' + }) + }) + + it('clears only the targeted workspace', () => { + seedBenchmarkDraft(WORKSPACE, 'script', 'f/a', { content: 'a' }) + seedBenchmarkDraft('other-ws', 'script', 'f/b', { content: 'b' }) + + clearBenchmarkDrafts(WORKSPACE) + + expect(listBenchmarkDrafts(WORKSPACE)).toHaveLength(0) + expect(listBenchmarkDrafts('other-ws')).toHaveLength(1) + }) +}) diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index ebbbac8d11..657f9aca6b 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -36,12 +36,14 @@ vi.mock('$lib/gen', async () => { getBenchmarkCompletedJob, getBenchmarkCompletedJobResultMaybe, getBenchmarkDatatableSchema, + getBenchmarkDraftForUser, getBenchmarkFlowByPath, getBenchmarkJobLogs, getBenchmarkScriptByHash, getBenchmarkScriptByPath, hasBenchmarkWorkspace, listBenchmarkDatatables, + listBenchmarkDrafts, listBenchmarkFlows, listBenchmarkJobs, listBenchmarkScripts, @@ -50,7 +52,8 @@ vi.mock('$lib/gen', async () => { previewBenchmarkSchedule, runBenchmarkDatatableSql, runBenchmarkFlowByPath, - runBenchmarkScriptPreview + runBenchmarkScriptPreview, + updateBenchmarkDraft } = await import('./mockBackend') function wrapService(target: T, overrides: Record): T { @@ -66,6 +69,25 @@ vi.mock('$lib/gen', async () => { return { ...actual, + DraftService: wrapService(actual.DraftService, { + updateDraft: async (data: { + workspace: string + kind: any + path: string + requestBody?: { value?: unknown } + }) => + hasBenchmarkWorkspace(data.workspace) + ? updateBenchmarkDraft(data) + : actual.DraftService.updateDraft(data), + getDraftForUser: async (data: { workspace: string; kind: any; path: string }) => + hasBenchmarkWorkspace(data.workspace) + ? getBenchmarkDraftForUser(data) + : actual.DraftService.getDraftForUser(data), + listDrafts: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) + ? listBenchmarkDrafts(data.workspace) + : actual.DraftService.listDrafts(data) + }), ScriptService: wrapService(actual.ScriptService, { listScripts: async (data: { workspace: string }) => hasBenchmarkWorkspace(data.workspace) @@ -434,5 +456,6 @@ benchmarkIt( resetBenchmarkMockBackend() } }, - 600_000 + // Full-suite runs (30+ cases at concurrency 2-3) routinely exceed 10 minutes. + 7_200_000 ) diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 28839b0594..766515519b 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -870,3 +870,76 @@ judgeChecklist: - fetches the logs for the requested job id - explains the failure from the returned logs (connection refused to the upstream API) + +# --- Documentation search (search_docs) --- +# Pure product-knowledge questions: the assistant should consult the docs via +# search_docs and answer conversationally, not draft or mutate anything. No +# draft is produced, so the global judge is skipped and we validate tool use. + +- id: global-docs-ai-agent-step + prompt: |- + Does Windmill support a flow step where an LLM decides which of my scripts to call based on the input? + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - search_docs + forbiddenToolsUsed: + - write_script + - write_flow + - deploy_workspace_item + - delete_workspace_item + skipJudge: true + +- id: global-docs-retry-step + prompt: |- + How does automatic retry work for a flow step that calls a flaky API? + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - search_docs + forbiddenToolsUsed: + - write_script + - write_flow + - deploy_workspace_item + - delete_workspace_item + skipJudge: true + +- id: global-docs-key-value-store + prompt: |- + Can I use a Redis-style key-value store from my Windmill scripts, and how? + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - search_docs + forbiddenToolsUsed: + - write_script + - write_flow + - deploy_workspace_item + - delete_workspace_item + skipJudge: true + +- id: global-docs-cron-schedule-format + prompt: |- + How do Windmill's cron schedules work, and what format does the schedule expression use? + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - search_docs + forbiddenToolsUsed: + - write_script + - write_flow + - deploy_workspace_item + - delete_workspace_item + skipJudge: true diff --git a/ai_evals/core/cases.test.ts b/ai_evals/core/cases.test.ts index 05e2f1527b..9955a73fa9 100644 --- a/ai_evals/core/cases.test.ts +++ b/ai_evals/core/cases.test.ts @@ -246,6 +246,21 @@ describe("loadCases", () => { }); }); + it("loads global docs-search cases as tool-use checks", async () => { + const globalCases = await loadCases("global"); + const docsCases = globalCases.filter((entry) => + entry.id.startsWith("global-docs-"), + ); + expect(docsCases.length).toBeGreaterThanOrEqual(3); + + // Each docs case verifies the assistant reaches for search_docs and does not + // draft anything; with no draft, the global judge is skipped. + for (const entry of docsCases) { + expect(entry.skipJudge).toBe(true); + expect(entry.toolExpect?.requiredToolsUsed).toContain("search_docs"); + } + }); + it("loads tool expectations for workspace mutation cases", async () => { const scriptCases = await loadCases("script"); const caseEntry = scriptCases.find( diff --git a/ai_evals/core/runSuite.ts b/ai_evals/core/runSuite.ts index ed82d841cb..bb0f9b99a4 100644 --- a/ai_evals/core/runSuite.ts +++ b/ai_evals/core/runSuite.ts @@ -225,7 +225,9 @@ async function runCaseAttempts(input: { checklist: input.evalCase.judgeChecklist, initial, expected: input.modeRunner.mode === "cli" ? undefined : expected, - actual: run.actual, + actual: input.modeRunner.prepareJudgeActual + ? input.modeRunner.prepareJudgeActual(run.actual) + : run.actual, model: input.judgeModel, }); diff --git a/ai_evals/core/types.ts b/ai_evals/core/types.ts index 27c2fcddac..9e2e32d5c3 100644 --- a/ai_evals/core/types.ts +++ b/ai_evals/core/types.ts @@ -172,7 +172,10 @@ export interface ToolValidationSpec { toolCallArgs?: ToolCallArgumentRule[]; } -export type EvalValidationSpec = FlowValidationSpec | AppValidationSpec | GlobalValidationSpec; +export type EvalValidationSpec = + | FlowValidationSpec + | AppValidationSpec + | GlobalValidationSpec; export interface EvalCase { id: string; @@ -294,6 +297,12 @@ export interface ModeRunner { context: ModeRunContext; }): Promise; buildArtifacts?(actual: TActual): BenchmarkArtifactFile[]; + /** + * Optional transform applied to `actual` before it is handed to the LLM judge. + * Use it to strip fields the judge must stay blind to (e.g. which docs-tool + * arm produced an answer). When omitted, the judge receives `actual` as-is. + */ + prepareJudgeActual?(actual: TActual): unknown; } export interface BenchmarkAttemptResult { diff --git a/backend/.sqlx/query-46f00a75b2e7e4ac70758a9687070f68bc0421f1aa228f80157adda63191d33b.json b/backend/.sqlx/query-46f00a75b2e7e4ac70758a9687070f68bc0421f1aa228f80157adda63191d33b.json new file mode 100644 index 0000000000..b90ba150d9 --- /dev/null +++ b/backend/.sqlx/query-46f00a75b2e7e4ac70758a9687070f68bc0421f1aa228f80157adda63191d33b.json @@ -0,0 +1,57 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH legacy AS (\n DELETE FROM draft\n WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL\n RETURNING value\n )\n INSERT INTO draft (workspace_id, email, path, typ, value, created_at)\n SELECT $1, $4, $2, $3, value, now() FROM legacy\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now()\n RETURNING 1 as \"one!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "one!", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + }, + "Varchar" + ] + }, + "nullable": [ + null + ] + }, + "hash": "46f00a75b2e7e4ac70758a9687070f68bc0421f1aa228f80157adda63191d33b" +} diff --git a/backend/.sqlx/query-54ad5cc89563fdbbacdd6bfde9be6ecfcdec3d505d28cc65b5920dcf87c0014f.json b/backend/.sqlx/query-54ad5cc89563fdbbacdd6bfde9be6ecfcdec3d505d28cc65b5920dcf87c0014f.json new file mode 100644 index 0000000000..2e189af5e8 --- /dev/null +++ b/backend/.sqlx/query-54ad5cc89563fdbbacdd6bfde9be6ecfcdec3d505d28cc65b5920dcf87c0014f.json @@ -0,0 +1,48 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft\n WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github" + ] + } + } + } + ] + }, + "nullable": [] + }, + "hash": "54ad5cc89563fdbbacdd6bfde9be6ecfcdec3d505d28cc65b5920dcf87c0014f" +} diff --git a/backend/.sqlx/query-e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545.json b/backend/.sqlx/query-c8fb2a1491f90951a6d2e4e9c56d288eb60f6c6644a73cdf949de0159215a7f7.json similarity index 73% rename from backend/.sqlx/query-e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545.json rename to backend/.sqlx/query-c8fb2a1491f90951a6d2e4e9c56d288eb60f6c6644a73cdf949de0159215a7f7.json index c077375ef4..4629a9bd0a 100644 --- a/backend/.sqlx/query-e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545.json +++ b/backend/.sqlx/query-c8fb2a1491f90951a6d2e4e9c56d288eb60f6c6644a73cdf949de0159215a7f7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO draft (workspace_id, email, path, typ, value, created_at)\n VALUES ($1, $2, $3, $4, $5::text::json, now())\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now()\n WHERE $7::bool = true\n OR $6::timestamptz IS NULL\n OR draft.created_at <= $6::timestamptz\n RETURNING created_at", + "query": "INSERT INTO draft (workspace_id, email, path, typ, value, created_at)\n VALUES ($1, $2, $3, $4, $5::text::json, COALESCE($8::timestamptz, now()))\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = EXCLUDED.created_at\n WHERE $7::bool = true\n OR $6::timestamptz IS NULL\n OR draft.created_at <= $6::timestamptz\n RETURNING created_at", "describe": { "columns": [ { @@ -49,12 +49,13 @@ }, "Text", "Timestamptz", - "Bool" + "Bool", + "Timestamptz" ] }, "nullable": [ false ] }, - "hash": "e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545" + "hash": "c8fb2a1491f90951a6d2e4e9c56d288eb60f6c6644a73cdf949de0159215a7f7" } diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 169fe61e0a..1c8ddf01bd 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -5066,16 +5066,14 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", ] [[package]] @@ -5800,7 +5798,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.4", "tower-service", - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", ] [[package]] @@ -5993,12 +5991,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -6506,12 +6498,6 @@ dependencies = [ "spin 0.9.8", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "levenshtein_automata" version = "0.2.1" @@ -7273,9 +7259,9 @@ dependencies = [ [[package]] name = "mysql_common" -version = "0.37.2" +version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b42ced54aa8ac97226486337973f9bc3956e24f03a23e88a6e18f640959d6e2" +checksum = "0f27695f286b461da077b8c2f72f47feaa04ce3c3f9c0976257410e90e21208a" dependencies = [ "base64 0.22.1", "bitflags 2.13.0", @@ -9112,7 +9098,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", - "getrandom 0.4.2", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -9477,7 +9463,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", ] [[package]] @@ -12025,7 +12011,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -13358,7 +13344,7 @@ version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -13460,16 +13446,7 @@ version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -13575,28 +13552,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.4.2" @@ -13620,18 +13575,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.13.0", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver 1.0.28", -] - [[package]] name = "wasmtimer" version = "0.4.3" @@ -13680,9 +13623,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] @@ -13693,14 +13636,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.7", + "webpki-roots 1.0.8", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] @@ -13792,7 +13735,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "async-nats", @@ -13874,7 +13817,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.728.1" +version = "1.729.0" dependencies = [ "async-stream", "async-trait", @@ -13907,7 +13850,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.728.1" +version = "1.729.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13920,7 +13863,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "argon2", @@ -14058,7 +14001,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.728.1" +version = "1.729.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14081,7 +14024,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.728.1" +version = "1.729.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14094,7 +14037,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14120,7 +14063,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.728.1" +version = "1.729.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14130,7 +14073,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.728.1" +version = "1.729.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14147,7 +14090,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.728.1" +version = "1.729.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14169,7 +14112,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14192,7 +14135,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.728.1" +version = "1.729.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14208,7 +14151,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.728.1" +version = "1.729.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14229,7 +14172,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.728.1" +version = "1.729.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14250,7 +14193,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.728.1" +version = "1.729.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14264,7 +14207,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "async-nats", @@ -14299,7 +14242,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14324,7 +14267,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.728.1" +version = "1.729.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14342,7 +14285,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14364,7 +14307,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.728.1" +version = "1.729.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14384,7 +14327,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.728.1" +version = "1.729.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14420,7 +14363,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14448,7 +14391,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.728.1" +version = "1.729.0" dependencies = [ "lazy_static", "serde", @@ -14460,7 +14403,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.728.1" +version = "1.729.0" dependencies = [ "argon2", "axum 0.8.9", @@ -14485,7 +14428,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.728.1" +version = "1.729.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14499,7 +14442,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.728.1" +version = "1.729.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14532,7 +14475,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.728.1" +version = "1.729.0" dependencies = [ "chrono", "lazy_static", @@ -14546,7 +14489,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14565,7 +14508,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.728.1" +version = "1.729.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14667,7 +14610,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.728.1" +version = "1.729.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14686,7 +14629,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.728.1" +version = "1.729.0" dependencies = [ "regex", "serde", @@ -14701,7 +14644,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14725,7 +14668,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "futures", @@ -14742,7 +14685,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.728.1" +version = "1.729.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14758,7 +14701,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "async-trait", @@ -14779,7 +14722,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "async-trait", @@ -14810,7 +14753,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "arc-swap", @@ -14835,7 +14778,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "async-stream", @@ -14869,7 +14812,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "futures", @@ -14887,7 +14830,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.728.1" +version = "1.729.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14896,7 +14839,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "lazy_static", @@ -14908,7 +14851,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "serde_json", @@ -14920,7 +14863,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "gosyn", @@ -14932,7 +14875,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "lazy_static", @@ -14944,7 +14887,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "serde_json", @@ -14956,7 +14899,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "nu-parser", @@ -14967,7 +14910,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14978,7 +14921,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14990,7 +14933,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15001,7 +14944,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "async-recursion", @@ -15023,7 +14966,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "serde_json", @@ -15035,7 +14978,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "lazy_static", @@ -15049,7 +14992,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15066,7 +15009,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "lazy_static", @@ -15079,7 +15022,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "serde", @@ -15091,7 +15034,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "lazy_static", @@ -15109,7 +15052,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15125,7 +15068,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15141,7 +15084,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "serde", @@ -15152,7 +15095,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "async-recursion", @@ -15190,7 +15133,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "const_format", @@ -15229,7 +15172,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.728.1" +version = "1.729.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15240,7 +15183,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "async-recursion", @@ -15272,7 +15215,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "async-trait", @@ -15296,7 +15239,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "async-trait", @@ -15329,7 +15272,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "async-trait", @@ -15362,7 +15305,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "async-trait", @@ -15382,7 +15325,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "async-trait", @@ -15416,7 +15359,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "async-trait", @@ -15452,7 +15395,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "async-trait", @@ -15475,7 +15418,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "async-trait", @@ -15499,7 +15442,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "async-nats", @@ -15523,7 +15466,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "async-trait", @@ -15558,7 +15501,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "async-trait", @@ -15586,7 +15529,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "async-trait", @@ -15611,7 +15554,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -15630,7 +15573,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "async-once-cell", @@ -15740,7 +15683,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.728.1" +version = "1.729.0" dependencies = [ "bytes", "futures", @@ -16353,100 +16296,12 @@ version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.118", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.118", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.13.0", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver 1.0.28", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 5d2c556965..4d41151c57 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.728.1" +version = "1.729.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.728.1" +version = "1.729.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index fa76836c89..e8927cee6d 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -97b5cb2096d3a9b4818943c5abf181d914cb4e99 +136f4634aca61e74ccb045372358a1e3f6b23e75 diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index 87cddda16b..0b9a19b873 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -12,6 +12,7 @@ "bitbucket": { "auth_url": "https://bitbucket.org/site/oauth2/authorize", "token_url": "https://bitbucket.org/site/oauth2/access_token", + "grant_types": ["authorization_code", "client_credentials"], "scopes": ["repository"] }, "slack": { @@ -103,6 +104,7 @@ "linkedin": { "auth_url": "https://www.linkedin.com/oauth/v2/authorization", "token_url": "https://www.linkedin.com/oauth/v2/accessToken", + "grant_types": ["authorization_code", "client_credentials"], "scopes": ["w_member_social", "r_liteprofile", "r_emailaddress"], "req_body_auth": true }, @@ -114,14 +116,31 @@ "visma": { "auth_url": "https://connect.visma.com/connect/authorize", "token_url": "https://connect.visma.com/connect/token", + "grant_types": ["authorization_code", "client_credentials"], "scopes": [ "offline_access", "vismanet_erp_interactive_api:create", "vismanet_erp_interactive_api:delete", "vismanet_erp_interactive_api:read", "vismanet_erp_interactive_api:update" + ], + "cc_scopes": [ + "vismanet_erp_service_api:create", + "vismanet_erp_service_api:delete", + "vismanet_erp_service_api:read", + "vismanet_erp_service_api:update" ] }, + "coupa": { + "grant_types": ["client_credentials"], + "connect_config_template": { + "display_name": "Coupa", + "label": "Coupa instance", + "placeholder": "your-instance", + "token_url": "https://{instance}.coupahost.com/oauth2/token", + "strip_suffix": ".coupahost.com" + } + }, "sage_intacct": { "auth_url": "https://api.intacct.com/ia/api/v1/oauth2/authorize", "token_url": "https://api.intacct.com/ia/api/v1/oauth2/token", @@ -130,6 +149,7 @@ "spotify": { "auth_url": "https://accounts.spotify.com/authorize", "token_url": "https://accounts.spotify.com/api/token", + "grant_types": ["authorization_code", "client_credentials"], "scopes": [ "user-read-playback-state", "user-modify-playback-state", @@ -149,12 +169,16 @@ "xero": { "auth_url": "https://login.xero.com/identity/connect/authorize", "token_url": "https://identity.xero.com/connect/token", - "scopes": ["offline_access", "accounting.transactions"] + "grant_types": ["authorization_code", "client_credentials"], + "scopes": ["offline_access", "accounting.transactions"], + "cc_scopes": ["accounting.transactions"] }, "zoho": { "auth_url": "https://accounts.zoho.com/oauth/v2/auth", "token_url": "https://accounts.zoho.com/oauth/v2/token", + "grant_types": ["authorization_code", "client_credentials"], "scopes": ["ZohoAssist.sessionapi.ALL"], + "cc_scopes": ["ZohoAssist.sessionapi.ALL"], "extra_params": { "access_type": "offline" } @@ -197,6 +221,8 @@ } }, "servicenow": { + "grant_types": ["authorization_code", "client_credentials"], + "req_body_auth": true, "connect_config_template": { "display_name": "ServiceNow", "label": "ServiceNow Instance", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 3acc018c14..304623998d 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.728.1" +version = "1.729.0" dependencies = [ "aho-corasick", "anyhow", @@ -6272,7 +6272,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.728.1" +version = "1.729.0" dependencies = [ "proc-macro2", "quote", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.728.1" +version = "1.729.0" dependencies = [ "convert_case", "serde", @@ -6293,7 +6293,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "lazy_static", @@ -6305,7 +6305,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "serde_json", @@ -6317,7 +6317,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "gosyn", @@ -6329,7 +6329,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "lazy_static", @@ -6341,7 +6341,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "serde_json", @@ -6353,7 +6353,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "nu-parser", @@ -6364,7 +6364,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6375,7 +6375,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6387,7 +6387,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6398,7 +6398,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "async-recursion", @@ -6420,7 +6420,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "serde_json", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "lazy_static", @@ -6446,7 +6446,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "convert_case", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "serde", @@ -6488,7 +6488,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6538,7 +6538,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6570,7 +6570,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "serde", @@ -6581,7 +6581,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.728.1" +version = "1.729.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 686be7d91a..6b43533f5b 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.728.1" +version = "1.729.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi-deref.json b/backend/windmill-api/openapi-deref.json index 0cf2e9aca0..135131af41 100644 --- a/backend/windmill-api/openapi-deref.json +++ b/backend/windmill-api/openapi-deref.json @@ -1,7 +1,7 @@ { "openapi": "3.0.3", "info": { - "version": "1.723.0", + "version": "1.728.0", "title": "Windmill API", "contact": { "name": "Windmill Team", @@ -9700,9 +9700,9 @@ "type": "string", "description": "OAuth client secret for resource-level credentials (client_credentials flow only)" }, - "cc_token_url": { + "cc_instance": { "type": "string", - "description": "OAuth token URL override for resource-level authentication (client_credentials flow only)" + "description": "Instance name for built-in providers whose client-credentials token URL is instance-templated; substituted into the fixed-host registry template server-side (client_credentials flow only). The token URL is never caller-supplied." }, "mcp_server_url": { "type": "string", @@ -9739,7 +9739,7 @@ } } }, - "/oauth/connect_client_credentials/{client}": { + "/w/{workspace}/oauth/connect_client_credentials/{client}": { "post": { "summary": "connect OAuth using client credentials", "operationId": "connectClientCredentials", @@ -9747,6 +9747,9 @@ "oauth" ], "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, { "name": "client", "in": "path", @@ -9773,21 +9776,17 @@ }, "cc_client_id": { "type": "string", - "description": "OAuth client ID for resource-level authentication" + "description": "OAuth client ID. Omit to use the credentials configured on the provider's instance OAuth entry." }, "cc_client_secret": { "type": "string", - "description": "OAuth client secret for resource-level authentication" + "description": "OAuth client secret. Omit to use the credentials configured on the provider's instance OAuth entry." }, - "cc_token_url": { + "cc_instance": { "type": "string", - "description": "OAuth token URL override for resource-level authentication" + "description": "Instance name for built-in providers whose client-credentials token URL is instance-templated; substituted into the fixed-host registry template server-side. The token URL is never caller-supplied." } - }, - "required": [ - "cc_client_id", - "cc_client_secret" - ] + } } } } @@ -10000,7 +9999,23 @@ "schema": { "type": "array", "items": { - "type": "string" + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "supports_client_credentials": { + "type": "boolean" + }, + "has_shared_credentials": { + "type": "boolean" + } + }, + "required": [ + "name", + "supports_client_credentials", + "has_shared_credentials" + ] } } } @@ -10049,6 +10064,10 @@ "items": { "type": "string" } + }, + "client_credentials_configured": { + "type": "boolean", + "description": "The instance OAuth entry carries shared client-credentials, so the connect dialog can skip the bring-your-own form and run the exchange server-side" } } } @@ -12191,10 +12210,18 @@ "type": "string", "description": "Best-effort, read from the draft JSON's `summary` field when the editor shape carries one." }, + "draft_path": { + "type": "string", + "description": "User-typed friendly path from the draft JSON's `draft_path`, when set and different from the storage path (e.g. a never-deployed item parked at `u/{user}/draft_{uuid}`)." + }, "draft_only": { "type": "boolean", "description": "No deployed counterpart exists at this path — the draft is the whole item." }, + "legacy_draft": { + "type": "boolean", + "description": "The listed draft is a legacy workspace-level row (email NULL) predating the per-user drafts migration. Only true when no per-user draft exists at this path." + }, "created_at": { "type": "string", "format": "date-time" @@ -12204,6 +12231,7 @@ "kind", "path", "draft_only", + "legacy_draft", "created_at" ] } @@ -12316,6 +12344,10 @@ "force": { "type": "boolean", "description": "Skip the conflict check and overwrite the server copy." + }, + "legacy": { + "type": "boolean", + "description": "Delete-only. Target the legacy workspace-level row (email NULL) instead of the current user's row. Used to discard a legacy draft from the review page." } } } @@ -12359,9 +12391,10 @@ "description": "Creates a new script when the path does not already exist.\nCreates a new version of an existing script when called with the same path and the current `parent_hash`.\n", "operationId": "createScript", "x-mcp-tool": true, - "x-mcp-instructions": "To create a script, specify the path (e.g., 'f/my_folder/my_script'), the content (source code), and the language. For TypeScript, use 'bun' unless deno-specific APIs are needed.", + "x-mcp-instructions": "To create a NEW script, specify the path (e.g., 'f/my_folder/my_script'), the content (source code), and the language, and leave parent_hash unset. For TypeScript, use 'bun' unless deno-specific APIs are needed. To UPDATE an existing script, do NOT delete and recreate it: call this tool with the same path and set parent_hash to the script's current hash, which you can read from the `hash` field returned by getScriptByPath. This creates a new version while preserving the script's history.", "x-mcp-tool-include-fields": [ "path", + "parent_hash", "content", "language", "summary", @@ -33609,8 +33642,16 @@ "type": "string", "nullable": true, "description": "Workspace username of the draft owner. `null` represents\nthe legacy workspace-level (NULL-email) row. Emails never\nleave the server.\n" + }, + "draft_saved_at": { + "type": "string", + "format": "date-time", + "description": "When this user's draft was last saved (`draft.created_at`),\nsurfaced in the fork modal as \"Last updated\".\n" } - } + }, + "required": [ + "draft_saved_at" + ] } } }, diff --git a/backend/windmill-api/openapi-deref.yaml b/backend/windmill-api/openapi-deref.yaml index 22792b38e3..e45131d81b 100644 --- a/backend/windmill-api/openapi-deref.yaml +++ b/backend/windmill-api/openapi-deref.yaml @@ -1,6 +1,6 @@ openapi: 3.0.3 info: - version: 1.723.0 + version: 1.728.0 title: Windmill API contact: name: Windmill Team @@ -7696,6 +7696,16 @@ paths: Emails never leave the server. + draft_saved_at: + type: string + format: date-time + description: > + When this user's draft was last saved + (`draft.created_at`), + + surfaced in the fork modal as "Last updated". + required: + - draft_saved_at required: &ref_79 - is_draft /w/{workspace}/variables/get_value/{path}: @@ -8900,11 +8910,14 @@ paths: description: >- OAuth client secret for resource-level credentials (client_credentials flow only) - cc_token_url: + cc_instance: type: string description: >- - OAuth token URL override for resource-level authentication - (client_credentials flow only) + Instance name for built-in providers whose + client-credentials token URL is instance-templated; + substituted into the fixed-host registry template + server-side (client_credentials flow only). The token URL is + never caller-supplied. mcp_server_url: type: string description: MCP server URL for MCP OAuth token refresh @@ -8926,13 +8939,17 @@ paths: text/plain: schema: type: string - /oauth/connect_client_credentials/{client}: + /w/{workspace}/oauth/connect_client_credentials/{client}: post: summary: connect OAuth using client credentials operationId: connectClientCredentials tags: - oauth parameters: + - name: workspace + in: path + required: true + schema: *ref_4 - name: client in: path description: OAuth client name @@ -8953,16 +8970,21 @@ paths: type: string cc_client_id: type: string - description: OAuth client ID for resource-level authentication + description: >- + OAuth client ID. Omit to use the credentials configured on + the provider's instance OAuth entry. cc_client_secret: type: string - description: OAuth client secret for resource-level authentication - cc_token_url: + description: >- + OAuth client secret. Omit to use the credentials configured + on the provider's instance OAuth entry. + cc_instance: type: string - description: OAuth token URL override for resource-level authentication - required: - - cc_client_id - - cc_client_secret + description: >- + Instance name for built-in providers whose + client-credentials token URL is instance-templated; + substituted into the fixed-host registry template + server-side. The token URL is never caller-supplied. responses: '200': description: OAuth token response @@ -9113,7 +9135,18 @@ paths: schema: type: array items: - type: string + type: object + properties: + name: + type: string + supports_client_credentials: + type: boolean + has_shared_credentials: + type: boolean + required: + - name + - supports_client_credentials + - has_shared_credentials /oauth/get_connect/{client}: get: summary: get oauth connect @@ -9145,6 +9178,12 @@ paths: type: array items: type: string + client_credentials_configured: + type: boolean + description: >- + The instance OAuth entry carries shared + client-credentials, so the connect dialog can skip the + bring-your-own form and run the exchange server-side /teams/activities: post: summary: send update to Microsoft Teams activity @@ -12719,11 +12758,24 @@ paths: description: >- Best-effort, read from the draft JSON's `summary` field when the editor shape carries one. + draft_path: + type: string + description: >- + User-typed friendly path from the draft JSON's + `draft_path`, when set and different from the storage + path (e.g. a never-deployed item parked at + `u/{user}/draft_{uuid}`). draft_only: type: boolean description: >- No deployed counterpart exists at this path — the draft is the whole item. + legacy_draft: + type: boolean + description: >- + The listed draft is a legacy workspace-level row (email + NULL) predating the per-user drafts migration. Only true + when no per-user draft exists at this path. created_at: type: string format: date-time @@ -12731,6 +12783,7 @@ paths: - kind - path - draft_only + - legacy_draft - created_at /w/{workspace}/drafts/get/{kind}/{path}: get: @@ -12832,6 +12885,12 @@ paths: force: type: boolean description: Skip the conflict check and overwrite the server copy. + legacy: + type: boolean + description: >- + Delete-only. Target the legacy workspace-level row (email + NULL) instead of the current user's row. Used to discard a + legacy draft from the review page. responses: '200': description: save result @@ -12862,11 +12921,17 @@ paths: operationId: createScript x-mcp-tool: true x-mcp-instructions: >- - To create a script, specify the path (e.g., 'f/my_folder/my_script'), - the content (source code), and the language. For TypeScript, use 'bun' - unless deno-specific APIs are needed. + To create a NEW script, specify the path (e.g., + 'f/my_folder/my_script'), the content (source code), and the language, + and leave parent_hash unset. For TypeScript, use 'bun' unless + deno-specific APIs are needed. To UPDATE an existing script, do NOT + delete and recreate it: call this tool with the same path and set + parent_hash to the script's current hash, which you can read from the + `hash` field returned by getScriptByPath. This creates a new version + while preserving the script's history. x-mcp-tool-include-fields: - path + - parent_hash - content - language - summary diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 1f59692b52..65051a4157 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.728.1 + version: 1.729.0 title: Windmill API contact: @@ -6288,9 +6288,9 @@ paths: cc_client_secret: type: string description: "OAuth client secret for resource-level credentials (client_credentials flow only)" - cc_token_url: + cc_instance: type: string - description: "OAuth token URL override for resource-level authentication (client_credentials flow only)" + description: "Instance name for built-in providers whose client-credentials token URL is instance-templated; substituted into the fixed-host registry template server-side (client_credentials flow only). The token URL is never caller-supplied." mcp_server_url: type: string description: "MCP server URL for MCP OAuth token refresh" @@ -6311,13 +6311,14 @@ paths: schema: type: string - /oauth/connect_client_credentials/{client}: + /w/{workspace}/oauth/connect_client_credentials/{client}: post: summary: connect OAuth using client credentials operationId: connectClientCredentials tags: - oauth parameters: + - $ref: "#/components/parameters/WorkspaceId" - name: client in: path description: OAuth client name @@ -6338,16 +6339,13 @@ paths: type: string cc_client_id: type: string - description: "OAuth client ID for resource-level authentication" + description: "OAuth client ID. Omit to use the credentials configured on the provider's instance OAuth entry." cc_client_secret: type: string - description: "OAuth client secret for resource-level authentication" - cc_token_url: + description: "OAuth client secret. Omit to use the credentials configured on the provider's instance OAuth entry." + cc_instance: type: string - description: "OAuth token URL override for resource-level authentication" - required: - - cc_client_id - - cc_client_secret + description: "Instance name for built-in providers whose client-credentials token URL is instance-templated; substituted into the fixed-host registry template server-side. The token URL is never caller-supplied." responses: "200": description: OAuth token response @@ -6481,7 +6479,18 @@ paths: schema: type: array items: - type: string + type: object + properties: + name: + type: string + supports_client_credentials: + type: boolean + has_shared_credentials: + type: boolean + required: + - name + - supports_client_credentials + - has_shared_credentials /oauth/get_connect/{client}: get: @@ -6514,6 +6523,9 @@ paths: type: array items: type: string + client_credentials_configured: + type: boolean + description: "The instance OAuth entry carries shared client-credentials, so the connect dialog can skip the bring-your-own form and run the exchange server-side" /teams/activities: post: @@ -7898,6 +7910,11 @@ paths: - draft parameters: - $ref: "#/components/parameters/WorkspaceId" + - name: all_users + in: query + description: List every draft in the workspace (all users), not just the current user's own + legacy rows. Other users' rows come back with `mine=false` (view-only). + schema: + type: boolean responses: "200": description: the user's drafts @@ -7927,7 +7944,25 @@ paths: created_at: type: string format: date-time - required: [kind, path, draft_only, legacy_draft, created_at] + can_write: + type: boolean + description: Whether the current user may deploy/discard this draft (same check the deploy/discard endpoints enforce). + mine: + type: boolean + description: The row belongs to the current user (own draft or the legacy no-owner row) and is therefore actionable. Always true in the default listing; with `all_users=true`, other users' rows are false (view-only). + draft_users: + description: | + Draft authors at this (path, kind) — the legacy NULL-email row surfaced as a null username. + Populated only for the shared full-page-editor kinds (script/flow/app/raw_app); omitted for + drawer kinds, which keep their drafts private. Feeds the Draft badge's owner-avatar circles. + type: array + items: + type: object + properties: + username: + type: string + nullable: true + required: [kind, path, draft_only, legacy_draft, created_at, can_write, mine] /w/{workspace}/drafts/get/{kind}/{path}: get: @@ -8028,6 +8063,10 @@ paths: legacy: type: boolean description: Delete-only. Target the legacy workspace-level row (email NULL) instead of the current user's row. Used to discard a legacy draft from the review page. + created_at: + type: string + format: date-time + description: Upsert-only override for the stored creation timestamp. Normal saves omit it (stamped server-side); the localStorage→DB migration passes the draft's original write time so migrated drafts keep their age. responses: "200": description: save result @@ -8044,6 +8083,41 @@ paths: format: date-time required: [status, current_timestamp] + /w/{workspace}/drafts/migrate_legacy/{kind}/{path}: + post: + summary: resolve a legacy (workspace-level) draft (admin only) + description: Delete a legacy draft (email NULL) or assign it to the authed admin as a per-user draft. Workspace admins / superadmins only. + operationId: migrateLegacyDraft + tags: + - draft + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: kind + in: path + required: true + schema: + $ref: "#/components/schemas/UserDraftItemKind" + - $ref: "#/components/parameters/ScriptPath" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + action: + type: string + enum: [delete, assign_to_self] + description: delete the legacy draft, or take ownership of it. + required: [action] + responses: + "200": + description: migration result + content: + text/plain: + schema: + type: string + /w/{workspace}/scripts/create: post: summary: create script diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index 2d324af43b..02c169f59e 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -9,7 +9,7 @@ use crate::db::{ApiAuthed, DB}; use axum::{ - extract::{Extension, Path}, + extract::{Extension, Path, Query}, routing::{get, post}, Json, Router, }; @@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize}; use windmill_common::{ db::UserDB, error::{Error, Result}, - user_drafts::{UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX}, + user_drafts::{DraftUserRef, UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX}, variables::{build_crypt, encrypt}, }; @@ -27,6 +27,7 @@ pub fn workspaced_service() -> Router { .route("/get/{kind}/{*path}", get(get_draft_for_user)) .route("/get_own/{kind}/{*path}", get(get_own_draft)) .route("/update/{kind}/{*path}", post(update_draft)) + .route("/migrate_legacy/{kind}/{*path}", post(migrate_legacy_draft)) } #[derive(Serialize, sqlx::FromRow)] @@ -51,6 +52,30 @@ pub struct DraftListItem { /// row exists at this (path, kind) — the DISTINCT ON prefers an owned row. pub legacy_draft: bool, pub created_at: chrono::DateTime, + /// All draft authors at this `(path, kind)`, for the shared full-page-editor + /// kinds (script/flow/app/raw_app) only — feeds the home-page-style owner + /// circles on the review page. `None` for drawer kinds, which keep their + /// drafts private. + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_users: Option>>, + /// Whether the authed user may deploy/discard this draft — the same check + /// the deploy/discard endpoints enforce. Computed per row after the query, + /// so it defaults to `false` when read from the row. + #[sqlx(default)] + pub can_write: bool, + /// The listed row belongs to the authed user (own draft or the legacy + /// no-owner row) and is therefore actionable by them. Always `true` in the + /// default (own-drafts) listing; only meaningful with `all_users=true`, + /// where other users' rows surface as `false` (view-only — you can't deploy + /// someone else's draft). + pub mine: bool, +} + +#[derive(Deserialize)] +pub struct ListDraftsQuery { + /// List every draft in the workspace (all users), not just the authed + /// user's own + legacy rows. Other users' rows come back with `mine=false`. + pub all_users: Option, } /// Every draft the authed user has in this workspace, across all kinds — the @@ -60,7 +85,9 @@ pub struct DraftListItem { async fn list_drafts( authed: ApiAuthed, Extension(db): Extension, + Extension(user_db): Extension, Path(w_id): Path, + Query(query): Query, ) -> Result>> { // Operators have no drafts of their own (they can't write any, see // `require_can_write_path`), so this list is always empty for them. They @@ -68,20 +95,58 @@ async fn list_drafts( if authed.is_operator { return Ok(Json(vec![])); } - let rows = sqlx::query_as::<_, DraftListItem>(&list_drafts_query()) + let all_users = query.all_users.unwrap_or(false); + let rows = sqlx::query_as::<_, DraftListItem>(&list_drafts_query(all_users)) .bind(&w_id) .bind(&authed.email) .fetch_all(&db) .await?; - Ok(Json(rows)) + // Per-row permission gating: + // - own drafts (incl. legacy no-owner rows, `mine = true`): the actionable + // gate is write permission — run the exact check deploy/discard enforce so + // the UI never offers an action that would 403. + // - other users' drafts (only present with `all_users`, `mine = false`): the + // UI never lets you act on them (`isSelectable` requires `mine`), so skip + // the write probe (`can_write = false`) and instead require READ access — + // otherwise the broadened listing would disclose the path/summary/authors + // of items the caller can't see. Unreadable rows are dropped, mirroring the + // `require_can_read_path` gate on `/drafts/get`. + let mut out = Vec::with_capacity(rows.len()); + for mut row in rows { + if row.mine { + row.can_write = + match require_can_write_path(&authed, &db, &user_db, &w_id, row.kind, &row.path) + .await + { + Ok(()) => true, + Err(Error::NotAuthorized(_)) => false, + Err(e) => return Err(e), + }; + out.push(row); + } else { + // `require_can_read_path` denies with `NotFound` (it hides existence) + // and, for some paths, `NotAuthorized` — both mean "not visible to the + // caller", so drop the row. Any other error is a real failure. + match require_can_read_path(&authed, &user_db, &w_id, row.kind, &row.path).await { + Ok(()) => { + row.can_write = false; + out.push(row); + } + Err(Error::NotFound(_)) | Err(Error::NotAuthorized(_)) => {} + Err(e) => return Err(e), + } + } + } + Ok(Json(out)) } /// Build the `list_drafts` SQL, generating the `draft_only` CASE from /// `deployed_table()` (shared single source — can't drift from the access /// check). Table names come from the closed enum, never user input. Kinds /// with no path-keyed table get no arm and fall to `ELSE true`. -/// `$1` = workspace_id, `$2` = email. -fn list_drafts_query() -> String { +/// `$1` = workspace_id, `$2` = email. With `all_users` the owner filter is +/// dropped so every workspace draft is listed (others' rows get `mine=false`). +fn list_drafts_query(all_users: bool) -> String { let mut case = String::from("CASE d.typ::text\n"); for kind in UserDraftItemKind::ALL { let Some(table) = kind.deployed_table() else { @@ -102,15 +167,35 @@ fn list_drafts_query() -> String { )); } case.push_str(" ELSE true\nEND"); - // `(d.email = $2 OR d.email IS NULL)` lists the user's own drafts AND the - // legacy NULL-email rows; `DISTINCT ON (d.path, d.typ)` with `email IS NULL` - // last collapses a (path, kind) that has both to the owned row. + // Owner circles, mirroring the home-page list subquery (see apps.rs): every + // draft author at this (path, kind), legacy NULL-email row surfaced as a + // null username. Restricted to the shared full-page-editor kinds — drawer + // kinds keep their drafts private, so we never reveal their authors. + let draft_users = r#"CASE WHEN d.typ::text IN ('script', 'flow', 'app', 'raw_app') THEN ( + SELECT json_agg(json_build_object('username', COALESCE(u.username, CASE WHEN du.workspace_id = 'admins' THEN du.email END)) + ORDER BY COALESCE(u.username, CASE WHEN du.workspace_id = 'admins' THEN du.email END) NULLS LAST) + FROM draft du + LEFT JOIN usr u ON u.workspace_id = du.workspace_id AND u.email = du.email + WHERE du.workspace_id = d.workspace_id AND du.path = d.path AND du.typ = d.typ + ) ELSE NULL END"#; + // Default lists the user's own drafts AND the legacy NULL-email rows; with + // `all_users` the filter is dropped to list every workspace draft. + let owner_filter = if all_users { + "" + } else { + " AND (d.email = $2 OR d.email IS NULL)" + }; + // `DISTINCT ON (d.path, d.typ)` keeps one row per item; the ORDER BY + // priority below picks the user's own row first, then the legacy NULL row, + // then (only with `all_users`) another user's row. `mine`/`legacy_draft` + // describe that kept row. format!( r#"SELECT DISTINCT ON (d.path, d.typ) d.path, d.typ AS kind, d.created_at, d.value ->> 'summary' AS summary, + {draft_users} AS draft_users, -- Friendly typed path, by kind (mirrors the home-page list -- endpoints): scripts bind the Path widget to `script.path`, -- so it round-trips through the draft JSON's own `path`; @@ -125,10 +210,12 @@ fn list_drafts_query() -> String { d.path ) AS draft_path, (d.email IS NULL) AS legacy_draft, + (d.email = $2 OR d.email IS NULL) AS mine, {case} AS draft_only FROM draft d - WHERE d.workspace_id = $1 AND (d.email = $2 OR d.email IS NULL) - ORDER BY d.path, d.typ, (d.email IS NULL)"# + WHERE d.workspace_id = $1{owner_filter} + ORDER BY d.path, d.typ, + CASE WHEN d.email = $2 THEN 0 WHEN d.email IS NULL THEN 1 ELSE 2 END"# ) } @@ -153,6 +240,12 @@ pub struct SaveDraftRequest { /// the email-scoped delete otherwise can't reach. #[serde(default)] pub legacy: bool, + /// Upsert-only override for the stored `created_at`. Normal saves omit it + /// and the row is stamped `now()`; the localStorage→DB migration passes the + /// draft's original write time (or epoch 0 when unknown) so migrated drafts + /// keep their age instead of all resurfacing to the top as freshly created. + #[serde(default)] + pub created_at: Option>, } #[derive(Serialize, Debug)] @@ -195,11 +288,13 @@ async fn update_draft( }; // Upsert. The conflict check rides on the DO UPDATE WHERE clause — // when the row is newer than `last_sync`, RETURNING yields nothing. + // `created_at` defaults to `now()` but the migration overrides it ($8) + // so a migrated draft keeps its original age instead of jumping to top. sqlx::query_scalar!( r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at) - VALUES ($1, $2, $3, $4, $5::text::json, now()) + VALUES ($1, $2, $3, $4, $5::text::json, COALESCE($8::timestamptz, now())) ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL - DO UPDATE SET value = EXCLUDED.value, created_at = now() + DO UPDATE SET value = EXCLUDED.value, created_at = EXCLUDED.created_at WHERE $7::bool = true OR $6::timestamptz IS NULL OR draft.created_at <= $6::timestamptz @@ -211,6 +306,7 @@ async fn update_draft( serialized, req.last_sync, req.force, + req.created_at, ) .fetch_optional(&db) .await? @@ -282,6 +378,81 @@ async fn update_draft( } } +#[derive(Deserialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum MigrateLegacyDraftAction { + /// Discard the legacy row entirely. + Delete, + /// Move the legacy row's content onto the authed admin's own row, then + /// drop the legacy row — so it becomes a normal per-user draft. + AssignToSelf, +} + +#[derive(Deserialize, Debug)] +pub struct MigrateLegacyDraftRequest { + pub action: MigrateLegacyDraftAction, +} + +/// Resolve a LEGACY (workspace-level, `email IS NULL`) draft. These predate the +/// per-user drafts migration and have no owner, so only workspace admins (and +/// superadmins, which carry `is_admin` in a workspace) may delete one or claim +/// it as their own. +async fn migrate_legacy_draft( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>, + Json(req): Json, +) -> Result { + if !authed.is_admin { + return Err(Error::NotAuthorized( + "only workspace admins can migrate legacy drafts".to_string(), + )); + } + let path = path.to_path(); + match req.action { + MigrateLegacyDraftAction::Delete => { + sqlx::query!( + r#"DELETE FROM draft + WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL"#, + &w_id, + path, + kind as UserDraftItemKind, + ) + .execute(&db) + .await?; + Ok(format!("Deleted legacy draft at {path}")) + } + MigrateLegacyDraftAction::AssignToSelf => { + // Take ownership: move the legacy value onto the admin's own row + // (replacing any existing own draft) and drop the legacy row, in one + // statement. `ON CONFLICT` matches the partial unique index that + // covers `email IS NOT NULL`. + let moved = sqlx::query_scalar!( + r#"WITH legacy AS ( + DELETE FROM draft + WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL + RETURNING value + ) + INSERT INTO draft (workspace_id, email, path, typ, value, created_at) + SELECT $1, $4, $2, $3, value, now() FROM legacy + ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL + DO UPDATE SET value = EXCLUDED.value, created_at = now() + RETURNING 1 as "one!""#, + &w_id, + path, + kind as UserDraftItemKind, + &authed.email, + ) + .fetch_optional(&db) + .await?; + if moved.is_none() { + return Err(Error::NotFound(format!("no legacy draft at {path}"))); + } + Ok(format!("Assigned legacy draft at {path} to you")) + } + } +} + /// For variable-kind drafts with `variable.is_secret == true`, encrypt /// `variable.value` with the workspace crypt key and mark it /// `$encrypted:` so the secret never persists in plaintext at rest. diff --git a/backend/windmill-common/src/query_builders.rs b/backend/windmill-common/src/query_builders.rs index f077687d6f..2c168cd433 100644 --- a/backend/windmill-common/src/query_builders.rs +++ b/backend/windmill-common/src/query_builders.rs @@ -1739,7 +1739,7 @@ struct PrimaryKeyConstraintPayload { fn db_supports_schemas(db_type: DbType) -> bool { matches!( db_type, - DbType::Postgresql | DbType::Snowflake | DbType::Bigquery + DbType::Postgresql | DbType::Snowflake | DbType::Bigquery | DbType::Duckdb ) } @@ -2410,8 +2410,15 @@ fn make_load_table_metadata_query( ) -> Result { match db_type { DbType::Duckdb => { - // For ducklake, the ducklake ATTACH is handled by the ducklake wrapper. - let mut q = String::from( + // For ducklake, the ducklake ATTACH is handled by the ducklake wrapper, so the + // ducklake catalog is the current database. information_schema spans every attached + // catalog, so we always scope to current_database() to stay within the ducklake. + let extra_col = if table.is_none() { + ",\n TABLE_SCHEMA as schema_name" + } else { + "" + }; + let mut q = format!( "SELECT COLUMN_NAME as field, DATA_TYPE as DataType, @@ -2420,12 +2427,20 @@ fn make_load_table_metadata_query( false as IsIdentity, CASE WHEN IS_NULLABLE = true THEN 'YES' ELSE 'NO' END as IsNullable, false as IsEnum, - TABLE_NAME as table_name + TABLE_NAME as table_name{} FROM information_schema.columns c -WHERE table_schema = current_schema()", +WHERE table_catalog = current_database()", + extra_col ); if let Some(t) = table { - q.push_str(&format!(" AND TABLE_NAME = '{}'", escape_sql_literal(t))); + let parts: Vec<&str> = t.split('.').collect(); + let tname = parts[parts.len() - 1]; + let schema = if parts.len() > 1 { parts[0] } else { "main" }; + q.push_str(&format!( + " AND TABLE_NAME = '{}' AND TABLE_SCHEMA = '{}'", + escape_sql_literal(tname), + escape_sql_literal(schema) + )); } Ok(q) } @@ -3722,9 +3737,10 @@ mod tests { table_ref("users", Some("myschema"), DbType::Mysql), "`users`" ); + // DuckDB (ducklake) supports schemas assert_eq!( table_ref("users", Some("myschema"), DbType::Duckdb), - r#""users""# + r#""myschema"."users""# ); } @@ -3856,6 +3872,13 @@ mod tests { assert!(sql.contains("DROP TABLE \"users\";")); } + #[test] + fn test_expand_drop_table_ducklake_with_schema() { + let marker = r#"-- WM_INTERNAL_DB_DROP_TABLE {"table":"events","schema":"analytics","ducklake":"my_lake"}"#; + let sql = expand_code(marker, &ScriptLang::DuckDb); + assert!(sql.contains("DROP TABLE \"analytics\".\"events\";")); + } + // ----------------------------------------------------------------------- // CREATE SCHEMA / DROP SCHEMA // ----------------------------------------------------------------------- @@ -4355,7 +4378,27 @@ mod tests { let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {"table":"users","ducklake":"lake"}"#; let sql = expand_code(marker, &ScriptLang::DuckDb); assert!(sql.starts_with("ATTACH 'ducklake://lake' AS dl;USE dl;\n")); - assert!(sql.contains("TABLE_NAME = 'users'")); + assert!(sql.contains("table_catalog = current_database()")); + // Unqualified table defaults to the "main" schema. + assert!(sql.contains("TABLE_NAME = 'users' AND TABLE_SCHEMA = 'main'")); + } + + #[test] + fn test_expand_load_table_metadata_ducklake_qualified_schema() { + let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {"table":"analytics.events","ducklake":"lake"}"#; + let sql = expand_code(marker, &ScriptLang::DuckDb); + assert!(sql.contains("TABLE_NAME = 'events' AND TABLE_SCHEMA = 'analytics'")); + } + + #[test] + fn test_expand_load_table_metadata_ducklake_all_tables() { + let marker = r#"-- WM_INTERNAL_DB_LOAD_TABLE_METADATA {"ducklake":"lake"}"#; + let sql = expand_code(marker, &ScriptLang::DuckDb); + assert!(sql.starts_with("ATTACH 'ducklake://lake' AS dl;USE dl;\n")); + // All-tables listing scopes to the ducklake catalog and exposes the schema per table. + assert!(sql.contains("table_catalog = current_database()")); + assert!(sql.contains("TABLE_SCHEMA as schema_name")); + assert!(!sql.contains("TABLE_NAME = '")); } // ----------------------------------------------------------------------- diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index e719891a69..3d4402decb 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -165,7 +165,7 @@ pub enum ObjectType { WorkspaceDependencies, } -pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28261/sync-script-to-git-repo-windmill"; +pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28719/sync-script-to-git-repo-windmill"; /// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a /// fork of another workspace. diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index 252f7ace28..bf1ea659aa 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -67,6 +67,13 @@ pub struct ClientWithScopes { pub allowed_domains: Option>, pub userinfo_url: Option, pub grant_types: Vec, + /// Resolved token endpoint, exposed so the connect dialog can prefill and + /// persist it on client-credentials accounts. + pub token_url: String, + /// Whether the instance entry carries shared credentials (non-empty id + + /// secret). Providers without them are bring-your-own only — the connect + /// dialog lists them under "Others", not "Instance-configured". + pub has_shared_credentials: bool, } /// Map of OAuth client names to their configurations @@ -81,6 +88,13 @@ pub struct OAuthConfig { pub token_url: String, pub userinfo_url: Option, pub scopes: Option>, + /// Default scopes for the client-credentials (2-legged) flow. These differ + /// from the authorization-code `scopes` for most providers (member/consent + /// scopes are invalid in a 2-legged token request), so CC never defaults to + /// `scopes`. Absent means no default scope — the caller supplies any + /// provider-specific scopes themselves. + #[serde(skip_serializing_if = "Option::is_none")] + pub cc_scopes: Option>, pub extra_params: Option>, pub extra_params_callback: Option>, pub req_body_auth: Option, @@ -91,10 +105,12 @@ pub struct OAuthConfig { /// entry, `build_oauth_clients` registers a second client under that key. #[serde(skip_serializing_if = "Option::is_none")] pub sandbox: Option, - /// Frontend-only metadata for per-instance OAuth providers (Snowflake, - /// ServiceNow, …) whose authorize/token URLs are derived from an - /// admin-entered instance name. Ignored by the backend, which only ever - /// sees the resulting concrete `connect_config`. + /// Metadata for per-instance OAuth providers (Snowflake, ServiceNow, Coupa, + /// …) whose authorize/token URLs carry an `{instance}` placeholder filled + /// from an instance name. The instance-settings UI uses it to build the + /// per-client `connect_config` for the authorization-code flow; the + /// client-credentials flow reads its `token_url`/`strip_suffix`/`label` + /// directly to host-pin the exchange. #[serde(skip_serializing_if = "Option::is_none")] pub connect_config_template: Option, } @@ -111,11 +127,13 @@ pub struct OAuthSandboxOverride { pub userinfo_url: Option, } -/// Frontend metadata for a per-instance OAuth provider. The instance-settings -/// UI renders one generic instance-name input and substitutes `{instance}` into +/// Metadata for a per-instance OAuth provider. The instance-settings UI renders +/// one generic instance-name input and substitutes `{instance}` into /// `auth_url`/`token_url` to build the per-client `connect_config`. Adding a new /// per-instance provider needs only a registry entry carrying this template — -/// no frontend code change. The backend never reads it. +/// no frontend code change. The client-credentials flow additionally reads +/// `token_url`, `strip_suffix`, and `label` from it server-side (see +/// `resolve_cc_token_url_input`) to host-pin the token exchange. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ConnectConfigTemplate { /// Properly-cased provider name for the settings dropdown (e.g. "ServiceNow"); @@ -126,7 +144,10 @@ pub struct ConnectConfigTemplate { pub placeholder: String, #[serde(skip_serializing_if = "Option::is_none")] pub help_url: Option, - pub auth_url: String, + /// Authorize endpoint (with `{instance}`). Absent for client-credentials-only + /// providers (e.g. Coupa) that have no browser sign-in flow. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_url: Option, pub token_url: String, #[serde(skip_serializing_if = "Option::is_none")] pub req_body_auth: Option, @@ -237,8 +258,12 @@ fn empty_string() -> String { "".to_string() } +/// Placeholder authorize URL for providers that only support the +/// client-credentials grant (the authorize endpoint is never used by it). +pub const MISSING_AUTH_URL: &str = "https://missing-auth-url"; + fn empty_auth() -> String { - "https://missing-auth-url".to_string() + MISSING_AUTH_URL.to_string() } fn default_grant_types() -> Vec { @@ -348,77 +373,349 @@ pub async fn build_slack_client( Ok(client) } -/// Build OAuth client for client credentials flow with resource-level credentials +/// Build OAuth client for client credentials flow with resource-level credentials. +/// +/// No instance-level entry is required: the provider endpoint config resolves +/// from the instance `oauths` entry when one exists, else from the static +/// registry, else is synthesized from the token URL override alone. Returns the +/// built client together with the resolved [`OAuthConfig`] so callers can reuse +/// its scopes / `extra_params_callback`. pub async fn build_client_credentials_oauth_client( db: &DB, client_name: &str, client_id: &str, client_secret: &str, - cc_token_url_override: Option<&str>, + resolved_token_url: Option<&str>, connect_configs_json: &str, -) -> error::Result<(OClient, OAuthClient)> { +) -> error::Result<(OClient, OAuthConfig)> { use windmill_common::global_settings::{load_value_from_global_settings, OAUTH_SETTING}; let oauths = load_value_from_global_settings(db, OAUTH_SETTING).await?; - let oauths = oauths.unwrap_or_default(); - let oauth_config = oauths - .get(client_name) - .ok_or_else(|| error::Error::BadRequest("OAuth configuration not found".to_string()))?; + let instance_entry: Option = oauths + .as_ref() + .and_then(|o| o.get(client_name)) + .and_then(|v| match serde_json::from_value(v.clone()) { + Ok(entry) => Some(entry), + Err(e) => { + tracing::warn!( + client = %client_name, + "Invalid instance OAuth entry, falling back to static registry: {e}" + ); + None + } + }); - let oauth_client_config: OAuthClient = serde_json::from_value(oauth_config.clone()) - .map_err(|e| error::Error::BadRequest(format!("Invalid OAuth config: {}", e)))?; - - let parse_static_configs = || { - serde_json::from_str::>(connect_configs_json).map_err(|e| { - error::Error::InternalErr(format!("Failed to parse oauth_connect.json: {}", e)) - }) + let resolve_from_registry = |client_name: &str| -> error::Result> { + let static_configs = + serde_json::from_str::>(connect_configs_json).map_err( + |e| error::Error::InternalErr(format!("Failed to parse oauth_connect.json: {}", e)), + )?; + Ok(resolve_registry_config(&static_configs, client_name)) }; - let resolve_from_registry = |client_name: &str| -> error::Result { - let static_configs = parse_static_configs()?; - resolve_registry_config(&static_configs, client_name).ok_or_else(|| { + + // A token URL alone is enough for client credentials: providers that only + // support this grant have no authorize endpoint to configure. + let instance_connect_config = instance_entry + .as_ref() + .and_then(|e| e.connect_config.clone()) + .filter(|c| !c.token_url.is_empty()) + .map(|mut c| { + if c.auth_url.is_empty() { + c.auth_url = empty_auth(); + } + c + }); + + let from_instance = instance_connect_config.is_some(); + let mut connect_config = match instance_connect_config { + Some(config) => config, + None => resolve_from_registry(client_name)?.ok_or_else(|| { error::Error::BadRequest(format!( - "OAuth configuration not found for '{}' in either global settings or static config", + "No token URL available for '{}': not found in instance OAuth settings or static \ + config", client_name )) - }) + })?, }; - let mut connect_config = if let Some(ref config) = oauth_client_config.connect_config { - if !config.auth_url.is_empty() && !config.token_url.is_empty() { - config.clone() - } else { - resolve_from_registry(client_name)? - } - } else { - resolve_from_registry(client_name)? - }; - - if let Some(override_url) = cc_token_url_override { - connect_config.token_url = override_url.to_string(); + // Registry providers default their client-credentials scopes from `cc_scopes`, + // never the authorization-code `scopes` (which several providers reject for a + // 2-legged token request). Instance-configured entries keep their admin-set + // scopes untouched. + if !from_instance { + connect_config.scopes = connect_config.cc_scopes.clone(); } + let caller_supplied_creds = !client_id.is_empty() && !client_secret.is_empty(); + + // Apply the server-resolved concrete token URL. Instance-templated providers + // (e.g. Coupa) carry an empty or `{instance}`-templated token URL in their + // registry config; the resolved value (host-pinned for bring-your-own, + // persisted on the row for refresh) is what completes it. The caller never + // supplies a free-form token URL: this value always comes from + // `resolve_cc_token_url_input` or a previously-resolved persisted URL. + if let Some(url) = resolved_token_url { + connect_config.token_url = url.to_string(); + } + if connect_config.token_url.is_empty() { + return Err(error::Error::BadRequest(format!( + "No token URL configured for '{}'", + client_name + ))); + } + + // Fall back to the instance entry's own credentials when the caller supplies + // none: the shared instance-level client-credentials setup, where an admin + // configures one service-account client for everyone and the secret never + // leaves the server. Only entries that explicitly enable the + // client_credentials grant qualify, so an authorization-code-only client's + // secret is never reused for this flow. + let instance_cc_creds = instance_entry.as_ref().filter(|e| { + e.grant_types.iter().any(|g| g == "client_credentials") + && !e.id.is_empty() + && !e.secret.is_empty() + }); + // All-or-nothing: use the caller's credentials only when both id and secret + // are present, otherwise fall back entirely to the instance entry. Never mix + // a caller-supplied id with the admin secret (or vice versa). + let (resolved_client_id, resolved_client_secret) = if caller_supplied_creds { + (client_id.to_string(), client_secret.to_string()) + } else { + instance_cc_creds + .map(|e| (e.id.clone(), e.secret.clone())) + .unwrap_or_default() + }; + let resource_oauth_client = OAuthClient { - id: client_id.to_string(), - secret: client_secret.to_string(), - allowed_domains: oauth_client_config.allowed_domains.clone(), + id: resolved_client_id, + secret: resolved_client_secret, + allowed_domains: instance_entry + .as_ref() + .and_then(|e| e.allowed_domains.clone()), connect_config: Some(connect_config.clone()), - login_config: oauth_client_config.login_config.clone(), - display_name: oauth_client_config.display_name.clone(), - grant_types: oauth_client_config.grant_types.clone(), - tenant: oauth_client_config.tenant.clone(), + login_config: instance_entry.as_ref().and_then(|e| e.login_config.clone()), + display_name: instance_entry.as_ref().and_then(|e| e.display_name.clone()), + grant_types: instance_entry + .as_ref() + .map(|e| e.grant_types.clone()) + .unwrap_or_else(default_grant_types), + tenant: instance_entry.as_ref().and_then(|e| e.tenant.clone()), }; let base_url = (**BASE_URL.load()).clone(); let (_, client) = build_basic_client( client_name.to_string(), - connect_config, + connect_config.clone(), resource_oauth_client, false, &base_url, None, )?; - Ok((client, oauth_client_config)) + Ok((client, connect_config)) +} + +/// Shared instance-level client-credentials for `client_name`: the `(id, secret, +/// token_url)` from its instance `oauths` entry, but only when that entry both +/// declares the `client_credentials` grant and carries non-empty credentials. +/// Lets the connect flow use one admin-configured service-account client instead +/// of asking each user for their own. +/// +/// # Authorization +/// Returns the admin's shared service-account secret, so callers MUST first +/// verify the caller's authorization to use it (workspace membership plus +/// read-write access — operators and read-only tokens are excluded). This helper +/// performs no authorization itself. +pub async fn resolve_instance_cc_credentials( + db: &DB, + client_name: &str, +) -> error::Result)>> { + use windmill_common::global_settings::{load_value_from_global_settings, OAUTH_SETTING}; + + let oauths = load_value_from_global_settings(db, OAUTH_SETTING).await?; + let entry: Option = oauths + .as_ref() + .and_then(|o| o.get(client_name)) + .and_then(|v| serde_json::from_value(v.clone()).ok()); + + Ok(entry.and_then(|e| { + let cc_grant = e.grant_types.iter().any(|g| g == "client_credentials"); + if cc_grant && !e.id.is_empty() && !e.secret.is_empty() { + // Token URL from the entry's connect_config (built by instance settings + // from the connect_config_template), so the account row is + // self-contained for refresh. + let token_url = e + .connect_config + .as_ref() + .map(|c| c.token_url.clone()) + .filter(|u| !u.is_empty()); + Some((e.id, e.secret, token_url)) + } else { + None + } + })) +} + +/// Resolve the concrete client-credentials token URL for a bring-your-own +/// connection. The caller never supplies a token URL: it always comes from the +/// built-in registry, so the exchange host can never be redirected. +/// +/// Supported only for registry providers. For one whose CC token URL carries an +/// `{instance}` placeholder (Coupa, ServiceNow, …) — declared in its +/// `connect_config_template` — the caller supplies only an instance name, +/// validated as a bare hostname label and substituted into the fixed-host +/// template. A fixed-host registry provider uses its registry token URL directly. +/// A custom resource type (no registry entry) is rejected: there is no known host +/// to send credentials to. +pub fn resolve_cc_token_url_input( + connect_configs_json: &str, + client_name: &str, + caller_instance: Option<&str>, +) -> error::Result { + let Some(cfg) = serde_json::from_str::>(connect_configs_json) + .ok() + .and_then(|m| resolve_registry_config(&m, client_name)) + else { + return Err(error::Error::BadRequest(format!( + "Client credentials with your own credentials are only supported for built-in OAuth \ + providers, not '{client_name}'. Configure shared credentials on the instance OAuth \ + entry instead." + ))); + }; + + // Instance-templated providers carry the `{instance}` token URL (and its + // label/strip_suffix) in `connect_config_template`; fixed-host providers use + // the plain `token_url`. + let tmpl = cfg.connect_config_template.as_ref(); + let template = tmpl + .map(|t| t.token_url.clone()) + .filter(|u| !u.is_empty()) + .or_else(|| Some(cfg.token_url.clone()).filter(|u| !u.is_empty())) + .ok_or_else(|| { + error::Error::BadRequest(format!("No token URL is configured for '{client_name}'")) + })?; + + if !template.contains("{instance}") { + // Fixed-host registry provider: its registry token URL is authoritative. + return Ok(template); + } + + // Structural host-pinning guard: only substitute when `{instance}` is the + // leftmost host label of a fixed-host template (`scheme://{instance}.fixed-host/…`). + // The hostname-label validation below keeps the value clean, but only this + // check guarantees the substituted value can never change the registrable + // domain — so a malformed template (e.g. `https://{instance}/token`) can't turn + // the caller's instance name into a full attacker-controlled host (SSRF / + // credential exfiltration). The template is a code-reviewed registry file, so a + // violation is a programming error. + let placeholder = "{instance}"; + let idx = template.find(placeholder).unwrap(); + let after = &template[idx + placeholder.len()..]; + if !template[..idx].ends_with("://") || !after.starts_with('.') { + return Err(error::Error::InternalErr(format!( + "Invalid instance-templated token URL for '{client_name}': {{instance}} must be the \ + leftmost host label (scheme://{{instance}}.fixed-host/…)" + ))); + } + + let raw = caller_instance + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + error::Error::BadRequest(format!( + "{} is required for {client_name}", + tmpl.map(|t| t.label.as_str()).unwrap_or("An instance name") + )) + })?; + // Strip an optional known host suffix so the user can paste a full host or a + // bare name, then accept only a hostname label — never any character that + // could move the host out of the template's domain. + let value = tmpl + .and_then(|t| t.strip_suffix.as_deref()) + .and_then(|sfx| raw.strip_suffix(sfx)) + .unwrap_or(raw) + .trim_end_matches('.'); + let valid = !value.is_empty() + && !value.starts_with(['-', '.']) + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.'); + if !valid { + return Err(error::Error::BadRequest(format!( + "invalid instance name '{raw}' for {client_name}" + ))); + } + Ok(template.replace("{instance}", value)) +} + +/// Resolve the concrete bring-your-own client-credentials token URL for any +/// provider, never from a caller-supplied URL: +/// - **Built-in registry providers** resolve from the registry via +/// [`resolve_cc_token_url_input`] (host-pinned from the caller's instance name +/// for instance-templated ones). +/// - **Custom providers configured at the instance level** use the admin's +/// `connect_config.token_url`. The caller has no instance template to fill, so +/// an instance name is rejected. +/// +/// This is the single entry point the connect/account-creation handlers should +/// use so both resolve identically. +pub async fn resolve_cc_token_url( + db: &DB, + client_name: &str, + caller_instance: Option<&str>, + connect_configs_json: &str, +) -> error::Result { + use windmill_common::global_settings::{load_value_from_global_settings, OAUTH_SETTING}; + + let supports_cc = + |grant_types: &[String]| grant_types.iter().any(|g| g == "client_credentials"); + + let registry_cfg = serde_json::from_str::>(connect_configs_json) + .ok() + .and_then(|m| resolve_registry_config(&m, client_name)); + if let Some(cfg) = registry_cfg { + // Built-in provider: only honor it for client credentials if it actually + // declares that grant, so an authorization-code-only provider can't be + // driven through the CC API. + if !supports_cc(&cfg.grant_types) { + return Err(error::Error::BadRequest(format!( + "'{client_name}' is not enabled for the client_credentials grant" + ))); + } + return resolve_cc_token_url_input(connect_configs_json, client_name, caller_instance); + } + + // Custom (non-registry) provider: the token URL comes from the admin's + // instance connect_config (an admin-configured, trusted host), never the + // caller. The instance entry must also enable the client-credentials grant. + let entry: Option = load_value_from_global_settings(db, OAUTH_SETTING) + .await? + .as_ref() + .and_then(|o| o.get(client_name)) + .and_then(|v| serde_json::from_value(v.clone()).ok()); + let instance_token_url = entry + .as_ref() + .filter(|e| supports_cc(&e.grant_types)) + .and_then(|e| e.connect_config.clone()) + .map(|c| c.token_url) + .filter(|u| !u.is_empty()); + match instance_token_url { + Some(_) + if caller_instance + .map(|s| !s.trim().is_empty()) + .unwrap_or(false) => + { + Err(error::Error::BadRequest(format!( + "An instance name only applies to built-in instance-templated providers, not \ + '{client_name}'" + ))) + } + Some(url) => Ok(url), + None => Err(error::Error::BadRequest(format!( + "Client credentials with your own credentials require '{client_name}' to be a built-in \ + OAuth provider or an instance entry that enables the client_credentials grant" + ))), + } } /// Exchange authorization code for tokens @@ -462,7 +759,7 @@ pub async fn exchange_token( client: OClient, refresh_token: &str, grant_type: &str, - oauth_client_info: Option<&ClientWithScopes>, + extra_params_callback: Option<&HashMap>, http_client: &reqwest::Client, scopes: Option<&[String]>, ) -> Result { @@ -483,11 +780,9 @@ pub async fn exchange_token( "client_credentials" => { let mut token_request = client.exchange_client_credentials(); - if let Some(oauth_info) = oauth_client_info { - if let Some(extra_params) = oauth_info.extra_params_callback.as_ref() { - for (key, value) in extra_params.iter() { - token_request = token_request.param(key.clone(), value.clone()); - } + if let Some(extra_params) = extra_params_callback { + for (key, value) in extra_params.iter() { + token_request = token_request.param(key.clone(), value.clone()); } } @@ -579,49 +874,78 @@ pub async fn refresh_token_for_account<'c>( http_client: &reqwest::Client, connect_configs_json: &str, ) -> error::Result { - let oauth_client_info = oauth_clients - .connects - .get(&account.client) - .ok_or_else(|| error::Error::BadRequest("invalid client".to_string()))? - .clone(); + // Instance-configured client: required for authorization_code (the refresh + // token exchange uses the instance app's credentials). For client_credentials + // it is resolved inside `build_client_credentials_oauth_client` instead. + let oauth_client_info = oauth_clients.connects.get(&account.client).cloned(); - let mut client = if account.grant_type == "client_credentials" { - match (&account.cc_client_id, &account.cc_client_secret) { - (Some(client_id), Some(client_secret)) => { - let (client, _) = build_client_credentials_oauth_client( - db, - &account.client, - client_id, - client_secret, - account.cc_token_url.as_deref(), - connect_configs_json, - ) - .await?; - client - } - _ => { - return Err(error::Error::BadRequest( - "client_credentials flow requires cc_client_id and cc_client_secret to be stored in account".to_string() - )); - } - } + let is_client_credentials = account.grant_type == "client_credentials"; + + let (mut client, cc_config) = if is_client_credentials { + // Bring-your-own accounts store their own credentials (and resolved token + // URL) on the row. Shared instance accounts store none: passing empty + // credentials makes the builder re-resolve the admin's service-account + // credentials and token URL from the instance entry on every refresh, so a + // rotated or removed shared secret takes effect immediately (mirrors the + // authorization-code model, where the row never holds the app secret). + let (client_id, client_secret) = match (&account.cc_client_id, &account.cc_client_secret) { + (Some(id), Some(secret)) => (id.as_str(), secret.as_str()), + _ => ("", ""), + }; + let (client, config) = build_client_credentials_oauth_client( + db, + &account.client, + client_id, + client_secret, + account.cc_token_url.as_deref(), + connect_configs_json, + ) + .await?; + (client, Some(config)) } else { - oauth_client_info.client.to_owned() + let info = oauth_client_info + .as_ref() + .ok_or_else(|| error::Error::BadRequest("invalid client".to_string()))?; + (info.client.to_owned(), None) }; - // Account-level scopes override instance-level scopes + // Account-level scopes (when stored) override these defaults. Client-credentials + // accounts default to the resolved CC config's scopes (`cc_scopes` for registry + // providers, the admin's instance scopes for custom ones) — never the instance + // client's authorization-code scopes, which are invalid in a 2-legged request. + // Authorization-code accounts default to the instance client's scopes. + let fallback_scopes = if is_client_credentials { + cc_config + .as_ref() + .and_then(|c| c.scopes.clone()) + .unwrap_or_default() + } else { + oauth_client_info + .as_ref() + .map(|i| i.scopes.clone()) + .unwrap_or_default() + }; let effective_scopes = account .scopes .as_deref() .filter(|s| !s.is_empty()) - .unwrap_or(&oauth_client_info.scopes); + .unwrap_or(&fallback_scopes); - if account.grant_type == "client_credentials" { + if is_client_credentials { for scope in effective_scopes.iter() { client.add_scope(scope); } } + let extra_params_callback = oauth_client_info + .as_ref() + .and_then(|i| i.extra_params_callback.clone()) + .or_else(|| { + cc_config + .as_ref() + .and_then(|c| c.extra_params_callback.clone()) + }); + tracing::info!( grant_type = %account.grant_type, client = %account.client, @@ -634,7 +958,7 @@ pub async fn refresh_token_for_account<'c>( client, &account.refresh_token, &account.grant_type, - Some(&oauth_client_info), + extra_params_callback.as_ref(), http_client, Some(effective_scopes), ) @@ -846,6 +1170,7 @@ mod tests { token_url: "https://account.example.com/oauth/token".to_string(), userinfo_url: Some("https://account.example.com/userinfo".to_string()), scopes: Some(vec!["signature".to_string()]), + cc_scopes: None, extra_params: None, extra_params_callback: None, req_body_auth: None, @@ -927,4 +1252,98 @@ mod tests { registry.insert("docusign".to_string(), sample_oauth_config(false)); assert!(resolve_registry_config(®istry, "docusign_sandbox").is_none()); } + + const CC_REGISTRY: &str = r#"{ + "coupa": { + "grant_types": ["client_credentials"], + "connect_config_template": { + "label": "Coupa instance", + "placeholder": "x", + "token_url": "https://{instance}.coupahost.com/oauth2/token", + "strip_suffix": ".coupahost.com" + } + }, + "servicenow": { + "grant_types": ["authorization_code", "client_credentials"], + "connect_config_template": { + "label": "ServiceNow instance", + "placeholder": "dev12345", + "auth_url": "https://{instance}.service-now.com/oauth_auth.do", + "token_url": "https://{instance}.service-now.com/oauth_token.do", + "strip_suffix": ".service-now.com" + } + }, + "visma": { + "auth_url": "https://connect.visma.com/connect/authorize", + "token_url": "https://connect.visma.com/connect/token", + "grant_types": ["authorization_code", "client_credentials"] + }, + "bad_host_tpl": { + "grant_types": ["client_credentials"], + "connect_config_template": { + "label": "x", "placeholder": "x", + "token_url": "https://{instance}/token" + } + }, + "bad_mid_tpl": { + "grant_types": ["client_credentials"], + "connect_config_template": { + "label": "x", "placeholder": "x", + "token_url": "https://api.{instance}.evil.com/token" + } + } + }"#; + + #[test] + fn cc_token_url_templated_substitutes_instance() { + let url = resolve_cc_token_url_input(CC_REGISTRY, "coupa", Some("acme")).unwrap(); + assert_eq!(url, "https://acme.coupahost.com/oauth2/token"); + } + + #[test] + fn cc_token_url_templated_from_connect_config_template() { + // ServiceNow's CC token URL comes from its connect_config_template. + let url = resolve_cc_token_url_input(CC_REGISTRY, "servicenow", Some("dev99")).unwrap(); + assert_eq!(url, "https://dev99.service-now.com/oauth_token.do"); + } + + #[test] + fn cc_token_url_strips_known_host_suffix() { + let url = + resolve_cc_token_url_input(CC_REGISTRY, "coupa", Some("acme.coupahost.com")).unwrap(); + assert_eq!(url, "https://acme.coupahost.com/oauth2/token"); + } + + #[test] + fn cc_token_url_rejects_instance_that_escapes_the_host() { + // A '/' (or any non-hostname char) must not let the caller move the host + // out of the template's domain. + assert!(resolve_cc_token_url_input(CC_REGISTRY, "coupa", Some("evil.com/oauth")).is_err()); + assert!(resolve_cc_token_url_input(CC_REGISTRY, "coupa", Some("a@b")).is_err()); + } + + #[test] + fn cc_token_url_requires_instance_when_templated() { + assert!(resolve_cc_token_url_input(CC_REGISTRY, "coupa", None).is_err()); + } + + #[test] + fn cc_token_url_fixed_host_uses_registry_url() { + let url = resolve_cc_token_url_input(CC_REGISTRY, "visma", None).unwrap(); + assert_eq!(url, "https://connect.visma.com/connect/token"); + } + + #[test] + fn cc_token_url_rejects_custom_provider() { + // No registry entry: bring-your-own client credentials are not allowed. + assert!(resolve_cc_token_url_input(CC_REGISTRY, "my_custom_thing", Some("acme")).is_err()); + } + + #[test] + fn cc_token_url_rejects_template_not_in_subdomain_position() { + // `{instance}` must be the leftmost host label of a fixed-host template, so + // a malformed template can't let the instance value control the host. + assert!(resolve_cc_token_url_input(CC_REGISTRY, "bad_host_tpl", Some("evil.com")).is_err()); + assert!(resolve_cc_token_url_input(CC_REGISTRY, "bad_mid_tpl", Some("evil")).is_err()); + } } diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 742f715e5a..ac6780ef14 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.728.1"; +export const VERSION = "v1.729.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/commands/generate-metadata/generate-metadata.ts b/cli/src/commands/generate-metadata/generate-metadata.ts index 95a0fc3b22..8517ac0912 100644 --- a/cli/src/commands/generate-metadata/generate-metadata.ts +++ b/cli/src/commands/generate-metadata/generate-metadata.ts @@ -799,7 +799,7 @@ async function rehashCommand( } const command = new Command() - .description("Generate metadata (locks, schemas) for all scripts, flows, and apps") + .description("Regenerate stale local locks and script schemas and refresh wmill-lock.yaml content hashes (scripts, flows, apps). Writes local files only, not a deploy. Run it after edits that add or remove imports or change a script's arguments, so the lock, the auto-generated UI schema, and wmill-lock.yaml stay in sync.") .arguments("[folder:string]") .option("--yes", "Skip confirmation prompt") .option("--dry-run", "Show what would be updated without making changes") @@ -823,9 +823,7 @@ const command = new Command() "rehash", new Command() .description( - "Trust on-disk content; rewrite wmill-lock.yaml hashes without backend " + - "trips or yaml/lock rewrites. Useful for bootstrapping missing lockfile " + - "entries or recovering from older-CLI hash drift." + "Refresh wmill-lock.yaml content hashes from the on-disk .lock and .script.yaml without re-resolving dependencies or hitting the backend. Use when those files are already correct and only the hashes need updating: bootstrapping missing entries or recovering from hash drift." ) .arguments("[folder:string]") .option("--skip-scripts", "Skip processing scripts") diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 0807ef50e0..be07eed833 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.728.1"; +export const VERSION = "1.729.0"; diff --git a/cli/src/guidance/core.ts b/cli/src/guidance/core.ts index 25890e1923..cd9ba686ef 100644 --- a/cli/src/guidance/core.ts +++ b/cli/src/guidance/core.ts @@ -117,6 +117,20 @@ Local previews exist for every entity type and don't deploy: Argument shapes and per-language details live in the \`write-script-\`, \`write-flow\`, and \`raw-app\` skills. +## Keeping metadata in sync + +After editing a script, flow inline script, or app runnable, its generated metadata can go stale. \`wmill-lock.yaml\` stores a content hash per item, so a change that **adds or removes an import** or **changes a script's arguments** invalidates that hash and leaves the \`.lock\` (resolved dependencies) and \`.script.yaml\` (the input schema that drives the auto-generated args UI) out of date. \`wmill generate-metadata\` regenerates them and refreshes the hashes. Leaving them stale produces spurious diffs in git-sync and CI. + +This only writes local files — it is **not** a deploy — but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default **offer it and run it once the user agrees**, rather than running it silently after every edit. YOU run the command (never tell the user to run it); the choice is only whether to confirm first. + +After running it, diff the regenerated lockfiles (e.g. \`git diff\` the \`.lock\` / \`.script.lock\` files): if any dependency versions changed, tell the user what bumped (e.g. \`requests 2.31.0 → 2.32.0\`) so they can catch an unwanted change before deploying. Do this even under \`Metadata: auto\` — it is information, not a confirmation gate. Pin a version in code to keep it fixed. + +With no path argument it regenerates only the items whose metadata is actually stale (content hash drifted), workspace-wide — not everything. The set can be larger than the file you edited for two reasons: imports propagate (editing a script that others import marks every importer stale too, so their locks regenerate against the new code — by design, since a lock must reflect the imported code), and any pre-existing drift is swept in. If it touches items you didn't expect, run \`wmill generate-metadata --dry-run\` first — it lists each stale item with a reason (\`content changed\` or \`depends on \`) and changes nothing, so you can see why each is in scope. To narrow it, pass a folder or file path (\`wmill generate-metadata f/foo\`); add \`--strict-folder-boundaries\` to touch only items literally inside that folder (it warns about stale importers outside the folder that it skipped — they resurface as stale on the next unscoped run). + +**Save the preference so you don't ask every session.** If the user wants metadata regenerated automatically after edits (or always confirmed first), record it in the **project-specific instructions** section of \`AGENTS.md\` (user-owned — never overwritten by \`wmill refresh prompts\`), e.g. a line like \`Metadata: auto (run wmill generate-metadata after edits)\` or \`Metadata: ask first\`. Read that line first on later sessions and follow it. + +If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes. + ## Deploying There are two ways local changes reach the workspace. Pick based on how the repo is wired, not habit. diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index a8fb92bee0..36f90698e7 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -51,7 +51,7 @@ After writing, tell the user which command fits what they want to do: - \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. - \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. +- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". ### Preview vs run — choose by intent, not habit @@ -66,13 +66,23 @@ Only use \`sync push\` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Keep metadata in sync after editing + +\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI. + +This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed. + +With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on \`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`. + +If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes. + ### After writing — offer to test, don't wait passively If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. +\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push. For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. @@ -141,7 +151,7 @@ After writing, tell the user which command fits what they want to do: - \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. - \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. +- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". ### Preview vs run — choose by intent, not habit @@ -156,13 +166,23 @@ Only use \`sync push\` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Keep metadata in sync after editing + +\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI. + +This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed. + +With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on \`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`. + +If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes. + ### After writing — offer to test, don't wait passively If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. +\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push. For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. @@ -224,7 +244,7 @@ After writing, tell the user which command fits what they want to do: - \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. - \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. +- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". ### Preview vs run — choose by intent, not habit @@ -239,13 +259,23 @@ Only use \`sync push\` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Keep metadata in sync after editing + +\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI. + +This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed. + +With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on \`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`. + +If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes. + ### After writing — offer to test, don't wait passively If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. +\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push. For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. @@ -916,7 +946,7 @@ datatable(name: string = "main"): DatatableSqlTemplateFunction /** * Create a SQL template function for DuckDB/ducklake queries - * @param name - DuckDB database name (default: "main") + * @param name - DuckDB database name, optionally with a schema as \`name:schema\` (default: "main") * @returns SQL template function for building parameterized queries * @example * let sql = wmill.ducklake() @@ -926,6 +956,9 @@ datatable(name: string = "main"): DatatableSqlTemplateFunction * SELECT * FROM friends * WHERE name = \${name} AND age = \${age} * \`.fetch() + * @example + * // Target a specific schema within the ducklake + * let sql = wmill.ducklake("my_lake:analytics") */ ducklake(name: string = "main"): SqlTemplateFunction `, @@ -942,7 +975,7 @@ After writing, tell the user which command fits what they want to do: - \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. - \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. +- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". ### Preview vs run — choose by intent, not habit @@ -957,13 +990,23 @@ Only use \`sync push\` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Keep metadata in sync after editing + +\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI. + +This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed. + +With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on \`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`. + +If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes. + ### After writing — offer to test, don't wait passively If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. +\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push. For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. @@ -1634,7 +1677,7 @@ datatable(name: string = "main"): DatatableSqlTemplateFunction /** * Create a SQL template function for DuckDB/ducklake queries - * @param name - DuckDB database name (default: "main") + * @param name - DuckDB database name, optionally with a schema as \`name:schema\` (default: "main") * @returns SQL template function for building parameterized queries * @example * let sql = wmill.ducklake() @@ -1644,6 +1687,9 @@ datatable(name: string = "main"): DatatableSqlTemplateFunction * SELECT * FROM friends * WHERE name = \${name} AND age = \${age} * \`.fetch() + * @example + * // Target a specific schema within the ducklake + * let sql = wmill.ducklake("my_lake:analytics") */ ducklake(name: string = "main"): SqlTemplateFunction `, @@ -1660,7 +1706,7 @@ After writing, tell the user which command fits what they want to do: - \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. - \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. +- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". ### Preview vs run — choose by intent, not habit @@ -1675,13 +1721,23 @@ Only use \`sync push\` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Keep metadata in sync after editing + +\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI. + +This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed. + +With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on \`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`. + +If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes. + ### After writing — offer to test, don't wait passively If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. +\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push. For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. @@ -1742,7 +1798,7 @@ After writing, tell the user which command fits what they want to do: - \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. - \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. +- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". ### Preview vs run — choose by intent, not habit @@ -1757,13 +1813,23 @@ Only use \`sync push\` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Keep metadata in sync after editing + +\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI. + +This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed. + +With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on \`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`. + +If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes. + ### After writing — offer to test, don't wait passively If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. +\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push. For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. @@ -2434,7 +2500,7 @@ datatable(name: string = "main"): DatatableSqlTemplateFunction /** * Create a SQL template function for DuckDB/ducklake queries - * @param name - DuckDB database name (default: "main") + * @param name - DuckDB database name, optionally with a schema as \`name:schema\` (default: "main") * @returns SQL template function for building parameterized queries * @example * let sql = wmill.ducklake() @@ -2444,6 +2510,9 @@ datatable(name: string = "main"): DatatableSqlTemplateFunction * SELECT * FROM friends * WHERE name = \${name} AND age = \${age} * \`.fetch() + * @example + * // Target a specific schema within the ducklake + * let sql = wmill.ducklake("my_lake:analytics") */ ducklake(name: string = "main"): SqlTemplateFunction `, @@ -2460,7 +2529,7 @@ After writing, tell the user which command fits what they want to do: - \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. - \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. +- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". ### Preview vs run — choose by intent, not habit @@ -2475,13 +2544,23 @@ Only use \`sync push\` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Keep metadata in sync after editing + +\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI. + +This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed. + +With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on \`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`. + +If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes. + ### After writing — offer to test, don't wait passively If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. +\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push. For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. @@ -2576,7 +2655,7 @@ After writing, tell the user which command fits what they want to do: - \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. - \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. +- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". ### Preview vs run — choose by intent, not habit @@ -2591,13 +2670,23 @@ Only use \`sync push\` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Keep metadata in sync after editing + +\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI. + +This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed. + +With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on \`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`. + +If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes. + ### After writing — offer to test, don't wait passively If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. +\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push. For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. @@ -2675,7 +2764,7 @@ After writing, tell the user which command fits what they want to do: - \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. - \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. +- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". ### Preview vs run — choose by intent, not habit @@ -2690,13 +2779,23 @@ Only use \`sync push\` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Keep metadata in sync after editing + +\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI. + +This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed. + +With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on \`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`. + +If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes. + ### After writing — offer to test, don't wait passively If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. +\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push. For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. @@ -2761,7 +2860,7 @@ After writing, tell the user which command fits what they want to do: - \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. - \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. +- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". ### Preview vs run — choose by intent, not habit @@ -2776,13 +2875,23 @@ Only use \`sync push\` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Keep metadata in sync after editing + +\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI. + +This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed. + +With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on \`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`. + +If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes. + ### After writing — offer to test, don't wait passively If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. +\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push. For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. @@ -2840,7 +2949,7 @@ After writing, tell the user which command fits what they want to do: - \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. - \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. +- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". ### Preview vs run — choose by intent, not habit @@ -2855,13 +2964,23 @@ Only use \`sync push\` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Keep metadata in sync after editing + +\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI. + +This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed. + +With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on \`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`. + +If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes. + ### After writing — offer to test, don't wait passively If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. +\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push. For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. @@ -2922,7 +3041,7 @@ After writing, tell the user which command fits what they want to do: - \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. - \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. +- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". ### Preview vs run — choose by intent, not habit @@ -2937,13 +3056,23 @@ Only use \`sync push\` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Keep metadata in sync after editing + +\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI. + +This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed. + +With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on \`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`. + +If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes. + ### After writing — offer to test, don't wait passively If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. +\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push. For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. @@ -3005,7 +3134,7 @@ After writing, tell the user which command fits what they want to do: - \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. - \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. +- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". ### Preview vs run — choose by intent, not habit @@ -3020,13 +3149,23 @@ Only use \`sync push\` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Keep metadata in sync after editing + +\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI. + +This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed. + +With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on \`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`. + +If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes. + ### After writing — offer to test, don't wait passively If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. +\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push. For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. @@ -3103,7 +3242,7 @@ After writing, tell the user which command fits what they want to do: - \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. - \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. +- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". ### Preview vs run — choose by intent, not habit @@ -3118,13 +3257,23 @@ Only use \`sync push\` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Keep metadata in sync after editing + +\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI. + +This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed. + +With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on \`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`. + +If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes. + ### After writing — offer to test, don't wait passively If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. +\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push. For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. @@ -3184,7 +3333,7 @@ After writing, tell the user which command fits what they want to do: - \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. - \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. +- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". ### Preview vs run — choose by intent, not habit @@ -3199,13 +3348,23 @@ Only use \`sync push\` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Keep metadata in sync after editing + +\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI. + +This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed. + +With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on \`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`. + +If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes. + ### After writing — offer to test, don't wait passively If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. +\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push. For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. @@ -3280,7 +3439,7 @@ After writing, tell the user which command fits what they want to do: - \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. - \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. +- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". ### Preview vs run — choose by intent, not habit @@ -3295,13 +3454,23 @@ Only use \`sync push\` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Keep metadata in sync after editing + +\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI. + +This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed. + +With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on \`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`. + +If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes. + ### After writing — offer to test, don't wait passively If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. +\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push. For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. @@ -4146,7 +4315,7 @@ After writing, tell the user which command fits what they want to do: - \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. - \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. +- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". ### Preview vs run — choose by intent, not habit @@ -4161,13 +4330,23 @@ Only use \`sync push\` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Keep metadata in sync after editing + +\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI. + +This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed. + +With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on \`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`. + +If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes. + ### After writing — offer to test, don't wait passively If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. +\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push. For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. @@ -4272,7 +4451,7 @@ After writing, tell the user which command fits what they want to do: - \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. - \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. +- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". ### Preview vs run — choose by intent, not habit @@ -4287,13 +4466,23 @@ Only use \`sync push\` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Keep metadata in sync after editing + +\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI. + +This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed. + +With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on \`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`. + +If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes. + ### After writing — offer to test, don't wait passively If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. +\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push. For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. @@ -4388,7 +4577,7 @@ After writing, tell the user which command fits what they want to do: - \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. - \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. +- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". ### Preview vs run — choose by intent, not habit @@ -4403,13 +4592,23 @@ Only use \`sync push\` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Keep metadata in sync after editing + +\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI. + +This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed. + +With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on \`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`. + +If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes. + ### After writing — offer to test, don't wait passively If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. +\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push. For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. @@ -4504,11 +4703,11 @@ Once the flow has real content, **offer** to open the visual preview as a one-se ## CLI Commands — running, previewing, deploying -After writing, act on the user's intent instead of just listing commands. Run the safe, non-deploying command yourself when it fits (\`wmill flow preview\` — see "After writing — offer to run, don't wait passively" below); only *name* the commands that deploy or rewrite files (\`wmill sync push\`, \`wmill generate-metadata\`) so the user can approve them. The options: +After writing, act on the user's intent instead of just listing commands. Run \`wmill flow preview\` yourself when it fits (see "After writing — offer to run, don't wait passively" below). \`wmill generate-metadata\` regenerates local lock/hash files (not a deploy) but re-resolves deps — offer it and run on agreement, unless the project's \`AGENTS.md\` opts into running metadata automatically. Only *name* \`wmill sync push\` (the deploy) so the user can approve it. The options: - \`wmill flow preview \` — **default when iterating on a local flow.** Runs the local \`flow.yaml\` against local inline scripts without deploying. Add \`--remote\` to use deployed workspace scripts for PathScript steps instead of local files. Add \`--step \` to run only one module in isolation (see "Single-step vs whole-flow preview" below). - \`wmill flow run \` — runs the flow **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — regenerate stale \`.lock\` and \`.script.yaml\` files. By default it scans **scripts, flows, and apps** across the workspace; pass \`--skip-flows --skip-apps\` (or run from a subdirectory) to limit the scope when you only care about the flow you edited. +- \`wmill generate-metadata\` — regenerate stale local \`.lock\` files for the flow and its inline scripts and refresh their content hashes in \`wmill-lock.yaml\`. Writes local files only (not a deploy). Run it after editing inline scripts whose imports or arguments changed, so \`wmill-lock.yaml\` doesn't drift and add noise to git-sync/CI. By default it scans **scripts, flows, and apps** across the workspace but only regenerates stale ones; pass the flow's folder as an argument (or run from that subdirectory) to limit the scope to the flow you edited. Note a flow (or script) that imports a changed shared script is pulled in too — run \`wmill generate-metadata --dry-run\` to see exactly what is stale and why (\`content changed\` vs \`depends on \`) before applying. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". ### Preview vs run — choose by intent, not habit @@ -4537,7 +4736,7 @@ If the user hasn't already told you to run/test the flow, offer it as a one-sent If the user already asked to test/run/try the flow in their original request, skip the offer and just execute \`wmill flow preview -d ''\` directly — pick plausible args from the flow's input schema. -\`wmill flow preview\` is safe to run yourself (it does not deploy). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. +\`wmill flow preview\` is safe to run yourself (it does not deploy). \`wmill generate-metadata\` does not deploy either (it only writes local lock/hash files) but re-resolves deps — offer it and run on agreement, unless the project's \`AGENTS.md\` opts into automatic metadata. After running it, check the regenerated \`.lock\` diff and tell the user which inline-script dependency versions changed, so they can catch an unwanted bump before deploying. Only \`wmill sync push\` deploys; run it only when the user explicitly asks. ### Visual preview @@ -4970,10 +5169,11 @@ The runnable ID is the filename without extension. For example, \`get_user.ts\` | C# | \`.cs\` | \`myFunc.cs\` | | Java | \`.java\` | \`myFunc.java\` | -After creating a runnable, offer to generate its lock files as a one-sentence next step (e.g. "Want me to generate the lock files?") and run it yourself once they agree — don't just name the command and wait. If the user already asked you to finish/lock the app, run it directly. It writes local lock files (not a deploy), so offer rather than running silently: +After creating or editing a backend runnable — especially when its imports or arguments changed — its local lock and \`wmill-lock.yaml\` go stale. Offer to run \`wmill generate-metadata\` and run it once the user agrees (or automatically if the project's \`AGENTS.md\` opts into that) — YOU run it, don't just name it and wait. It writes local files only (not a deploy), and keeping the lock current avoids noise in git-sync/CI: \`\`\`bash wmill generate-metadata \`\`\` +After it runs, check the regenerated \`.lock\` diff and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying. ### Optional YAML configuration @@ -5063,7 +5263,7 @@ data: Two commands you run yourself, not the user: - \`wmill app new\` — run it with flags, per the "Creating a Raw App" section above. -- \`wmill generate-metadata\` — generates local lock files; offer it and run it on consent, per "After creating a runnable" above (it writes local lock files, not a deploy). +- \`wmill generate-metadata\` — (re)generates local lock files and refreshes \`wmill-lock.yaml\` content hashes; writes local files only (not a deploy). After adding or editing a runnable, offer it and run it on agreement — or automatically if the project's \`AGENTS.md\` opts into that (see "After creating a runnable" above). For the rest, tell the user which command fits their intent and let them run it — these deploy to the workspace, overwrite local files, or launch a long-running server, so the user should consent each time: @@ -5603,7 +5803,7 @@ After writing, tell the user which command fits what they want to do: - \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. - \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. +- \`wmill generate-metadata\` — regenerate the local \`.script.yaml\` (input schema) and \`.lock\` (resolved dependencies) for scripts you changed, and refresh their content hashes in \`wmill-lock.yaml\`. Local files only — **not** a deploy. See "Keep metadata in sync" below. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". ### Preview vs run — choose by intent, not habit @@ -5618,13 +5818,23 @@ Only use \`sync push\` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Keep metadata in sync after editing + +\`wmill-lock.yaml\` tracks a content hash for each item. Editing a script's content — most importantly **adding or removing an import** or **changing \`main\`'s arguments** — invalidates that hash and leaves the \`.lock\`, the \`.script.yaml\` input schema, and the hash row out of date. Run \`wmill generate-metadata\` (scoped to what you touched) after such edits so the resolved lock, the auto-generated args UI (driven by \`.script.yaml\`), and \`wmill-lock.yaml\` all match the code. Leaving them stale produces spurious diffs in git-sync and CI. + +This only writes local files (it is **not** a deploy), but it re-resolves dependencies, so it can bump unpinned versions (the same as deploying from the UI; expected, not a bug). So by default offer it and run it once the user agrees, rather than running it silently after every edit — unless the project's \`AGENTS.md\` opts into running metadata automatically (see the "Keeping metadata in sync" preference there). Either way YOU run the command, not the user. After running it, diff the regenerated \`.lock\` / \`.script.lock\` files and tell the user which dependency versions changed (e.g. \`requests 2.31.0 → 2.32.0\`), so they can catch an unwanted bump before deploying — even under \`Metadata: auto\`, since it's information, not a confirmation gate. Pin versions in code to keep them fixed. + +With no path argument, \`generate-metadata\` regenerates only the items whose content hash drifted — not everything. Imports propagate: editing a script that others import marks every importer stale too, so a one-line change to a shared module can regenerate many locks (by design — their locks must reflect the imported code). If it touches more than you expect, run \`wmill generate-metadata --dry-run\` — it lists each stale item with a reason (\`content changed\` or \`depends on \`) without changing anything — then narrow with a path argument (\`wmill generate-metadata f/foo\`) or \`--strict-folder-boundaries\`. + +If the on-disk \`.lock\` and \`.script.yaml\` are already correct and only \`wmill-lock.yaml\` needs its hashes refreshed (hash drift, or bootstrapping missing entries), use \`wmill generate-metadata rehash\` — it re-records hashes from disk with no backend round-trip and no dependency changes. + ### After writing — offer to test, don't wait passively If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. +\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill generate-metadata\` does not deploy either — it only writes local files (locks, schemas, hashes) — but offer it before running (or run automatically if the project's \`AGENTS.md\` opts in), per "Keep metadata in sync" above. Only \`wmill sync push\` deploys to the workspace — run it only when the user explicitly asks to deploy/publish/push. For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. @@ -6229,7 +6439,7 @@ folder related commands ### generate-metadata -Generate metadata (locks, schemas) for all scripts, flows, and apps +Regenerate stale local locks and script schemas and refresh wmill-lock.yaml content hashes (scripts, flows, apps). Writes local files only, not a deploy. Run it after edits that add or remove imports or change a script's arguments, so the lock, the auto-generated UI schema, and wmill-lock.yaml stay in sync. **Arguments:** \`[folder:string]\` @@ -6248,7 +6458,7 @@ Generate metadata (locks, schemas) for all scripts, flows, and apps **Subcommands:** -- \`generate-metadata rehash [folder:string]\` +- \`generate-metadata rehash [folder:string]\` - Refresh wmill-lock.yaml content hashes from the on-disk .lock and .script.yaml without re-resolving dependencies or hitting the backend. Use when those files are already correct and only the hashes need updating: bootstrapping missing entries or recovering from hash drift. - \`--skip-scripts\` - Skip processing scripts - \`--skip-flows\` - Skip processing flows - \`--skip-apps\` - Skip processing apps @@ -6356,7 +6566,7 @@ sync local with a remote instance or the opposite (push or pull) - \`-o, --output-file \` - Write YAML to a file instead of stdout - \`--show-secrets\` - Include sensitive fields (license key, JWT secret) without prompting - \`--instance \` - Name of the instance, override the active instance -- \`instance connect-slack\` +- \`instance connect-slack\` - Non-interactively connect Slack at the instance level using a pre-minted bot token (xoxb-...). Produces the same artifacts as the UI OAuth flow: global_settings 'slack' row + encrypted f/slack_bot/global_bot_token variable and resource in the admins workspace. - \`--bot-token \` - Slack bot token (xoxb-...) - \`--team-id \` - Slack team id - \`--team-name \` - Slack team name @@ -6410,6 +6620,8 @@ Validate Windmill flow, schedule, and trigger YAML files in a directory ### object-storage +Object storage (S3) related commands. Operates on the workspace's default object storage; use --storage to target a configured secondary storage. + **Alias:** \`s3\` **Subcommands:** @@ -6457,6 +6669,8 @@ inspect asset-driven pipelines (scripts marked \`// pipeline\`, wired by \`// on ### protection-rules +Sync workspace protection rules between protection-rules.yaml and Windmill. The file is keyed by workspace name; keys must match wmill.yaml 'workspaces'. + **Subcommands:** - \`protection-rules pull [workspace:string]\` - Pull protection rules from Windmill into protection-rules.yaml for a workspace @@ -6797,7 +7011,7 @@ workspace related commands - \`--bot-token \` - Slack bot token (xoxb-...) - \`--team-id \` - Slack team id - \`--team-name \` - Slack team name -- \`workspace disconnect-slack\` +- \`workspace disconnect-slack\` - Clear slack_team_id / slack_name on the active workspace (marks the workspace as disconnected). Does NOT remove the bot token variable/resource/folder/group — delete those from the local sync folder and run 'wmill sync push' to tear them down. Does NOT remove the workspace-level OAuth override — set slack_oauth_client_id/_secret to '' in settings.yaml and push. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 51fb1e9590..39cc16c056 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.728.1", + "version": "1.729.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.728.1", + "version": "1.729.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 97565fd4b0..98838b9713 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.728.1", + "version": "1.729.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/frontend/src/lib/components/ApiConnectForm.svelte b/frontend/src/lib/components/ApiConnectForm.svelte index 7d10db1d48..dd02a3c578 100644 --- a/frontend/src/lib/components/ApiConnectForm.svelte +++ b/frontend/src/lib/components/ApiConnectForm.svelte @@ -47,8 +47,9 @@ async function isSupabaseAvailable() { try { - supabaseWizard = - ((await OauthService.listOauthConnects()) ?? {})['supabase_wizard'] != undefined + supabaseWizard = ((await OauthService.listOauthConnects()) ?? []).some( + (c) => c.name === 'supabase_wizard' + ) } catch (error) {} } async function loadSchema() { diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index 4872629cc1..1046ee69b0 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -16,7 +16,7 @@ import oauthConnectRegistry from '$oauth_connect_registry' import { createEventDispatcher, onDestroy } from 'svelte' import Path from './Path.svelte' - import { Button, Skeleton } from './common' + import { Button, RadioCard, Skeleton } from './common' import ApiConnectForm from './ApiConnectForm.svelte' import SearchItems from './SearchItems.svelte' import WhitelistIp from './WhitelistIp.svelte' @@ -27,11 +27,10 @@ import { base } from '$lib/base' import Required from './Required.svelte' import Toggle from './Toggle.svelte' - import { Pen } from 'lucide-svelte' + import { Pen, Search } from 'lucide-svelte' import GfmMarkdown from './GfmMarkdown.svelte' import { apiTokenApps, forceSecretValue, linkedSecretValue } from './app_connect' import type { SchemaProperty } from '$lib/common' - import Tooltip from './Tooltip.svelte' import TextInput from './text_input/TextInput.svelte' import { sameTopDomainOrigin } from '$lib/cookies' import SyncResourceTypes from './SyncResourceTypes.svelte' @@ -75,6 +74,18 @@ let value: string = $state('') let valueToken: TokenResponse | undefined = undefined let connects: string[] | undefined = $state(undefined) + /** Per-provider instance-entry metadata, keyed by provider name. */ + let connectsInfo: Record< + string, + { supports_client_credentials: boolean; has_shared_credentials: boolean } + > = $state({}) + + /** An instance entry with shared credentials (admin id+secret): connect with + * no input. Shown under "Instance-configured"; bring-your-own-only providers + * (no shared creds) are shown under "Others" instead. */ + function isSharedConnect(key: string): boolean { + return connectsInfo[key]?.has_shared_credentials ?? false + } const SANDBOX_SUFFIX = '_sandbox' function stripSandboxSuffix(name: string): string { @@ -119,6 +130,9 @@ } let scopes: string[] = $state([]) + /** The authorization-code default scopes (instance entry / registry), kept so + * toggling back from client-credentials can restore them. */ + let instanceScopes: string[] = $state([]) let extra_params: [string, string][] = [] let responseExtra: Record = $state({}) let path: string = $state('') @@ -147,11 +161,132 @@ */ let clientId = $state('') let clientSecret = $state('') - let tokenUrl = $state('') + let ccInstance = $state('') let resourceTypeInfo: ResourceType | undefined = $state(undefined) let resourceTypeNotFound = $state(false) + function registryEntry(): any { + const reg = oauthConnectRegistry as Record + // Resolve `_sandbox` clients to their parent registry entry (e.g. + // salesforce_sandbox -> salesforce) so sandbox connections see CC metadata. + return reg[stripSandboxSuffix(connectClient)] ?? reg[stripSandboxSuffix(resourceType)] + } + + /** The static registry declares this provider supports client credentials */ + function registryCcCapable(): boolean { + return registryEntry()?.grant_types?.includes('client_credentials') ?? false + } + + /** Instance-name metadata for providers whose token URL is instance-templated + * (carried in `connect_config_template`): the user enters an instance name + * instead of a full token URL, and the backend substitutes it into the + * fixed-host template so the exchange host stays pinned. */ + let ccInstanceMeta = $derived( + registryEntry()?.connect_config_template as + | { label: string; placeholder: string; help_url?: string } + | undefined + ) + + /** Instance entry declares client credentials but not authorization_code + * (custom provider configured with only a token URL) */ + let authCodeUnavailable = $state(false) + + /** Instance entry carries shared client-credentials (id + secret); the user + * doesn't enter their own — the exchange runs server-side with those creds */ + let ccInstanceConfigured = $state(false) + + /** The user wants their own credentials (picked the provider from the "Others" + * section) — overrides the shared instance credentials for this connection */ + let ccBringYourOwn = $state(false) + + /** Connect with the shared instance credentials (no form) rather than the + * bring-your-own form */ + let useSharedInstanceCreds = $derived(ccInstanceConfigured && !ccBringYourOwn) + + /** Connectable via client credentials only: registry-declared provider with + * no instance OAuth client, or instance provider without an authorize URL */ + let ccOnly = $derived.by( + () => + authCodeUnavailable || + (registryCcCapable() && connectClient != '' && !(connects?.includes(connectClient) ?? false)) + ) + + /** Clear CC inputs and scopes so a previous selection never leaks into a new one */ + function resetClientCredentialsState() { + supportsClientCredentials = false + useClientCredentials = false + authCodeUnavailable = false + ccInstanceConfigured = false + ccBringYourOwn = false + clientId = '' + clientSecret = '' + ccInstance = '' + scopes = [] + } + + /** Default scopes for the client-credentials grant. Registry providers use + * their `cc_scopes` (auth-code scopes are invalid in a 2-legged request); + * custom (non-registry) providers configured at the instance level have no + * registry entry, so they keep their admin-configured scopes (`instanceScopes`) + * instead of being zeroed. */ + function defaultCcScopes(): string[] { + const entry = registryEntry() + return entry ? (entry.cc_scopes ?? []) : instanceScopes + } + + function enableClientCredentials() { + manual = false + supportsClientCredentials = true + if (!useClientCredentials) { + // Switching into client-credentials: default to the CC scopes (never the + // authorization-code scopes — most providers reject member/consent scopes + // in a 2-legged request). Only reset on the transition so edits made while + // already in CC mode are preserved. + scopes = defaultCcScopes() + } + useClientCredentials = true + } + + /** Switch to the browser sign-in (authorization-code) grant, restoring its + * default scopes when coming from the client-credentials grant. */ + function selectAuthCodeGrant() { + if (useClientCredentials) { + scopes = instanceScopes + } + useClientCredentials = false + } + + /** Static registry declares client-credentials support for `key`. */ + function isCcCapable(key: string): boolean { + return ( + (oauthConnectRegistry as Record)[stripSandboxSuffix(key)]?.grant_types?.includes( + 'client_credentials' + ) ?? false + ) + } + + /** Step-1 "Others" selection: CC-capable resource types open the client- + * credentials form with the user's own credentials — even when the instance + * has shared ones (the "Instance-configured OAuth APIs" section is the entry + * point for those). Every other type opens the raw manual form. */ + function selectFromOthers(key: string) { + connectClient = key + resourceType = key + resetClientCredentialsState() + // Registry CC providers and instance-configured providers that declare the + // client-credentials grant (incl. custom providers set up with only a token + // URL and no shared creds) open the bring-your-own form. Everything else is + // a manual resource. + if (isCcCapable(key) || (connectsInfo[key]?.supports_client_credentials ?? false)) { + ccBringYourOwn = true + enableClientCredentials() + } else { + manual = true + } + next() + } + let pathError = $state('') export async function open(rt?: string) { @@ -168,22 +303,33 @@ resourceType = stripSandboxSuffix(rawRt) valueToken = undefined - // Reset client credentials state - supportsClientCredentials = false - useClientCredentials = false - clientId = '' - clientSecret = '' - tokenUrl = '' + resetClientCredentialsState() await loadConnects() - manual = !connects?.includes(connectClient) + const inConnects = connects?.includes(connectClient) ?? false + // Registry-declared client-credentials providers are connectable even + // without an instance OAuth client + manual = !inConnects && !(rt && registryCcCapable()) if (manual && express) { dispatch('error', 'Express OAuth setup is not available for non OAuth resource types') return } + if (!inConnects && !manual && express) { + // Client-credentials connections need interactive credential entry + dispatch('error', 'Express OAuth setup is not available for client credentials providers') + return + } + if (!inConnects && !manual) { + enableClientCredentials() + } if (rt) { if (!manual && express) { await getScopesAndParams() + if (authCodeUnavailable) { + // No popup flow to drive express setup with + dispatch('error', 'Express OAuth setup is not available for client credentials providers') + return + } step = 2 } next() @@ -193,18 +339,19 @@ async function loadConnects() { if (!connects) { try { - connects = (await OauthService.listOauthConnects()) - .filter((x) => x != 'supabase_wizard') - .sort((a, b) => a.localeCompare(b)) + const list = (await OauthService.listOauthConnects()) + .filter((x) => x.name != 'supabase_wizard') + .sort((a, b) => a.name.localeCompare(b.name)) + connects = list.map((x) => x.name) + connectsInfo = Object.fromEntries(list.map((x) => [x.name, x])) } catch (e) { connects = [] + connectsInfo = {} console.error('Error loading OAuth connects', e) } } } - const connectAndManual = ['gitlab'] - run(() => { isGoogleSignin = step == 1 && @@ -227,7 +374,11 @@ args['api_key'] == '' && args['key'] == '' && linkedSecrets.length > 0 - : false)) || + : useClientCredentials && + !useSharedInstanceCreds && + (clientId.trim() == '' || + clientSecret.trim() == '' || + (!!ccInstanceMeta && ccInstance.trim() == '')))) || step == 3 || (step == 4 && pathError != '') || !isValid @@ -241,8 +392,11 @@ workspace: effectiveWorkspace }) + // "Others" lists every resource type — including instance-configured OAuth + // providers — so any of them can also be connected with the user's own + // credentials or manually, not only via the shared instance setup (same as + // the authorization-code behavior). connectsManual = availableRts - .filter((x) => connectAndManual.includes(x) || !Object.keys(connects ?? {}).includes(x)) .map( (x) => ({ @@ -339,15 +493,39 @@ } async function getScopesAndParams() { + if (!connects?.includes(connectClient)) { + // No instance OAuth client (registry-declared CC-only provider): + // defaults come from the static registry instead. + instanceScopes = registryEntry()?.scopes ?? [] + scopes = useClientCredentials ? defaultCcScopes() : instanceScopes + extra_params = [] + supportsClientCredentials = registryCcCapable() + return + } const connect = await OauthService.getOauthConnect({ client: connectClient }) - scopes = connect.scopes ?? [] + instanceScopes = connect.scopes ?? [] extra_params = Object.entries(connect.extra_params ?? {}) as [string, string][] /** - * Check if the OAuth provider supports client_credentials grant type - * This determines whether to show the OAuth flow selection UI + * The CC flow is offered when the static registry declares it for the + * provider, or the admin enabled it on the instance entry (custom + * providers) */ - supportsClientCredentials = connect.grant_types?.includes('client_credentials') ?? false + supportsClientCredentials = + registryCcCapable() || (connect.grant_types?.includes('client_credentials') ?? false) + // Shared instance credentials: the user connects without entering any creds + ccInstanceConfigured = connect.client_credentials_configured ?? false + // Custom provider configured with only a token URL: no popup flow possible + authCodeUnavailable = + supportsClientCredentials && !(connect.grant_types?.includes('authorization_code') ?? true) + if (authCodeUnavailable) { + useClientCredentials = true + } + // Default scopes to the active grant: client-credentials uses the registry's + // cc_scopes (auth-code scopes are invalid in a 2-legged request), every other + // path keeps the instance entry's scopes. Applies to shared instance creds, + // not just bring-your-own. Switching grants resets to these defaults. + scopes = useClientCredentials ? defaultCcScopes() : instanceScopes } async function getResourceTypeInfo() { @@ -386,37 +564,46 @@ if (useClientCredentials) { /** * Client credentials flow: Direct API call to backend - * No popup window or user interaction required - * Uses instance-level OAuth credentials for server-to-server auth + * No popup window or user interaction required — the resource-level + * credentials are exchanged directly against the token URL */ try { // Trim whitespace from credentials to avoid false negatives const trimmedClientId = clientId.trim() const trimmedClientSecret = clientSecret.trim() + const trimmedInstance = ccInstance.trim() + // Instance-templated providers collect an instance name; the backend + // builds the host-pinned token URL from it. Other registry providers + // need no URL input (the token URL comes from the registry). + const needsInstance = !!ccInstanceMeta - // Validate required fields - if (!trimmedClientId || !trimmedClientSecret) { + // Bring-your-own credentials are required unless the provider has + // shared instance credentials, in which case the exchange runs + // server-side with those and no input is collected here. + if ( + !useSharedInstanceCreds && + (!trimmedClientId || !trimmedClientSecret || (needsInstance && !trimmedInstance)) + ) { sendUserToast( - 'Client ID and Client Secret are required for client credentials flow', + needsInstance + ? `Client ID, Client Secret and ${ccInstanceMeta?.label} are required for client credentials flow` + : 'Client ID and Client Secret are required for client credentials flow', true ) return } - const requestBody: any = { - scopes: scopes, - cc_client_id: trimmedClientId, - cc_client_secret: trimmedClientSecret - } - - // Add token URL override if provided - if (tokenUrl.trim()) { - requestBody.cc_token_url = tokenUrl.trim() - } - const tokenResponse = await OauthService.connectClientCredentials({ + workspace: effectiveWorkspace, client: connectClient, - requestBody + requestBody: useSharedInstanceCreds + ? { scopes: scopes } + : { + scopes: scopes, + cc_client_id: trimmedClientId, + cc_client_secret: trimmedClientSecret, + ...(needsInstance ? { cc_instance: trimmedInstance } : {}) + } }) // Process the token response like in popup flow @@ -490,21 +677,34 @@ throw Error(`Resource at path ${path} already exists. Delete it or pick another path`) } - // Per-instance OAuth providers (Snowflake, ServiceNow, …): copy the - // admin-configured instance from the OAuth client's extra_params into the - // resource args, per the registry template's resource_mapping (e.g. - // ServiceNow -> instance_url: https://{instance}.service-now.com). Generic - // so a new per-instance provider needs only a registry entry. + // Per-instance OAuth providers (Snowflake, ServiceNow, …): fill the + // resource args from the connection's instance, per the registry + // template's resource_mapping (e.g. ServiceNow -> instance_url: + // https://{instance}.service-now.com). Bring-your-own carries the instance + // the user entered in `ccInstance` (raw, possibly a full host); the shared + // path carries it (already normalized) in the connect entry's extra_params. + // Prefer the user-entered one so the saved resource matches the exchange. const connectTemplate = (oauthConnectRegistry as Record)[resourceType] ?.connect_config_template if (connectTemplate?.resource_mapping) { const instanceKey = connectTemplate.extra_params_key ?? 'instance' - const found = extra_params.find(([key, _]) => key === instanceKey) - if (found) { + let instanceValue = extra_params.find(([key, _]) => key === instanceKey)?.[1] ?? '' + if (ccInstance.trim()) { + const stripSuffix = connectTemplate.strip_suffix as string | undefined + let v = ccInstance + .trim() + .replace(/^https?:\/\//, '') + .replace(/\/.*$/, '') + if (stripSuffix && v.endsWith(stripSuffix)) { + v = v.slice(0, -stripSuffix.length) + } + instanceValue = v.replace(/\.+$/, '') + } + if (instanceValue) { for (const [argField, valueTemplate] of Object.entries( connectTemplate.resource_mapping as Record )) { - args[argField] = valueTemplate.replaceAll('{instance}', found[1]) + args[argField] = valueTemplate.replaceAll('{instance}', instanceValue) } } } @@ -526,13 +726,18 @@ accountData.scopes = scopes } - // Add client credentials if using client_credentials flow - if (useClientCredentials) { + // Client-credentials accounts are self-contained: the refresh worker + // re-exchanges using only what is stored on the account row. With + // shared instance credentials the backend copies them onto the row, + // so nothing is sent from here. + if (useClientCredentials && !useSharedInstanceCreds) { accountData.cc_client_id = clientId.trim() accountData.cc_client_secret = clientSecret.trim() - // Add token URL override if provided - if (tokenUrl.trim()) { - accountData.cc_token_url = tokenUrl.trim() + // Instance-templated providers send an instance name; the backend + // resolves and stores the host-pinned token URL. Other registry + // providers need nothing more (token URL comes from the registry). + if (ccInstanceMeta) { + accountData.cc_instance = ccInstance.trim() } } @@ -657,7 +862,7 @@ ({ + ? connects.filter(isSharedConnect).map((key) => ({ key })) : undefined} @@ -671,17 +876,18 @@ f={(x) => x.key} /> {#if step == 1} -
- +
+
+ + +
-

OAuth APIs

+

Instance-configured OAuth APIs

{#if filteredConnects} {#each filteredConnects as { key }} @@ -693,6 +899,7 @@ manual = false connectClient = key resourceType = stripSandboxSuffix(key) + resetClientCredentialsState() next() }} > @@ -705,10 +912,10 @@ {/each} {/if}
- {#if connects && connects.length == 0} + {#if connects && connects.filter(isSharedConnect).length == 0}
No OAuth APIs has been setup on the instance. To add oauth APIs, first sync the resource - types with the hub, then add oauth configuration. See No OAuth APIs have been set up on this instance. To add OAuth APIs, first sync the resource + types with the hub, then add OAuth configuration. See documentation
@@ -718,7 +925,7 @@ {#if connectsManual && connectsManual?.length < 10}
- Resource Types have not been synced with the hub + Resource types have not been synced with the hub
{/if} @@ -730,12 +937,7 @@ unifiedSize="md" variant="default" selected={key === resourceType} - on:click={() => { - manual = true - connectClient = key - resourceType = key - next() - }} + on:click={() => selectFromOthers(key)} > @@ -749,16 +951,10 @@ @@ -867,6 +1063,14 @@
{/if} + {#if registryCcCapable()} + + {/if} {#key resourceTypeInfo} Create a resource backed by an OAuth connection, whose token is fetched from the external services and refreshed automatically if needed before expiration. - + {#if ccBringYourOwn} + + {/if} {#if resourceTypeInfo?.description} @@ -909,26 +1118,40 @@ {#if supportsClientCredentials} -
-

Authentication Method

-
- - - - Server-to-server authentication without user interaction. -

- Provide your own OAuth client credentials for this resource. -
-
+
+

Authentication

+ {#if ccOnly || ccBringYourOwn} +
+ {#if useSharedInstanceCreds} + {resourceType} connects server-to-server using the credentials configured for this + instance. The token is acquired and refreshed automatically. + {:else} + {resourceType} connects server-to-server. Enter a client ID and secret; the token is + acquired and refreshed automatically. + {/if} +
+ {:else} +
+ + enableClientCredentials()} + /> +
+ {/if} - {#if useClientCredentials} + {#if useClientCredentials && !useSharedInstanceCreds}
- + {#if ccInstanceMeta} + + {/if}
{/if}
diff --git a/frontend/src/lib/components/AppWrapper.svelte b/frontend/src/lib/components/AppWrapper.svelte index 9c6cd9756d..bd113e160c 100644 --- a/frontend/src/lib/components/AppWrapper.svelte +++ b/frontend/src/lib/components/AppWrapper.svelte @@ -2,10 +2,17 @@ import { untrack } from 'svelte' import AppEditor from './apps/editor/AppEditor.svelte' import type { AppEditorProps } from './apps/types' + import { workspaceStore } from '$lib/stores' let { app: oldApp, ...props }: AppEditorProps = $props() let app = $state(untrack(() => oldApp)) - + +{#if $workspaceStore} + +{/if} diff --git a/frontend/src/lib/components/AuthSettings.svelte b/frontend/src/lib/components/AuthSettings.svelte index 335b64277c..2ad6ab917a 100644 --- a/frontend/src/lib/components/AuthSettings.svelte +++ b/frontend/src/lib/components/AuthSettings.svelte @@ -18,6 +18,8 @@ import { capitalize, type Item } from '$lib/utils' import ClipboardPanel from './details/ClipboardPanel.svelte' import Toggle from './Toggle.svelte' + import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' + import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import DropdownV2 from './DropdownV2.svelte' import { APP_TO_ICON_COMPONENT } from './icons' import { ExternalLink, Plus, Circle, X } from 'lucide-svelte' @@ -100,6 +102,10 @@ // carry a `connect_config_template`. Derived from the registry so adding a // new one needs only a JSON entry — they get a builtin tile + the generic // instance-name input below, with no frontend change. + // Every per-instance templated provider gets a settings tile + instance input: + // authorization-code ones (ServiceNow) provide an `auth_url`, client-credentials-only + // ones (Coupa) provide only a `token_url`. The admin enters their instance host so + // the shared credentials point at the right endpoint. const connectConfigTemplates: Record = Object.fromEntries( Object.entries(oauthConnectRegistry) .filter(([, cfg]) => cfg && typeof cfg === 'object' && 'connect_config_template' in cfg) @@ -112,6 +118,55 @@ ...windmillBuiltinsTemplated ] + /** Resolve a `_sandbox` key to its parent registry entry (sandbox + * variants inherit the parent's grant_types), matching the connect dialog. */ + function canonicalRegistryKey(name: string): string { + return name.endsWith('_sandbox') ? name.slice(0, -'_sandbox'.length) : name + } + + /** The static registry declares client credentials for this provider */ + function registryCcCapable(name: string): boolean { + return ( + (oauthConnectRegistry as Record)[ + canonicalRegistryKey(name) + ]?.grant_types?.includes('client_credentials') ?? false + ) + } + + /** The static registry supports authorization code for this provider. A + * provider with no explicit grant_types defaults to authorization code. */ + function registryAuthCodeCapable(name: string): boolean { + const reg = (oauthConnectRegistry as Record)[canonicalRegistryKey(name)] + if (!reg) return false + return reg.grant_types ? reg.grant_types.includes('authorization_code') : true + } + + /** Built-in provider that only supports client credentials (e.g. Coupa): no + * authorization-code flow to choose, so the grant is fixed. */ + function registryCcOnly(name: string): boolean { + return registryCcCapable(name) && !registryAuthCodeCapable(name) + } + + /** Map the entry's grant_types to the single-select choice (so the segmented + * control always has exactly one selected and can never be empty) */ + function grantChoice(name: string): string { + const gts = oauths?.[name]?.['grant_types'] ?? ['authorization_code'] + const cc = gts.includes('client_credentials') + const ac = gts.includes('authorization_code') + if (cc && ac) return 'both' + if (cc) return 'client_credentials' + return 'authorization_code' + } + + /** Set the grant types from the segmented choice. The instance credentials are + * then used for every selected grant — authorization-code popup and/or + * server-to-server. */ + function setGrantChoice(name: string, choice: string) { + if (!oauths || !oauths[name]) return + oauths[name]['grant_types'] = + choice === 'both' ? ['authorization_code', 'client_credentials'] : [choice] + } + let showCustomOAuthForm = $state(false) let customOAuthName = $state('') let customNameInput = $state() @@ -125,7 +180,11 @@ if (oauths && name) { // Create a new object to ensure the new item is added at the end const newOauths = { ...oauths } - newOauths[name] = { id: '', secret: '', grant_types: ['authorization_code'] } + newOauths[name] = { + id: '', + secret: '', + grant_types: registryCcOnly(name) ? ['client_credentials'] : ['authorization_code'] + } oauths = newOauths dropdownOpen = false } @@ -463,49 +522,51 @@ bind:password={oauths[k]['secret']} /> - {#if k === 'visma' || !windmillBuiltins.includes(k)} -
-
- { - const target = e.target as HTMLInputElement - if (oauths && oauths[k]) { - if (!oauths[k]['grant_types']) { - oauths[k]['grant_types'] = ['authorization_code'] - } - if (target.checked) { - if (!oauths[k]['grant_types'].includes('client_credentials')) { - oauths[k]['grant_types'] = [ - ...oauths[k]['grant_types'], - 'client_credentials' - ] - } - } else { - oauths[k]['grant_types'] = oauths[k]['grant_types'].filter( - (gt: string) => gt !== 'client_credentials' - ) - } - } - }} - /> - Support Client Credentials Flow + These credentials are for + {#if !windmillBuiltins.includes(k) || (registryCcCapable(k) && registryAuthCodeCapable(k))} + setGrantChoice(k, v)} + > + {#snippet children({ item })} + + + + {/snippet} + + {:else if registryCcCapable(k)} + + Client credentials (server-to-server) + Fill Client ID and Secret to share one service account, or leave them empty + so each user brings their own. - - Enables server-to-server authentication without user interaction. Use for - automated scripts and background jobs. -

- When enabled, users can provide their own client credentials at the resource - level. The Client ID and Secret configured above are only used for the traditional - OAuth flow (popup window). -
-
-
- {/if} + + {:else} + Authorization code (browser sign-in) + {/if} +
{#if k === 'azure_oauth'} {:else if !windmillBuiltins.includes(k) && k != 'slack'} diff --git a/frontend/src/lib/components/AutosaveIndicator.svelte b/frontend/src/lib/components/AutosaveIndicator.svelte index 21ad7a4ac0..bd4912a950 100644 --- a/frontend/src/lib/components/AutosaveIndicator.svelte +++ b/frontend/src/lib/components/AutosaveIndicator.svelte @@ -1,9 +1,10 @@
{#snippet trigger()}
- {#if syncState === 'saving' || syncState === 'pending'} + {#if editingOtherUserDraft} + + + {:else if syncState === 'saving' || syncState === 'pending'} {:else if syncState === 'failed'} @@ -254,61 +277,77 @@ {#snippet content()}
- {#if syncState === 'failed'} -
-

Save failed

- {#if failureMessage} -
{failureMessage}
- {/if} -
- {/if} - {#if autosaveEnabled} + {#if editingOtherUserDraft}

- All changes are saved as a draft on the server. The draft is per-user — your teammates' - editors keep their own. + You're editing {otherDraftOwnerLabel ?? 'another user'}'s draft. Auto-save is paused — + your own draft is untouched. Editing prompts before overwriting it.

- {:else} -

- Auto-save is off — changes only persist when you press Ctrl/Cmd+S. The draft is per-user - — your teammates' editors keep their own. -

- {/if} - { - UserDraftDbSyncer.autosaveEnabled = e.detail - }} - /> - {#if othersDraftsCount > 0} -
-

- Other users are working on this {kindLabel}. -

- -
- {/if} - {#if showResetAction} + {:else} + {#if syncState === 'failed'} +
+

Save failed

+ {#if failureMessage} +
{failureMessage}
+ {/if} +
+ {/if} + {#if autosaveEnabled} +

+ All changes are saved as a draft on the server. The draft is per-user — your + teammates' editors keep their own. +

+ {:else} +

+ Auto-save is off — changes only persist when you press Ctrl/Cmd+S. The draft is + per-user — your teammates' editors keep their own. +

+ {/if} + { + UserDraftDbSyncer.autosaveEnabled = e.detail + }} + /> + {#if othersDraftsCount > 0} +
+

+ Other users are working on this {kindLabel}. +

+ +
+ {/if} + {#if showResetAction} + + {/if} {/if}
{/snippet} diff --git a/frontend/src/lib/components/CompareDrafts.svelte b/frontend/src/lib/components/CompareDrafts.svelte index 4afbd843b3..cc450a51fb 100644 --- a/frontend/src/lib/components/CompareDrafts.svelte +++ b/frontend/src/lib/components/CompareDrafts.svelte @@ -2,19 +2,22 @@ import WorkspaceDeployLayout from './WorkspaceDeployLayout.svelte' import DiffDrawer from './DiffDrawer.svelte' import WorkspaceDeployItemSummary from './WorkspaceDeployItemSummary.svelte' + import DraftBadge from './DraftBadge.svelte' + import Toggle from './Toggle.svelte' + import Popover from './meltComponents/Popover.svelte' import { Badge } from './common' - import Tooltip from './meltComponents/Tooltip.svelte' import Button from './common/button/Button.svelte' import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte' - import { ArrowRight, DiffIcon, GitFork, Pencil, Undo2 } from 'lucide-svelte' + import { AlertTriangle, ArrowRight, DiffIcon, GitFork, Pencil, Undo2 } from 'lucide-svelte' import { untrack } from 'svelte' import CompareModeToggle, { type CompareMode } from './CompareModeToggle.svelte' import { editUrlFor } from './sessions/forkEditUrl' import { AppService, FlowService, ScriptService, type WorkspaceItemDiff } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { getDraftDiffValues, deployDraft, discardDraft } from '$lib/utils_draft_deploy' - import { type DraftItem } from '$lib/workspaceDrafts.svelte' + import { type DraftItem, useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' import type { Kind as LayoutKind } from '$lib/utils_deployable' + import { userStore } from '$lib/stores' interface Props { currentWorkspaceId: string @@ -68,6 +71,12 @@ legacy_draft: boolean raw_app: boolean key: string + can_write: boolean + draft_users?: { username?: string | null }[] + /** The row is my own draft (or the legacy no-owner row) — only then is it + * actionable. Other users' rows (shown when "Show all drafts" is on) are + * view-only: you can't deploy/discard someone else's draft. */ + mine: boolean } function getItemKey(kind: string, path: string): string { return `${kind}:${path}` @@ -98,12 +107,25 @@ return kind as LayoutKind } - // The list (and the Draft Count) come from the shared Workspace Drafts module, - // owned by the page and passed in via `draftItems`; deploy/discard invalidate - // that resource, so the list refetches and deployed items drop off without a - // manual reload here. + // "Show all drafts" widens the list from my own (+ legacy) to every user's + // drafts in the workspace. Off by default. The default view reuses the page's + // shared Workspace Drafts resource (passed in via `draftItems`); the "all + // users" superset is fetched lazily here via its own resource — only while the + // toggle is on (workspace() is undefined otherwise, so no fetch) — and shares + // the same invalidation, so a deploy/discard refetches both. + let showAll = $state(false) + const allDrafts = useWorkspaceDrafts( + () => (showAll ? currentWorkspaceId : undefined), + () => true + ) + const sourceItems = $derived(showAll ? allDrafts.items : draftItems) + const loading = $derived(showAll ? allDrafts.loading : draftsLoading) + + // The list (and, in the default view, the Draft Count) come from the Workspace + // Drafts module; deploy/discard invalidate the resource, so the list refetches + // and deployed items drop off without a manual reload here. const items: Row[] = $derived( - draftItems.map((d) => ({ + sourceItems.map((d) => ({ ...d, key: getItemKey(d.kind, d.path), kind: toLayoutKind(d.kind), @@ -113,13 +135,53 @@ })) ) + const currentUsername = $derived($userStore?.username) + + // Other real users (not me, not the legacy NULL-email row) who also drafted + // this path. Only the shared full-page-editor kinds carry draft_users, so this + // is naturally empty for drawer kinds. Deploying only deploys my own draft, so + // a non-empty list warrants the triangle warning. + function otherDraftUsers(row: Row): string[] { + return (row.draft_users ?? []) + .map((u) => u.username) + .filter((u): u is string => !!u && u !== currentUsername) + } + + // The backend already returns exactly the rows for the current view (own + + // legacy, or every user's with "Show all drafts"), so there's no client-side + // filtering — `visibleItems` is just the mapped list. + const visibleItems = $derived(items) + + // A row is actionable when it isn't already deployed this session, the user has + // write permission, AND it's their own draft (you can't deploy someone else's + // draft — those show view-only in the "all drafts" view). The server enforces + // the same; this keeps the UI honest. A data-pipeline bundle is never deployable + // from this page — its scripts deploy individually inside the pipeline view — so + // it's excluded from every selection path. + function isSelectable(item: Row): boolean { + return ( + deploymentStatus[item.key]?.status !== 'deployed' && + item.can_write && + item.mine && + item.draftKind !== 'data_pipeline' + ) + } + + // Why a row can't be deployed/discarded (drives the disabled-checkbox tooltip + // and the Discard button's title). `undefined` ⇒ actionable. + function blockedReason(item: Row): string | undefined { + if (!item.mine) return 'This draft belongs to another user' + if (!item.can_write) return "You don't have write permission on this path" + return undefined + } + // The Draft Items list only carries the *deployed* summary, so the draft's // (new) display name isn't known yet. Fetch each item's draft blob once and // cache both names — mirrors CompareWorkspaces' fetchSummaries (eager on load, // keyed by row key) so the rename rendering is shared and consistent. Only // non-`draft_only` items can show a rename: a `draft_only` item has no deployed - // side to diff the name against. Raw apps live on a separate route and aren't - // fetchable here, so they're skipped (no rename shown, same as before). + // side to diff the name against. Raw apps are fetched via the apps endpoint too + // (it auto-detects raw from the deployed row and overlays the raw_app draft). const summaryCache = $state< Record >({}) @@ -161,9 +223,9 @@ untrack(() => { for (const item of current) { if ( + item.mine && !item.draft_only && - !item.raw_app && - ['script', 'flow', 'app'].includes(item.draftKind) && + (['script', 'flow', 'app'].includes(item.draftKind) || item.raw_app) && !summaryCache[item.key] ) { void fetchDraftSummary(item) @@ -172,13 +234,6 @@ }) }) - // A data-pipeline bundle isn't deployable from this page — deploy happens - // per-script inside the pipeline view. Exclude it from every selection path - // so the bulk "Deploy N drafts" never tries to deploy a bundle. - function isRowDeployable(i: { key: string; draftKind: Row['draftKind'] }): boolean { - return deploymentStatus[i.key]?.status !== 'deployed' && i.draftKind !== 'data_pipeline' - } - let selectedItems = $state([]) let deploying = $state(false) // Select all on the first non-empty load (deploy-all is the common intent); @@ -204,23 +259,23 @@ }) $effect(() => { - if (!hasAutoSelected && items.length > 0) { - selectedItems = items.filter(isRowDeployable).map((i) => i.key) + if (!hasAutoSelected && visibleItems.length > 0) { + selectedItems = visibleItems.filter(isSelectable).map((i) => i.key) hasAutoSelected = true } }) - // Selected items still in the live list and deployable. Derived (not a pruning - // effect) so the "Deploy N drafts" button stays reactive to the Workspace - // Drafts resource: deploy/discard drop items, and stale keys left in + // Selected items still in the visible list and deployable. Derived (not a + // pruning effect) so the "Deploy N drafts" button stays reactive to the + // Workspace Drafts resource: deploy/discard drop items, and stale keys left in // selectedItems are simply ignored here (and by deploySelected). let selectedCount = $derived( - items.filter((i) => selectedItems.includes(i.key) && isRowDeployable(i)).length + visibleItems.filter((i) => selectedItems.includes(i.key) && isSelectable(i)).length ) let allSelected = $derived( - items.length > 0 && - items.filter(isRowDeployable).every((i) => selectedItems.includes(i.key)) + visibleItems.filter(isSelectable).length > 0 && + visibleItems.filter(isSelectable).every((i) => selectedItems.includes(i.key)) ) function toggleItem(item: { key: string }) { @@ -232,7 +287,7 @@ } function selectAll() { - selectedItems = items.filter(isRowDeployable).map((i) => i.key) + selectedItems = visibleItems.filter(isSelectable).map((i) => i.key) } function deselectAll() { @@ -271,8 +326,9 @@ async function deploySelected() { deploying = true // Snapshot the items to deploy: deployDraft invalidates the Workspace Drafts - // resource, so `items` can change mid-loop — iterate a stable copy. - const toDeploy = items.filter((i) => selectedItems.includes(i.key)) + // resource, so `items` can change mid-loop — iterate a stable copy. Guard on + // isSelectable so a non-writable row can never be deployed via a stale key. + const toDeploy = visibleItems.filter((i) => selectedItems.includes(i.key) && isSelectable(i)) let deployedAny = false for (const item of toDeploy) { deploymentStatus[item.key] = { status: 'loading' } @@ -300,12 +356,34 @@ } // --- Discard --- + // Only one discard is destructive: removing the last draft of a never-deployed + // item (draft_only, and no other user still holds a draft) permanently deletes + // the item, so it gets a confirmation. Every other discard just reverts to the + // deployed version or removes your own copy while another draft remains — those + // run immediately (the row already carries the ⚠️ for the multi-user case). let discardTarget = $state(undefined) - async function confirmDiscard() { - const item = discardTarget - discardTarget = undefined - if (!item) return + function isDestructiveDiscard(item: Row): boolean { + // A deployed counterpart exists → discard just reverts, never deletes. + if (!item.draft_only) return false + // draft_only → discarding deletes the item, UNLESS another real user still + // holds a draft of it. Guard on `currentUsername`: if we don't yet know who + // "me" is, `otherDraftUsers` would count my own row as someone else's, so + // fall back to treating it as a delete (confirm) rather than risk a silent + // deletion. + if (!currentUsername) return true + return otherDraftUsers(item).length === 0 + } + + function onDiscardClick(item: Row) { + if (isDestructiveDiscard(item)) { + discardTarget = item + } else { + void doDiscard(item) + } + } + + async function doDiscard(item: Row) { const res = await discardDraft( item.draftKind, item.path, @@ -322,6 +400,12 @@ } } + function confirmDiscard() { + const item = discardTarget + discardTarget = undefined + if (item) void doDiscard(item) + } + // Editor URL for a draft item, scoped to the current workspace. Raw apps live // under a different editor route, so map their kind accordingly. Kinds whose // editor is a drawer on a list page (variables, resources, schedules, @@ -406,16 +490,33 @@
isRowDeployable(item as unknown as Row)} + selectablePredicate={(item) => isSelectable(item as unknown as Row)} + selectBlockedReason={(item) => blockedReason(item as unknown as Row)} onToggleItem={toggleItem} onSelectAll={selectAll} onDeselectAll={deselectAll} - emptyMessage={draftsLoading ? 'Loading drafts…' : 'No drafts in this workspace'} + emptyMessage={loading + ? 'Loading drafts…' + : showAll + ? 'No drafts in this workspace' + : 'No drafts you authored in this workspace'} > + {#snippet selectAllActions()} + + {/snippet} + {#snippet header()} {#if isFork}
@@ -461,26 +562,55 @@ {oldSummary} {newSummary} renamed={!draftItem.draft_only && - oldSummary != null && - newSummary != null && + !!oldSummary && + !!newSummary && oldSummary !== newSummary} /> {/snippet} + {#snippet itemPath(item)} + {@const draftItem = item as unknown as Row} + {#if draftItem.kind === 'resource' || draftItem.kind === 'variable' || draftItem.kind === 'resource_type'} + + {:else if !draftItem.draft_only && draftItem.draft_path && draftItem.draft_path !== draftItem.path} + + {draftItem.path} + {draftItem.draft_path} + {:else} + {draftItem.draft_path ?? draftItem.path} + {/if} + {/snippet} + {#snippet itemActions(item)} {@const draftItem = item as unknown as Row} + {@const others = otherDraftUsers(draftItem)} {kindLabel(draftItem.draftKind)} - {#if draftItem.draft_only} - New - {/if} - {#if draftItem.legacy_draft} - - Legacy draft - {#snippet text()} - A legacy draft predates the per-user drafts migration: it isn't tied to any user - (workspace-level, email NULL), so everyone with access to this path sees it. + + {#if draftItem.mine && others.length > 0} + + {#snippet trigger()} + {/snippet} - + {#snippet content()} +
+ {others.length} other {others.length === 1 ? 'user' : 'users'} ({others.join(', ')}) + {others.length === 1 ? 'has' : 'have'} a draft of this item. Deploying only deploys your + draft; theirs are left untouched. +
+ {/snippet} + {/if} {#if deploymentStatus[draftItem.key]?.status !== 'deployed'} {#if draftItem.draftKind === 'data_pipeline'} @@ -488,29 +618,42 @@ individually inside the pipeline view. --> {@const openUrl = draftEditUrl(draftItem)} {#if openUrl} - {/if} {:else} + {@const discardBlock = blockedReason(draftItem)} + + {#if draftItem.mine} + + {/if} {/if} - {/if} {/snippet} @@ -532,23 +675,18 @@
+ (discardTarget = undefined)} > - {#if discardTarget?.draft_only} -

- {discardTarget?.path} exists only as a - draft. Discarding it will permanently delete the item. This cannot be undone. -

- {:else} -

- Discard the draft of - {discardTarget?.path}? The deployed - version is unaffected. -

- {/if} +

+ {discardTarget?.draft_path ?? discardTarget?.path} exists only as a draft. Discarding it will permanently delete the item. This cannot be undone. +

diff --git a/frontend/src/lib/components/CustomOauth.svelte b/frontend/src/lib/components/CustomOauth.svelte index 432a02152b..942dc553d7 100644 --- a/frontend/src/lib/components/CustomOauth.svelte +++ b/frontend/src/lib/components/CustomOauth.svelte @@ -1,12 +1,12 @@