From f0659a755a161420833e3bfdbe04befc6ebeb977 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 12 Jun 2026 17:20:54 +0200 Subject: [PATCH 001/246] fix(cli): consistent flow inline lock filenames for compound extensions (#9555) Co-authored-by: Claude Fable 5 --- cli/src/commands/flow/flow_metadata.ts | 24 ++++++- ..._scripts_failure_preprocessor_unit.test.ts | 65 ++++++++++++++++++- .../src/inline-scripts/extractor.ts | 37 ++++++++++- 3 files changed, 121 insertions(+), 5 deletions(-) diff --git a/cli/src/commands/flow/flow_metadata.ts b/cli/src/commands/flow/flow_metadata.ts index 5c3ca578c7..f088a34fbc 100644 --- a/cli/src/commands/flow/flow_metadata.ts +++ b/cli/src/commands/flow/flow_metadata.ts @@ -1,5 +1,7 @@ import { colors } from "@cliffy/ansi/colors"; import * as log from "../../core/log.ts"; +import { existsSync } from "node:fs"; +import { rm } from "node:fs/promises"; import * as path from "node:path"; import { sep as SEP } from "node:path"; import { stringify as yamlStringify } from "yaml"; @@ -17,7 +19,7 @@ import { filterWorkspaceDependenciesForScripts, } from "../../utils/metadata.ts"; import { ScriptLanguage } from "../../utils/script_common.ts"; -import { extractInlineScripts as extractInlineScriptsForFlows, extractCurrentMapping } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts"; +import { extractInlineScripts as extractInlineScriptsForFlows, extractCurrentMapping, legacyLockPathForContent } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts"; import { newPathAssigner } from "../../../windmill-utils-internal/src/path-utils/path-assigner.ts"; import { generateHash, getHeaders, readTextFile, writeIfChanged } from "../../utils/utils.ts"; @@ -339,6 +341,26 @@ export async function generateFlowLockInternal( writeIfChanged(process.cwd() + SEP + folder + SEP + s.path, s.content); }); + // CLI versions between #8561 and the canonical-lock-name fix named lock + // files after the content path minus only its last dot segment (e.g. + // "x.inline_script.deno.lock"). The canonical name strips the full + // language extension ("x.inline_script.lock"), so remove the legacy file + // once its replacement has been written above. + for (const s of inlineScripts) { + if (s.is_lock) continue; + const legacyRelPath = legacyLockPathForContent(s.path, s.language); + if (!legacyRelPath) continue; + const legacyAbsPath = process.cwd() + SEP + folder + SEP + legacyRelPath; + if (existsSync(legacyAbsPath)) { + try { + await rm(legacyAbsPath); + log.info(colors.gray(`Removed legacy lock file ${legacyRelPath} (renamed to canonical name)`)); + } catch (e) { + log.info(colors.yellow(`Failed to remove legacy lock file ${legacyRelPath}: ${e}`)); + } + } + } + // Overwrite `flow.yaml` with the new lockfile references writeIfChanged( process.cwd() + SEP + folder + SEP + "flow.yaml", diff --git a/cli/test/inline_scripts_failure_preprocessor_unit.test.ts b/cli/test/inline_scripts_failure_preprocessor_unit.test.ts index a5d1298f15..136fc2c413 100644 --- a/cli/test/inline_scripts_failure_preprocessor_unit.test.ts +++ b/cli/test/inline_scripts_failure_preprocessor_unit.test.ts @@ -8,7 +8,7 @@ */ import { expect, test, describe } from "bun:test"; -import { extractInlineScripts, extractCurrentMapping } from "../windmill-utils-internal/src/inline-scripts/extractor.ts"; +import { extractInlineScripts, extractCurrentMapping, legacyLockPathForContent } from "../windmill-utils-internal/src/inline-scripts/extractor.ts"; import { replaceInlineScripts } from "../windmill-utils-internal/src/inline-scripts/replacer.ts"; import { newPathAssigner } from "../windmill-utils-internal/src/path-utils/path-assigner.ts"; import type { FlowModule } from "../windmill-utils-internal/src/gen/types.gen.ts"; @@ -573,6 +573,69 @@ describe("extractInlineScripts with mapping preserves file paths", () => { const lockScript = scripts.find((s) => s.is_lock); expect(lockScript!.path).toBe("my.inline_script.lock"); }); + + test("lock path strips compound language extension from mapped path (deno)", () => { + const mod = makeRawscriptModule("a", "code", "deno", "lock-content"); + + const mapping = { a: "my.inline_script.deno.ts" }; + const scripts = extractInlineScripts([mod], mapping, "/", "bun"); + + const lockScript = scripts.find((s) => s.is_lock); + expect(lockScript!.path).toBe("my.inline_script.lock"); + expect((mod.value as any).lock).toBe("!inline my.inline_script.lock"); + }); + + test("deno lock name is identical with and without mapping", () => { + const summary = "My Step"; + const makeMod = () => { + const m = makeRawscriptModule("a", "code", "deno", "lock-content"); + m.summary = summary; + return m; + }; + + const unmapped = extractInlineScripts([makeMod()], {}, "/", "bun"); + const contentPath = unmapped.find((s) => !s.is_lock)!.path; + const unmappedLock = unmapped.find((s) => s.is_lock)!.path; + + // Re-extract with the content path mapped (as flow generate-locks does) + const mapped = extractInlineScripts([makeMod()], { a: contentPath }, "/", "bun"); + const mappedLock = mapped.find((s) => s.is_lock)!.path; + + expect(contentPath).toBe("my_step.inline_script.deno.ts"); + expect(mappedLock).toBe(unmappedLock); + expect(mappedLock).toBe("my_step.inline_script.lock"); + }); + + test("collapsed ts extension (language == defaultTs) still strips correctly", () => { + const mod = makeRawscriptModule("a", "code", "deno", "lock-content"); + + // defaultTs=deno: content file uses plain .ts + const mapping = { a: "my.inline_script.ts" }; + const scripts = extractInlineScripts([mod], mapping, "/", "deno"); + + const lockScript = scripts.find((s) => s.is_lock); + expect(lockScript!.path).toBe("my.inline_script.lock"); + }); +}); + +// --------------------------------------------------------------------------- +// legacyLockPathForContent — migration helper for pre-fix lock names +// --------------------------------------------------------------------------- + +describe("legacyLockPathForContent", () => { + test("returns the last-segment-stripped name for compound extensions", () => { + expect(legacyLockPathForContent("my.inline_script.deno.ts", "deno")).toBe( + "my.inline_script.deno.lock", + ); + expect(legacyLockPathForContent("step.inline_script.pg.sql", "postgresql")).toBe( + "step.inline_script.pg.lock", + ); + }); + + test("returns undefined when legacy and canonical names coincide", () => { + expect(legacyLockPathForContent("my.inline_script.ts", "deno")).toBeUndefined(); + expect(legacyLockPathForContent("my.inline_script.py", "python3")).toBeUndefined(); + }); }); // --------------------------------------------------------------------------- diff --git a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts index f556b32c57..e29d0167ba 100644 --- a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts +++ b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts @@ -1,4 +1,4 @@ -import { newPathAssigner, PathAssigner } from "../path-utils/path-assigner"; +import { LANGUAGE_EXTENSIONS, newPathAssigner, PathAssigner } from "../path-utils/path-assigner"; import { FlowModule, RawScript, ScriptLang } from "../gen/types.gen"; /** @@ -48,9 +48,8 @@ function extractRawscriptInline( if (lock && lock != "") { // Derive lock path base from the mapped content path when available, // so lock files are named consistently with their content files. - const dotIdx = mappedPath ? mappedPath.lastIndexOf('.') : -1; const lockBasePath = mappedPath - ? (dotIdx > 0 ? mappedPath.substring(0, dotIdx + 1) : mappedPath + '.') + ? lockBasePathForContent(mappedPath, language) : basePath; const lockPath = lockBasePath + "lock"; rawscript.lock = "!inline " + lockPath.replaceAll(separator, "/"); @@ -59,6 +58,38 @@ function extractRawscriptInline( return r; } +/** + * Derive the lock path base (including trailing dot) from an inline script's + * content path. The full language extension must be stripped — for compound + * extensions like "deno.ts", stripping only the last dot segment would yield + * "x.inline_script.deno.lock" while the assigner-based branch (no mapping, + * e.g. fresh sync pull) yields "x.inline_script.lock", flip-flopping the lock + * filename between operations. See legacyLockPathForContent for the old name. + */ +function lockBasePathForContent(contentPath: string, language: ScriptLang): string { + const langExt = LANGUAGE_EXTENSIONS[language]; + if (langExt && contentPath.endsWith("." + langExt)) { + return contentPath.substring(0, contentPath.length - langExt.length); + } + // Collapsed "ts" (language == defaultTs) or a custom extension: a single + // dot segment is the whole extension. + const dotIdx = contentPath.lastIndexOf("."); + return dotIdx > 0 ? contentPath.substring(0, dotIdx + 1) : contentPath + "."; +} + +/** + * Lock path that CLI versions between #8561 and this fix produced for a given + * content path (last dot segment stripped, e.g. "x.inline_script.deno.lock"). + * Returns undefined when it matches the canonical name. Callers use this to + * clean up the stale legacy file after writing the canonical one. + */ +export function legacyLockPathForContent(contentPath: string, language: ScriptLang): string | undefined { + const dotIdx = contentPath.lastIndexOf("."); + const legacy = (dotIdx > 0 ? contentPath.substring(0, dotIdx + 1) : contentPath + ".") + "lock"; + const canonical = lockBasePathForContent(contentPath, language) + "lock"; + return legacy === canonical ? undefined : legacy; +} + /** * Options for extractInlineScripts function */ From 358571687296fbe5c378533b3c1662707955c64a Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:21:43 +0200 Subject: [PATCH 002/246] fix: stop sending temperature for AI chat across all providers (#9553) * fix: stop sending temperature for AI chat across all providers Remove the temperature field from all AI chat completion requests and drop the model-based send-or-not special-casing. Previously the chat sent temperature: 0 for determinism and omitted it for reasoning models (claude-opus-4-7/4-8, gpt-5+, o-series) that reject sampling params, which required hand-maintaining a growing model list. The model-detection helper is kept (renamed modelDisallowsSamplingParams -> requiresMaxCompletionTokens) since it still serves a separate concern: choosing max_completion_tokens over max_tokens for OpenAI/Azure reasoning models on the Chat Completions API. The FIM autocomplete temperature: 0 is intentionally left untouched (it is the code-autocomplete path, not the chat). Co-Authored-By: Claude Fable 5 * refactor: drop dead claude-* branches from requiresMaxCompletionTokens After temperature removal, requiresMaxCompletionTokens only governs the max_completion_tokens vs max_tokens choice for OpenAI/Azure reasoning models (its sole call site is gated on provider === openai|azure_openai). The retained claude-* branches were leftovers from when the function omitted temperature for Anthropic reasoning models; they are unreachable for the max_completion_tokens decision and made the kept detection tests assert a semantically false claim. Claude reasoning behavior is owned by reasoningRegistry. Drop the dead branches and their tests so name, comment, code, and tests agree. Behaviorally inert. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../lib/components/copilot/chat/anthropic.ts | 7 +- .../copilot/chat/openai-responses.ts | 4 - .../src/lib/components/copilot/lib.test.ts | 97 ++++--------------- frontend/src/lib/components/copilot/lib.ts | 6 +- .../src/lib/components/copilot/modelConfig.ts | 33 ++----- 5 files changed, 32 insertions(+), 115 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/anthropic.ts b/frontend/src/lib/components/copilot/chat/anthropic.ts index 753c0b04d1..48b068039f 100644 --- a/frontend/src/lib/components/copilot/chat/anthropic.ts +++ b/frontend/src/lib/components/copilot/chat/anthropic.ts @@ -78,16 +78,15 @@ export async function getAnthropicCompletion( const client = options?.anthropicClient ?? workspaceAIClients.getAnthropicClient() - // Adds output_config.effort + adaptive thinking (and strips temperature) when an - // effort is set; no-op otherwise. Returns the base shape unchanged when off. + // Adds output_config.effort + adaptive thinking when an effort is set; + // no-op otherwise. Returns the base shape unchanged when off. const anthropicParams = applyReasoningToConfig( { model: config.model, max_tokens: config.max_tokens as number, messages: anthropicMessages, ...(system && { system }), - ...(anthropicTools && { tools: anthropicTools }), - ...(typeof config.temperature === 'number' && { temperature: config.temperature }) + ...(anthropicTools && { tools: anthropicTools }) }, 'anthropic', options?.reasoningEffort diff --git a/frontend/src/lib/components/copilot/chat/openai-responses.ts b/frontend/src/lib/components/copilot/chat/openai-responses.ts index e99591d379..eb1dd2b2d5 100644 --- a/frontend/src/lib/components/copilot/chat/openai-responses.ts +++ b/frontend/src/lib/components/copilot/chat/openai-responses.ts @@ -130,10 +130,6 @@ function convertCompletionConfigToResponsesConfig( responsesConfig.max_output_tokens = config.max_tokens } - // Keep other relevant fields - if (config.temperature !== undefined) { - responsesConfig.temperature = config.temperature - } if ('tools' in config && config.tools && config.tools.length > 0) { responsesConfig.tools = config.tools.map((tool) => { if (tool.type === 'function' && 'function' in tool) { diff --git a/frontend/src/lib/components/copilot/lib.test.ts b/frontend/src/lib/components/copilot/lib.test.ts index 15b09151b6..9afdf013f3 100644 --- a/frontend/src/lib/components/copilot/lib.test.ts +++ b/frontend/src/lib/components/copilot/lib.test.ts @@ -10,7 +10,7 @@ import { getReasoningContentDelta } from './chat/openaiReasoning' import { parseFimCompletionChoice } from './fim' -import { getDefaultChatTemperature, modelDisallowsSamplingParams } from './modelConfig' +import { requiresMaxCompletionTokens } from './modelConfig' import { supportsAutocomplete } from './utils' type AssistantMessageWithReasoning = ChatCompletionMessageParam & { @@ -21,91 +21,32 @@ type AssistantMessageWithReasoning = ChatCompletionMessageParam & { } describe('modelConfig', () => { - it('flags Fable 5 model IDs via includes matching', () => { - expect(modelDisallowsSamplingParams('claude-fable-5')).toBe(true) - expect(modelDisallowsSamplingParams('claude-fable-5@20260611')).toBe(true) - expect(modelDisallowsSamplingParams('claude-fable-5/thinking')).toBe(true) - expect(modelDisallowsSamplingParams('anthropic/claude-fable-5')).toBe(true) - }) - - it('flags Opus 4.7 model IDs via includes matching', () => { - expect(modelDisallowsSamplingParams('claude-opus-4-7')).toBe(true) - expect(modelDisallowsSamplingParams('claude-opus-4-7@20260416')).toBe(true) - expect(modelDisallowsSamplingParams('claude-opus-4-7/thinking')).toBe(true) - expect(modelDisallowsSamplingParams('anthropic/claude-opus-4-7')).toBe(true) - }) - - it('flags Opus 4.8 model IDs via includes matching', () => { - expect(modelDisallowsSamplingParams('claude-opus-4-8')).toBe(true) - expect(modelDisallowsSamplingParams('claude-opus-4-8@20260416')).toBe(true) - expect(modelDisallowsSamplingParams('claude-opus-4-8/thinking')).toBe(true) - expect(modelDisallowsSamplingParams('anthropic/claude-opus-4-8')).toBe(true) - }) - - it('omits deterministic temperature for Anthropic Opus 4.7 chat requests', () => { - expect( - getDefaultChatTemperature({ provider: 'anthropic', model: 'claude-opus-4-7' }) - ).toBeUndefined() - }) - - it('omits deterministic temperature for Anthropic Fable 5 chat requests', () => { - expect( - getDefaultChatTemperature({ provider: 'anthropic', model: 'claude-fable-5' }) - ).toBeUndefined() - }) - - it('omits deterministic temperature for non-anthropic providers carrying Opus 4.7 models', () => { - expect( - getDefaultChatTemperature({ provider: 'openrouter', model: 'anthropic/claude-opus-4-7' }) - ).toBeUndefined() - }) - - it('keeps deterministic temperature for older Anthropic models', () => { - expect(getDefaultChatTemperature({ provider: 'anthropic', model: 'claude-sonnet-4-6' })).toBe(0) - }) - it('flags gpt-5+ and o-series reasoning models via prefix matching', () => { - expect(modelDisallowsSamplingParams('gpt-5')).toBe(true) - expect(modelDisallowsSamplingParams('gpt-5.5')).toBe(true) - expect(modelDisallowsSamplingParams('gpt-5-mini')).toBe(true) - expect(modelDisallowsSamplingParams('o1')).toBe(true) - expect(modelDisallowsSamplingParams('o3')).toBe(true) - expect(modelDisallowsSamplingParams('o4-mini')).toBe(true) + expect(requiresMaxCompletionTokens('gpt-5')).toBe(true) + expect(requiresMaxCompletionTokens('gpt-5.5')).toBe(true) + expect(requiresMaxCompletionTokens('gpt-5-mini')).toBe(true) + expect(requiresMaxCompletionTokens('o1')).toBe(true) + expect(requiresMaxCompletionTokens('o3')).toBe(true) + expect(requiresMaxCompletionTokens('o4-mini')).toBe(true) // provider-prefixed identifiers (e.g. OpenRouter) match on the bare model id - expect(modelDisallowsSamplingParams('openai/gpt-5')).toBe(true) - expect(modelDisallowsSamplingParams('openai/o3')).toBe(true) + expect(requiresMaxCompletionTokens('openai/gpt-5')).toBe(true) + expect(requiresMaxCompletionTokens('openai/o3')).toBe(true) }) - it('keeps sampling params for non-reasoning models that merely share a prefix', () => { + it('does not require max_completion_tokens for non-reasoning models that merely share a prefix', () => { // gpt-4o starts with "gpt-" but not "gpt-5"; the "o" is mid-string, not a prefix - expect(modelDisallowsSamplingParams('gpt-4o')).toBe(false) - expect(modelDisallowsSamplingParams('gpt-4o-mini')).toBe(false) + expect(requiresMaxCompletionTokens('gpt-4o')).toBe(false) + expect(requiresMaxCompletionTokens('gpt-4o-mini')).toBe(false) // the provider prefix "openai/" must not be mistaken for an o-series model - expect(modelDisallowsSamplingParams('openai/gpt-4o')).toBe(false) + expect(requiresMaxCompletionTokens('openai/gpt-4o')).toBe(false) // the o-series match requires a digit after "o", so non-OpenAI ids that // start with "o" (Mistral open-* family, OpenRouter optimus-*/openchat-*) - // keep their deterministic temperature - expect(modelDisallowsSamplingParams('open-mistral-7b')).toBe(false) - expect(modelDisallowsSamplingParams('open-mixtral-8x7b')).toBe(false) - expect(modelDisallowsSamplingParams('open-mistral-nemo-2407')).toBe(false) - expect(modelDisallowsSamplingParams('optimus-alpha')).toBe(false) - expect(modelDisallowsSamplingParams('openchat/openchat-7b')).toBe(false) - }) - - it('keeps deterministic temperature for Mistral open-* models', () => { - expect(getDefaultChatTemperature({ provider: 'mistral', model: 'open-mixtral-8x7b' })).toBe(0) - }) - - it('omits deterministic temperature for gpt-5.5 routed through the customai gateway', () => { - expect(getDefaultChatTemperature({ provider: 'customai', model: 'gpt-5.5' })).toBeUndefined() - }) - - it('omits deterministic temperature for o-series models on the customai gateway', () => { - expect(getDefaultChatTemperature({ provider: 'customai', model: 'o3' })).toBeUndefined() - }) - - it('keeps deterministic temperature for gpt-4o on the customai gateway', () => { - expect(getDefaultChatTemperature({ provider: 'customai', model: 'gpt-4o' })).toBe(0) + // do not require max_completion_tokens + expect(requiresMaxCompletionTokens('open-mistral-7b')).toBe(false) + expect(requiresMaxCompletionTokens('open-mixtral-8x7b')).toBe(false) + expect(requiresMaxCompletionTokens('open-mistral-nemo-2407')).toBe(false) + expect(requiresMaxCompletionTokens('optimus-alpha')).toBe(false) + expect(requiresMaxCompletionTokens('openchat/openchat-7b')).toBe(false) }) }) diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index 2977da328a..0b9e916947 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -14,7 +14,7 @@ import Anthropic from '@anthropic-ai/sdk' import { get, type Writable } from 'svelte/store' import { OpenAPI, ResourceService, type Script } from '../../gen' import { EDIT_CONFIG, FIX_CONFIG, GEN_CONFIG } from './prompts' -import { getDefaultChatTemperature, modelDisallowsSamplingParams } from './modelConfig' +import { requiresMaxCompletionTokens } from './modelConfig' import { applyReasoningToConfig } from './reasoningRegistry' import { formatResourceTypes } from './utils' import { processToolCall, type Tool, type ToolCallbacks } from './chat/shared' @@ -315,10 +315,9 @@ function getModelSpecificConfig( // copilotInfo store may not be initialized in vitest } const maxTokens = customMaxTokensStore?.[modelKey] ?? defaultMaxTokens - const defaultTemperature = getDefaultChatTemperature(modelProvider) if ( (modelProvider.provider === 'openai' || modelProvider.provider === 'azure_openai') && - modelDisallowsSamplingParams(modelProvider.model) + requiresMaxCompletionTokens(modelProvider.model) ) { return { model: modelProvider.model, @@ -328,7 +327,6 @@ function getModelSpecificConfig( } else { return { model: modelProvider.model, - ...(defaultTemperature !== undefined ? { temperature: defaultTemperature } : {}), ...(tools && tools.length > 0 ? { tools } : {}), max_tokens: maxTokens } diff --git a/frontend/src/lib/components/copilot/modelConfig.ts b/frontend/src/lib/components/copilot/modelConfig.ts index 7b228c6ba9..726ff4e387 100644 --- a/frontend/src/lib/components/copilot/modelConfig.ts +++ b/frontend/src/lib/components/copilot/modelConfig.ts @@ -1,28 +1,11 @@ -import type { AIProviderModel } from '$lib/gen' - -export function modelDisallowsSamplingParams(model: string) { +// gpt-5+ and o-series reasoning models reject the legacy `max_tokens` field on +// the OpenAI/Azure Chat Completions API and require `max_completion_tokens` +// instead. The check strips any provider prefix (e.g. OpenRouter's "openai/o3") +// so it matches the bare model id, and the o-series match requires a digit after +// the "o" (o1/o3/o4-mini) so it does not catch unrelated ids like Mistral's +// "open-mistral-*" or "optimus-*". +export function requiresMaxCompletionTokens(model: string) { const normalizedModel = model.toLowerCase() - // Strip any provider prefix (e.g. OpenRouter's "openai/o3") so the - // reasoning-model check matches the bare model id rather than the prefix. const baseModel = normalizedModel.split('/').pop() ?? normalizedModel - // gpt-5+ and o-series reasoning models reject sampling params such as - // temperature (only the default value is supported), regardless of which - // provider/gateway routes the request — so this must stay provider-agnostic. - // The o-series match requires a digit after the "o" (o1/o3/o4-mini) so it - // does not catch unrelated ids like Mistral's "open-mistral-*" or "optimus-*". - return ( - normalizedModel.includes('claude-fable-5') || - normalizedModel.includes('claude-opus-4-7') || - normalizedModel.includes('claude-opus-4-8') || - baseModel.startsWith('gpt-5') || - /^o\d/.test(baseModel) - ) -} - -export function getDefaultChatTemperature(modelProvider: AIProviderModel): number | undefined { - if (modelDisallowsSamplingParams(modelProvider.model)) { - return undefined - } - - return 0 + return baseModel.startsWith('gpt-5') || /^o\d/.test(baseModel) } From 4e9e0c024b4b95f9676b1646591d8f0c662e84ab Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 12 Jun 2026 17:22:46 +0200 Subject: [PATCH 003/246] feat(cli): add --yes, --secret/--no-secret and --description to variable add (#9548) * feat(cli): add --yes, --secret/--no-secret and --description to variable add Co-Authored-By: Claude Fable 5 * test(cli): cover variable add create/update flag semantics Co-Authored-By: Claude Fable 5 * fix(cli): warn on secret downgrade in variable add and pin preserve semantics in test Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- cli/src/commands/variable/variable.ts | 83 ++++++++++++++---- cli/src/guidance/skills.gen.ts | 6 +- cli/test/variable_resource_push.test.ts | 86 +++++++++++++++++++ .../auto-generated/cli/cli-commands.md | 6 +- system_prompts/auto-generated/prompts.ts | 6 +- .../skills/cli-commands/SKILL.md | 6 +- 6 files changed, 173 insertions(+), 20 deletions(-) diff --git a/cli/src/commands/variable/variable.ts b/cli/src/commands/variable/variable.ts index 8d95db5b2b..dec35fc4f0 100644 --- a/cli/src/commands/variable/variable.ts +++ b/cli/src/commands/variable/variable.ts @@ -185,7 +185,13 @@ async function push( } async function add( - opts: GlobalOptions & { public?: boolean; plainSecrets?: boolean }, + opts: GlobalOptions & { + public?: boolean; + plainSecrets?: boolean; + yes?: boolean; + secret?: boolean; + description?: string; + }, value: string, remotePath: string ) { @@ -196,6 +202,10 @@ async function add( return; } + // --secret/--no-secret take precedence over the legacy --public flag; + // undefined means "secret on create, preserve current setting on update" + const isSecret = opts.secret ?? (opts.public ? false : undefined); + if ( await wmill.existsVariable({ workspace: workspace.workspaceId, @@ -203,6 +213,7 @@ async function add( }) ) { if ( + !opts.yes && !(await Confirm.prompt({ message: `Variable already exist, do you want to update its value?`, default: true, @@ -210,22 +221,46 @@ async function add( ) { return; } + if (isSecret === false) { + const existing = await wmill.getVariable({ + workspace: workspace.workspaceId, + path: remotePath, + decryptSecret: false, + }); + if (existing.is_secret) { + log.warn( + colors.yellow( + `Variable ${remotePath} is currently secret and will be downgraded to non-secret: its value will be stored in plaintext` + ) + ); + } + } log.info(colors.bold.yellow("Updating variable...")); + await wmill.updateVariable({ + workspace: workspace.workspaceId, + path: remotePath, + alreadyEncrypted: false, // value from CLI is always plaintext + requestBody: { + value, + ...(isSecret !== undefined ? { is_secret: isSecret } : {}), + ...(opts.description !== undefined + ? { description: opts.description } + : {}), + }, + }); + } else { + log.info(colors.bold.yellow("Creating variable...")); + await wmill.createVariable({ + workspace: workspace.workspaceId, + alreadyEncrypted: false, // value from CLI is always plaintext + requestBody: { + path: remotePath, + value, + is_secret: isSecret ?? true, + description: opts.description ?? "", + }, + }); } - - log.info(colors.bold.yellow("Pushing variable...")); - - await pushVariable( - workspace.workspaceId, - remotePath + ".variable.yaml", - undefined, - { - value, - is_secret: !opts.public, - description: "", - }, - true // value from CLI is always plaintext — tell API not to treat it as pre-encrypted - ); log.info(colors.bold.underline.green(`Variable ${remotePath} pushed`)); } @@ -255,8 +290,24 @@ const command = new Command() "Create a new variable on the remote. This will update the variable if it already exists." ) .arguments(" ") + .option( + "--yes", + "Skip confirmation prompt when updating an existing variable" + ) + .option( + "--secret", + "Mark the variable as secret (default when creating a new variable)" + ) + .option( + "--no-secret", + "Mark the variable as non-secret (when updating, the existing setting is preserved if neither --secret nor --no-secret is passed)" + ) + .option( + "--description ", + "Set the variable description (when updating, the existing description is preserved if not passed)" + ) .option("--plain-secrets", "Push secrets as plain text") - .option("--public", "Legacy option, use --plain-secrets instead") + .option("--public", "Legacy option, use --no-secret instead") .action(add as any); diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index c1c882993e..1071331822 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6705,8 +6705,12 @@ variable related commands - \`variable push \` - Push a local variable spec. This overrides any remote versions. - \`--plain-secrets\` - Push secrets as plain text - \`variable add \` - Create a new variable on the remote. This will update the variable if it already exists. + - \`--yes\` - Skip confirmation prompt when updating an existing variable + - \`--secret\` - Mark the variable as secret (default when creating a new variable) + - \`--no-secret\` - Mark the variable as non-secret (when updating, the existing setting is preserved if neither --secret nor --no-secret is passed) + - \`--description \` - Set the variable description (when updating, the existing description is preserved if not passed) - \`--plain-secrets\` - Push secrets as plain text - - \`--public\` - Legacy option, use --plain-secrets instead + - \`--public\` - Legacy option, use --no-secret instead ### version diff --git a/cli/test/variable_resource_push.test.ts b/cli/test/variable_resource_push.test.ts index c348a31e66..e8b0dc9e26 100644 --- a/cli/test/variable_resource_push.test.ts +++ b/cli/test/variable_resource_push.test.ts @@ -133,6 +133,92 @@ describe("variable", () => { }); }); + test("add creates secret by default and preserves fields on update", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + const varPath = `f/test/add_var_${uniqueId}`; + + // Create: secret by default, --description sets the description + const createResult = await backend.runCLICommand( + ["variable", "add", "v1", varPath, "--description", "first desc"], + tempDir + ); + expect(createResult.code).toEqual(0); + + let apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/variables/get/${varPath}` + ); + expect(apiResp.status).toEqual(200); + let varData = await apiResp.json(); + expect(varData.is_secret).toBe(true); + expect(varData.description).toBe("first desc"); + + // Update with --yes only: no prompt, is_secret and description preserved + const updateResult = await backend.runCLICommand( + ["variable", "add", "v2", varPath, "--yes"], + tempDir + ); + expect(updateResult.code).toEqual(0); + + apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/variables/get/${varPath}` + ); + expect(apiResp.status).toEqual(200); + varData = await apiResp.json(); + expect(varData.is_secret).toBe(true); + expect(varData.description).toBe("first desc"); + expect(varData.value).toBe("v2"); + + // Update with --no-secret: flips to non-secret + const noSecretResult = await backend.runCLICommand( + ["variable", "add", "v3", varPath, "--yes", "--no-secret"], + tempDir + ); + expect(noSecretResult.code).toEqual(0); + + apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/variables/get/${varPath}` + ); + expect(apiResp.status).toEqual(200); + varData = await apiResp.json(); + expect(varData.is_secret).toBe(false); + expect(varData.value).toBe("v3"); + + // Update the non-secret variable with no secret flags: is_secret must + // stay false (preserved, not re-defaulted to secret) + const preserveResult = await backend.runCLICommand( + ["variable", "add", "v4", varPath, "--yes"], + tempDir + ); + expect(preserveResult.code).toEqual(0); + + apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/variables/get/${varPath}` + ); + expect(apiResp.status).toEqual(200); + varData = await apiResp.json(); + expect(varData.is_secret).toBe(false); + expect(varData.value).toBe("v4"); + + // Explicit --secret flips it back + const secretResult = await backend.runCLICommand( + ["variable", "add", "v5", varPath, "--yes", "--secret"], + tempDir + ); + expect(secretResult.code).toEqual(0); + + apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/variables/get/${varPath}` + ); + expect(apiResp.status).toEqual(200); + varData = await apiResp.json(); + expect(varData.is_secret).toBe(true); + expect(varData.value).toBe("v5"); + }); + }); + test("pull retrieves variables into local files", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend); diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index 86c43bedac..4712aae8c4 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -668,8 +668,12 @@ variable related commands - `variable push ` - Push a local variable spec. This overrides any remote versions. - `--plain-secrets` - Push secrets as plain text - `variable add ` - Create a new variable on the remote. This will update the variable if it already exists. + - `--yes` - Skip confirmation prompt when updating an existing variable + - `--secret` - Mark the variable as secret (default when creating a new variable) + - `--no-secret` - Mark the variable as non-secret (when updating, the existing setting is preserved if neither --secret nor --no-secret is passed) + - `--description ` - Set the variable description (when updating, the existing description is preserved if not passed) - `--plain-secrets` - Push secrets as plain text - - `--public` - Legacy option, use --plain-secrets instead + - `--public` - Legacy option, use --no-secret instead ### version diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index f22caeaa4c..ac2b77719b 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -3219,8 +3219,12 @@ variable related commands - \`variable push \` - Push a local variable spec. This overrides any remote versions. - \`--plain-secrets\` - Push secrets as plain text - \`variable add \` - Create a new variable on the remote. This will update the variable if it already exists. + - \`--yes\` - Skip confirmation prompt when updating an existing variable + - \`--secret\` - Mark the variable as secret (default when creating a new variable) + - \`--no-secret\` - Mark the variable as non-secret (when updating, the existing setting is preserved if neither --secret nor --no-secret is passed) + - \`--description \` - Set the variable description (when updating, the existing description is preserved if not passed) - \`--plain-secrets\` - Push secrets as plain text - - \`--public\` - Legacy option, use --plain-secrets instead + - \`--public\` - Legacy option, use --no-secret instead ### version diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 13d423dad7..8f4c15786d 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -673,8 +673,12 @@ variable related commands - `variable push ` - Push a local variable spec. This overrides any remote versions. - `--plain-secrets` - Push secrets as plain text - `variable add ` - Create a new variable on the remote. This will update the variable if it already exists. + - `--yes` - Skip confirmation prompt when updating an existing variable + - `--secret` - Mark the variable as secret (default when creating a new variable) + - `--no-secret` - Mark the variable as non-secret (when updating, the existing setting is preserved if neither --secret nor --no-secret is passed) + - `--description ` - Set the variable description (when updating, the existing description is preserved if not passed) - `--plain-secrets` - Push secrets as plain text - - `--public` - Legacy option, use --plain-secrets instead + - `--public` - Legacy option, use --no-secret instead ### version From aaf05635cedadc73455dc522474b673719f9fd5c Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 12 Jun 2026 17:24:46 +0200 Subject: [PATCH 004/246] feat: wire chat reasoning effort through gemini and bedrock proxies (#9545) * feat: map chat reasoning effort to gemini thinkingConfig in ai proxy * feat: support claude adaptive thinking through the bedrock ai proxy * feat: per-model gemini effort levels and thought summary display * fix: exclude claude opus 4.5 from reasoning effort (rejects adaptive) * fix: render markdown in thinking blocks and unstick reasoning spinner * feat: model-aware reasoning effort options across ai chat providers * fix: scope openrouter reasoning off to the underlying model family --- backend/windmill-ai/src/ai_bedrock.rs | 96 +++++- backend/windmill-ai/src/ai_google.rs | 108 +++++- backend/windmill-ai/src/ai_types.rs | 19 ++ backend/windmill-ai/src/providers/bedrock.rs | 322 +++++++++++++++++- .../windmill-ai/src/providers/google_ai.rs | 170 ++++++++- backend/windmill-ai/src/sse.rs | 1 + .../lib/components/copilot/chat/AIChat.svelte | 1 + .../copilot/chat/AssistantMessage.svelte | 25 +- .../copilot/chat/ProviderModelSelector.svelte | 9 +- .../lib/components/copilot/chat/chatLoop.ts | 34 +- .../copilot/chat/openaiReasoning.ts | 46 ++- .../src/lib/components/copilot/chat/shared.ts | 7 + .../src/lib/components/copilot/lib.test.ts | 44 ++- frontend/src/lib/components/copilot/lib.ts | 21 +- .../copilot/reasoningRegistry.test.ts | 264 +++++++++++++- .../components/copilot/reasoningRegistry.ts | 262 +++++++++++++- 16 files changed, 1351 insertions(+), 78 deletions(-) diff --git a/backend/windmill-ai/src/ai_bedrock.rs b/backend/windmill-ai/src/ai_bedrock.rs index e549e63041..8aa9addcae 100644 --- a/backend/windmill-ai/src/ai_bedrock.rs +++ b/backend/windmill-ai/src/ai_bedrock.rs @@ -560,6 +560,26 @@ fn convert_message(msg: &OpenAIMessage) -> Result { let mut content_blocks = Vec::new(); + // Replay the Claude reasoning block first: when thinking is enabled, + // Anthropic requires the reasoning block (with its unmodified signature) to + // precede toolUse in the assistant turn it was emitted in. The proxy + // round-trips it on the tool call's extra_content (see BedrockExtraContent). + if role == ConversationRole::Assistant { + if let Some(reasoning) = msg + .tool_calls + .as_ref() + .and_then(|tcs| { + tcs.iter() + .find_map(|tc| tc.extra_content.as_ref().and_then(|ec| ec.bedrock.as_ref())) + }) + .map(bedrock_reasoning_block_from_extra) + .transpose()? + .flatten() + { + content_blocks.push(reasoning); + } + } + // Handle content (text and/or images) if let Some(content) = &msg.content { match content { @@ -597,6 +617,37 @@ fn convert_message(msg: &OpenAIMessage) -> Result { .map_err(|e| Error::internal_err(format!("Failed to build message: {}", e))) } +/// Rebuild a Bedrock reasoning content block from the round-tripped +/// [`BedrockExtraContent`](crate::ai_types::BedrockExtraContent). +fn bedrock_reasoning_block_from_extra( + extra: &crate::ai_types::BedrockExtraContent, +) -> Result, Error> { + if let Some(redacted) = extra.redacted_content.as_deref() { + let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, redacted) + .map_err(|e| { + Error::internal_err(format!("Failed to decode redacted reasoning: {}", e)) + })?; + return Ok(Some(ContentBlock::ReasoningContent( + aws_sdk_bedrockruntime::types::ReasoningContentBlock::RedactedContent(bytes.into()), + ))); + } + + let Some(text) = extra.reasoning_text.as_deref() else { + return Ok(None); + }; + let mut builder = aws_sdk_bedrockruntime::types::ReasoningTextBlock::builder().text(text); + if let Some(signature) = extra.signature.as_deref() { + builder = builder.signature(signature); + } + Ok(Some(ContentBlock::ReasoningContent( + aws_sdk_bedrockruntime::types::ReasoningContentBlock::ReasoningText( + builder.build().map_err(|e| { + Error::internal_err(format!("Failed to build reasoning block: {}", e)) + })?, + ), + ))) +} + /// Convert OpenAI tool call to Bedrock ToolUse content block fn convert_tool_call_to_content(tool_call: &OpenAIToolCall) -> Result { let input = json_to_document( @@ -814,7 +865,10 @@ pub fn streaming_tool_calls_to_openai(tool_calls: Vec) -> Vec id: tc.id, function: OpenAIFunction { name: tc.name, arguments: tc.arguments }, r#type: FUNCTION_TYPE.to_string(), - extra_content: None, // Bedrock doesn't use thought signatures + // Worker agent requests never enable thinking, so there is no + // reasoning block to round-trip here (the chat proxy path does — + // see providers/bedrock.rs). + extra_content: None, }) .collect() } @@ -858,6 +912,7 @@ pub fn build_tool_config( #[cfg(test)] mod tests { use super::*; + use aws_sdk_bedrockruntime::types::ReasoningContentBlock; use serde_json::value::RawValue; fn text_message(role: &str, content: &str) -> OpenAIMessage { @@ -934,6 +989,45 @@ mod tests { )); } + #[test] + fn openai_messages_to_bedrock_replays_reasoning_before_tool_use() { + let tool_call = OpenAIToolCall { + id: "call_1".to_string(), + function: OpenAIFunction { name: "lookup".to_string(), arguments: "{}".to_string() }, + r#type: FUNCTION_TYPE.to_string(), + extra_content: Some(crate::ai_types::ExtraContent { + bedrock: Some(crate::ai_types::BedrockExtraContent { + reasoning_text: Some("let me think".to_string()), + signature: Some("sig-abc".to_string()), + redacted_content: None, + }), + ..Default::default() + }), + }; + let assistant = OpenAIMessage { + role: "assistant".to_string(), + tool_calls: Some(vec![tool_call]), + ..Default::default() + }; + let messages = vec![text_message("user", "hi"), assistant]; + + let (bedrock_messages, _) = + openai_messages_to_bedrock(&messages, false).expect("bedrock conversion succeeds"); + + let content = bedrock_messages + .last() + .expect("assistant message") + .content(); + match &content[0] { + ContentBlock::ReasoningContent(ReasoningContentBlock::ReasoningText(rt)) => { + assert_eq!(rt.text(), "let me think"); + assert_eq!(rt.signature(), Some("sig-abc")); + } + other => panic!("expected reasoning block first, got {:?}", other), + } + assert!(matches!(&content[1], ContentBlock::ToolUse(_))); + } + #[test] fn openai_messages_to_bedrock_skips_cache_points_when_disabled() { let messages = vec![ diff --git a/backend/windmill-ai/src/ai_google.rs b/backend/windmill-ai/src/ai_google.rs index 3743e6eb52..d0ede744d3 100644 --- a/backend/windmill-ai/src/ai_google.rs +++ b/backend/windmill-ai/src/ai_google.rs @@ -139,6 +139,22 @@ pub struct GeminiGenerationConfig { pub response_mime_type: Option, #[serde(rename = "responseSchema", skip_serializing_if = "Option::is_none")] pub response_schema: Option, + #[serde(rename = "thinkingConfig", skip_serializing_if = "Option::is_none")] + pub thinking_config: Option, +} + +/// Thinking controls. Gemini 3+ models take a level token (`thinkingLevel`); +/// Gemini 2.5 models take a token budget (`thinkingBudget`, `-1` = dynamic). +/// `includeThoughts` returns thought summaries for chat display — summaries are +/// free; thinking tokens are billed whether or not they are returned. +#[derive(Serialize, Debug, PartialEq)] +pub struct GeminiThinkingConfig { + #[serde(rename = "thinkingLevel", skip_serializing_if = "Option::is_none")] + pub thinking_level: Option, + #[serde(rename = "thinkingBudget", skip_serializing_if = "Option::is_none")] + pub thinking_budget: Option, + #[serde(rename = "includeThoughts", skip_serializing_if = "Option::is_none")] + pub include_thoughts: Option, } // ============================================================================ @@ -206,6 +222,9 @@ pub struct GeminiSSEPart { pub text: Option, #[serde(rename = "functionCall")] pub function_call: Option, + /// Marks a thought-summary part (returned when `includeThoughts` is set). + #[serde(default)] + pub thought: Option, /// Thought signature for Gemini 3+ models. #[serde(rename = "thoughtSignature")] pub thought_signature: Option, @@ -293,6 +312,7 @@ impl GeminiToolCallEvent { pub fn to_extra_content(&self) -> Option { self.thought_signature.as_ref().map(|sig| ExtraContent { google: Some(GoogleExtraContent { thought_signature: Some(sig.clone()) }), + bedrock: None, }) } } @@ -301,6 +321,9 @@ impl GeminiToolCallEvent { #[derive(Debug, Default)] pub struct GeminiParsedEvent { pub text: Option, + /// Thought-summary text (parts flagged `thought: true`), kept separate from + /// the answer so it can stream as `reasoning_content`. + pub reasoning: Option, pub tool_calls: Vec, pub annotations: Vec, pub used_websearch: bool, @@ -575,6 +598,9 @@ pub fn gemini_response_to_openai(parsed: &GeminiParsedEvent, model: &str) -> ser "role": "assistant", "content": content, }); + if let Some(reasoning) = &parsed.reasoning { + message["reasoning_content"] = serde_json::json!(reasoning); + } if !tool_calls.is_empty() { message["tool_calls"] = serde_json::json!(tool_calls); } @@ -604,6 +630,20 @@ pub fn gemini_event_to_openai_sse_chunks( ) -> Vec { let mut chunks = Vec::new(); + if let Some(reasoning) = &parsed.reasoning { + let chunk = serde_json::json!({ + "id": id, + "object": "chat.completion.chunk", + "model": model, + "choices": [{ + "index": 0, + "delta": { "reasoning_content": reasoning }, + "finish_reason": null, + }] + }); + chunks.push(format!("data: {}\n\n", chunk)); + } + if let Some(text) = &parsed.text { let chunk = serde_json::json!({ "id": id, @@ -718,9 +758,16 @@ fn extract_candidates_into(candidates: &[GeminiSSECandidate], parsed: &mut Gemin for part in parts { if let Some(text) = &part.text { if !text.is_empty() { - match parsed.text.as_mut() { + // Thought-summary parts go to the reasoning channel, + // not the answer. + let target = if part.thought == Some(true) { + &mut parsed.reasoning + } else { + &mut parsed.text + }; + match target.as_mut() { Some(existing) => existing.push_str(text), - None => parsed.text = Some(text.clone()), + None => *target = Some(text.clone()), } } } @@ -788,8 +835,8 @@ fn openai_tool_call_json( #[cfg(test)] mod tests { use super::{ - gemini_event_to_openai_sse_chunks, gemini_response_to_openai, sanitize_schema_for_google, - GeminiParsedEvent, GeminiToolCallEvent, + gemini_event_to_openai_sse_chunks, gemini_response_to_openai, parse_gemini_sse_event, + sanitize_schema_for_google, GeminiParsedEvent, GeminiToolCallEvent, }; #[test] @@ -856,6 +903,59 @@ mod tests { assert_eq!(tool_call["index"], 0); } + #[test] + fn gemini_thought_parts_route_to_reasoning() { + let event = r#"{ + "candidates": [{ + "content": { + "parts": [ + {"text": "summary of my thinking", "thought": true}, + {"text": "the answer"} + ] + } + }] + }"#; + let parsed = parse_gemini_sse_event(event) + .expect("parse succeeds") + .expect("event is recognised"); + assert_eq!(parsed.reasoning.as_deref(), Some("summary of my thinking")); + assert_eq!(parsed.text.as_deref(), Some("the answer")); + + let mut tool_call_index = 0; + let chunks = gemini_event_to_openai_sse_chunks( + &parsed, + "chatcmpl-test", + "gemini-3-flash-preview", + &mut tool_call_index, + ); + assert_eq!(chunks.len(), 2); + let reasoning_chunk: serde_json::Value = serde_json::from_str( + chunks[0] + .strip_prefix("data: ") + .and_then(|c| c.strip_suffix("\n\n")) + .unwrap(), + ) + .unwrap(); + assert_eq!( + reasoning_chunk["choices"][0]["delta"]["reasoning_content"], + "summary of my thinking" + ); + let text_chunk: serde_json::Value = serde_json::from_str( + chunks[1] + .strip_prefix("data: ") + .and_then(|c| c.strip_suffix("\n\n")) + .unwrap(), + ) + .unwrap(); + assert_eq!(text_chunk["choices"][0]["delta"]["content"], "the answer"); + + // Non-streaming conversion keeps the channels separate too. + let response = gemini_response_to_openai(&parsed, "gemini-3-flash-preview"); + let message = &response["choices"][0]["message"]; + assert_eq!(message["content"], "the answer"); + assert_eq!(message["reasoning_content"], "summary of my thinking"); + } + #[test] fn sanitize_schema_for_google_removes_property_names() { let mut schema = serde_json::json!({ diff --git a/backend/windmill-ai/src/ai_types.rs b/backend/windmill-ai/src/ai_types.rs index 47c72fd3da..c6f171daf3 100644 --- a/backend/windmill-ai/src/ai_types.rs +++ b/backend/windmill-ai/src/ai_types.rs @@ -101,11 +101,30 @@ pub struct GoogleExtraContent { pub thought_signature: Option, } +/// Bedrock-specific extra content carrying the Claude reasoning block emitted +/// in the same assistant turn as a tool call. Anthropic requires reasoning +/// blocks (text + signature, unmodified) to be replayed before `toolUse` when +/// thinking is enabled, so the proxy round-trips them through the +/// OpenAI-shaped tool call. +#[derive(Deserialize, Serialize, Clone, Debug, Default)] +pub struct BedrockExtraContent { + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub signature: Option, + /// Base64 of a redacted (encrypted) reasoning block, when the provider + /// returned one instead of readable text. + #[serde(skip_serializing_if = "Option::is_none")] + pub redacted_content: Option, +} + /// Extra content for provider-specific metadata (e.g., Google thought signatures) #[derive(Deserialize, Serialize, Clone, Debug, Default)] pub struct ExtraContent { #[serde(skip_serializing_if = "Option::is_none")] pub google: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bedrock: Option, } #[derive(Deserialize, Serialize, Clone, Debug)] diff --git a/backend/windmill-ai/src/providers/bedrock.rs b/backend/windmill-ai/src/providers/bedrock.rs index 0eef359c36..ddc5f33998 100644 --- a/backend/windmill-ai/src/providers/bedrock.rs +++ b/backend/windmill-ai/src/providers/bedrock.rs @@ -12,11 +12,14 @@ use crate::{ bedrock_stream_event_to_text, bedrock_stream_event_to_tool_delta, bedrock_stream_event_to_tool_delta_with_block_index, bedrock_stream_event_to_tool_start, bedrock_stream_event_to_tool_start_with_block_index, build_tool_config, - create_inference_config, format_bedrock_error, openai_messages_to_bedrock, - streaming_tool_calls_to_openai, BearerTokenProvider, BedrockClient, StreamingToolCall, + create_inference_config, format_bedrock_error, json_to_document, + openai_messages_to_bedrock, streaming_tool_calls_to_openai, BearerTokenProvider, + BedrockClient, StreamingToolCall, }, ai_providers::USE_ENV_REGION, - ai_types::{OpenAIFunction, OpenAIToolCall, ToolDefFunction}, + ai_types::{ + BedrockExtraContent, ExtraContent, OpenAIFunction, OpenAIToolCall, ToolDefFunction, + }, image_handler::prepare_messages_for_api, proxy::ProxyBuildArgs, query_builder::{ParsedResponse, StreamEventSink}, @@ -45,6 +48,11 @@ struct OpenAIRequest { max_tokens: Option, #[serde(default)] temperature: Option, + /// Anthropic effort token from the chat reasoning setting (e.g. `low`, + /// `high`, `max`). Enables adaptive thinking via + /// `additionalModelRequestFields` — see [`bedrock_thinking_fields`]. + #[serde(default)] + reasoning_effort: Option, } #[derive(Deserialize, Debug)] @@ -349,7 +357,13 @@ async fn handle_bedrock_sdk_streaming( let enable_prompt_caching = bedrock_model_supports_prompt_caching(model); let (bedrock_messages, system_prompts) = openai_messages_to_bedrock(&openai_req.messages, enable_prompt_caching)?; - let inference_config = create_inference_config(openai_req.temperature, openai_req.max_tokens); + // Adaptive thinking rejects sampling params; drop temperature when reasoning is on. + let temperature = openai_req + .reasoning_effort + .is_none() + .then_some(openai_req.temperature) + .flatten(); + let inference_config = create_inference_config(temperature, openai_req.max_tokens); let tool_config = build_tool_config_from_request( openai_req.tools.as_deref(), openai_req.tool_choice.as_ref(), @@ -374,6 +388,11 @@ async fn handle_bedrock_sdk_streaming( request_builder = request_builder.set_tool_config(Some(config)); } + if let Some(effort) = openai_req.reasoning_effort.as_deref() { + request_builder = + request_builder.additional_model_request_fields(bedrock_thinking_fields(effort)); + } + tracing::debug!("Bedrock SDK streaming: sending converse_stream request"); let stream_output = request_builder.send().await.map_err(|e| { let error_msg = format!("Bedrock SDK streaming error: {}", format_bedrock_error(&e)); @@ -391,6 +410,17 @@ async fn handle_bedrock_sdk_streaming( }) } +/// Build the Converse `additionalModelRequestFields` enabling Claude adaptive +/// thinking at the given effort. `display: summarized` is billing-neutral on +/// Anthropic models and matches the direct-Anthropic chat path, which renders +/// summarized thinking in the UI. +fn bedrock_thinking_fields(effort: &str) -> aws_smithy_types::Document { + json_to_document(serde_json::json!({ + "thinking": { "type": "adaptive", "display": "summarized" }, + "output_config": { "effort": effort } + })) +} + pub fn sdk_stream_to_sse( stream: aws_sdk_bedrockruntime::primitives::event_stream::EventReceiver< aws_sdk_bedrockruntime::types::ConverseStreamOutput, @@ -438,6 +468,11 @@ struct BedrockSseStreamState { tool_calls: HashMap, tool_block_indexes: HashMap, next_tool_index: usize, + /// Claude reasoning block accumulated from `ReasoningContent` deltas + /// (text + signature, or redacted bytes). Attached to the first tool call + /// of the turn so the frontend round-trips it for replay. + reasoning: Option, + reasoning_attached: bool, } impl BedrockSseStreamState { @@ -449,6 +484,8 @@ impl BedrockSseStreamState { tool_calls: HashMap::new(), tool_block_indexes: HashMap::new(), next_tool_index: 0, + reasoning: None, + reasoning_attached: false, } } } @@ -459,6 +496,24 @@ fn bedrock_sse_chunks_for_event( ) -> Vec { let mut chunks = Vec::new(); + if let Some(reasoning_text) = accumulate_reasoning_delta(event, state) { + let chunk = serde_json::json!({ + "id": state.id, + "object": "chat.completion.chunk", + "created": state.created, + "model": state.model, + "choices": [{ + "index": 0, + "delta": { + "reasoning_content": reasoning_text + }, + "finish_reason": serde_json::Value::Null + }] + }); + + chunks.push(Bytes::from(format!("data: {}\n\n", chunk))); + } + if let Some((block_index, tool_call)) = bedrock_stream_event_to_tool_start_with_block_index(event) { @@ -470,6 +525,25 @@ fn bedrock_sse_chunks_for_event( (tool_call.id.clone(), tool_call.name.clone(), String::new()), ); + let mut tool_call_json = serde_json::json!({ + "index": index, + "id": tool_call.id, + "type": "function", + "function": { + "name": tool_call.name, + "arguments": "" + } + }); + // Attach the turn's reasoning block to the first tool call so the + // frontend echoes it back and the next request can replay it before + // toolUse (required by Claude when thinking is enabled). + if !state.reasoning_attached { + if let Some(reasoning) = state.reasoning.as_ref() { + tool_call_json["extra_content"] = serde_json::json!({ "bedrock": reasoning }); + state.reasoning_attached = true; + } + } + let chunk = serde_json::json!({ "id": state.id, "object": "chat.completion.chunk", @@ -478,15 +552,7 @@ fn bedrock_sse_chunks_for_event( "choices": [{ "index": 0, "delta": { - "tool_calls": [{ - "index": index, - "id": tool_call.id, - "type": "function", - "function": { - "name": tool_call.name, - "arguments": "" - } - }] + "tool_calls": [tool_call_json] }, "finish_reason": serde_json::Value::Null }] @@ -573,6 +639,61 @@ fn bedrock_sse_chunks_for_event( chunks } +/// Fold a `ReasoningContent` stream delta into the state's pending reasoning +/// block. Returns the text delta (for a `reasoning_content` SSE chunk) when the +/// event carried readable reasoning text. +fn accumulate_reasoning_delta( + event: &aws_sdk_bedrockruntime::types::ConverseStreamOutput, + state: &mut BedrockSseStreamState, +) -> Option { + let aws_sdk_bedrockruntime::types::ConverseStreamOutput::ContentBlockDelta(delta_event) = event + else { + return None; + }; + let aws_sdk_bedrockruntime::types::ContentBlockDelta::ReasoningContent(reasoning) = + delta_event.delta()? + else { + return None; + }; + + let entry = state.reasoning.get_or_insert_with(Default::default); + match reasoning { + aws_sdk_bedrockruntime::types::ReasoningContentBlockDelta::Text(text) => { + entry + .reasoning_text + .get_or_insert_with(String::new) + .push_str(text); + Some(text.clone()) + } + aws_sdk_bedrockruntime::types::ReasoningContentBlockDelta::Signature(signature) => { + entry + .signature + .get_or_insert_with(String::new) + .push_str(signature); + None + } + aws_sdk_bedrockruntime::types::ReasoningContentBlockDelta::RedactedContent(blob) => { + // Base64 of concatenated fragments != concatenated base64 fragments, + // so accumulate raw bytes and re-encode. + let mut bytes = entry + .redacted_content + .as_deref() + .and_then(|existing| { + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, existing) + .ok() + }) + .unwrap_or_default(); + bytes.extend_from_slice(blob.as_ref()); + entry.redacted_content = Some(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + bytes, + )); + None + } + _ => None, + } +} + async fn handle_bedrock_sdk_non_streaming( model: &str, body: &[u8], @@ -586,7 +707,13 @@ async fn handle_bedrock_sdk_non_streaming( let enable_prompt_caching = bedrock_model_supports_prompt_caching(model); let (bedrock_messages, system_prompts) = openai_messages_to_bedrock(&openai_req.messages, enable_prompt_caching)?; - let inference_config = create_inference_config(openai_req.temperature, openai_req.max_tokens); + // Adaptive thinking rejects sampling params; drop temperature when reasoning is on. + let temperature = openai_req + .reasoning_effort + .is_none() + .then_some(openai_req.temperature) + .flatten(); + let inference_config = create_inference_config(temperature, openai_req.max_tokens); let tool_config = build_tool_config_from_request( openai_req.tools.as_deref(), openai_req.tool_choice.as_ref(), @@ -611,6 +738,11 @@ async fn handle_bedrock_sdk_non_streaming( request_builder = request_builder.set_tool_config(Some(config)); } + if let Some(effort) = openai_req.reasoning_effort.as_deref() { + request_builder = + request_builder.additional_model_request_fields(bedrock_thinking_fields(effort)); + } + tracing::debug!("Bedrock SDK non-streaming: sending converse request"); let response = request_builder.send().await.map_err(|e| { let error_msg = format!( @@ -643,6 +775,7 @@ async fn handle_bedrock_sdk_non_streaming( let mut text_content = String::new(); let mut tool_calls: Vec = Vec::new(); + let mut reasoning: Option = None; if let Some(aws_sdk_bedrockruntime::types::ConverseOutput::Message(message)) = response.output() { @@ -651,6 +784,29 @@ async fn handle_bedrock_sdk_non_streaming( aws_sdk_bedrockruntime::types::ContentBlock::Text(text) => { text_content.push_str(text); } + aws_sdk_bedrockruntime::types::ContentBlock::ReasoningContent(rc) => { + let entry = reasoning.get_or_insert_with(Default::default); + match rc { + aws_sdk_bedrockruntime::types::ReasoningContentBlock::ReasoningText(rt) => { + entry + .reasoning_text + .get_or_insert_with(String::new) + .push_str(rt.text()); + if let Some(signature) = rt.signature() { + entry.signature = Some(signature.to_string()); + } + } + aws_sdk_bedrockruntime::types::ReasoningContentBlock::RedactedContent( + blob, + ) => { + entry.redacted_content = Some(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + blob.as_ref(), + )); + } + _ => {} + } + } aws_sdk_bedrockruntime::types::ContentBlock::ToolUse(tool_use) => { let input_json = document_to_json(tool_use.input()); tool_calls.push(OpenAIToolCall { @@ -668,7 +824,20 @@ async fn handle_bedrock_sdk_non_streaming( } } - let message = if !tool_calls.is_empty() { + // Attach the turn's reasoning block to the first tool call so the frontend + // round-trips it for replay (required by Claude when thinking is enabled). + if let (Some(reasoning_block), Some(first_tool_call)) = + (reasoning.as_ref(), tool_calls.first_mut()) + { + first_tool_call.extra_content = + Some(ExtraContent { bedrock: Some(reasoning_block.clone()), ..Default::default() }); + } + let reasoning_content = reasoning + .as_ref() + .and_then(|r| r.reasoning_text.clone()) + .filter(|t| !t.is_empty()); + + let mut message = if !tool_calls.is_empty() { serde_json::json!({ "role": "assistant", "content": if text_content.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(text_content) }, @@ -680,6 +849,9 @@ async fn handle_bedrock_sdk_non_streaming( "content": text_content }) }; + if let Some(reasoning_content) = reasoning_content { + message["reasoning_content"] = serde_json::Value::String(reasoning_content); + } let usage = if let Some(usage_data) = response.usage() { serde_json::json!({ @@ -1122,4 +1294,124 @@ mod tests { 0 ); } + + #[test] + fn bedrock_thinking_fields_carry_adaptive_thinking_and_effort() { + let fields = document_to_json(&bedrock_thinking_fields("xhigh")); + assert_eq!(fields["thinking"]["type"], "adaptive"); + assert_eq!(fields["thinking"]["display"], "summarized"); + assert_eq!(fields["output_config"]["effort"], "xhigh"); + } + + fn reasoning_delta( + block_index: i32, + delta: aws_sdk_bedrockruntime::types::ReasoningContentBlockDelta, + ) -> ConverseStreamOutput { + ConverseStreamOutput::ContentBlockDelta( + ContentBlockDeltaEvent::builder() + .content_block_index(block_index) + .delta(ContentBlockDelta::ReasoningContent(delta)) + .build() + .unwrap(), + ) + } + + #[test] + fn bedrock_sse_streams_reasoning_and_attaches_block_to_first_tool_call() { + use aws_sdk_bedrockruntime::types::ReasoningContentBlockDelta; + + let mut state = + BedrockSseStreamState::new("chatcmpl-test".to_string(), "model".to_string(), 1); + + // Reasoning text streams as reasoning_content deltas. + let chunks = bedrock_sse_chunks_for_event( + &reasoning_delta(0, ReasoningContentBlockDelta::Text("let me ".to_string())), + &mut state, + ); + assert_eq!( + sse_json(&chunks[0])["choices"][0]["delta"]["reasoning_content"], + "let me " + ); + bedrock_sse_chunks_for_event( + &reasoning_delta(0, ReasoningContentBlockDelta::Text("think".to_string())), + &mut state, + ); + // Signature deltas accumulate silently (no chunk emitted). + assert!(bedrock_sse_chunks_for_event( + &reasoning_delta( + 0, + ReasoningContentBlockDelta::Signature("sig-abc".to_string()) + ), + &mut state, + ) + .is_empty()); + + // The first tool call carries the full reasoning block for replay. + let tool_start = ConverseStreamOutput::ContentBlockStart( + ContentBlockStartEvent::builder() + .content_block_index(1) + .start(ContentBlockStart::ToolUse( + ToolUseBlockStart::builder() + .tool_use_id("call_1") + .name("lookup") + .build() + .unwrap(), + )) + .build() + .unwrap(), + ); + let start_json = sse_json(&bedrock_sse_chunks_for_event(&tool_start, &mut state)[0]); + let tool_call = &start_json["choices"][0]["delta"]["tool_calls"][0]; + assert_eq!( + tool_call["extra_content"]["bedrock"]["reasoning_text"], + "let me think" + ); + assert_eq!( + tool_call["extra_content"]["bedrock"]["signature"], + "sig-abc" + ); + + // Subsequent tool calls don't repeat the block. + let second_tool_start = ConverseStreamOutput::ContentBlockStart( + ContentBlockStartEvent::builder() + .content_block_index(2) + .start(ContentBlockStart::ToolUse( + ToolUseBlockStart::builder() + .tool_use_id("call_2") + .name("lookup") + .build() + .unwrap(), + )) + .build() + .unwrap(), + ); + let second_json = + sse_json(&bedrock_sse_chunks_for_event(&second_tool_start, &mut state)[0]); + assert!(second_json["choices"][0]["delta"]["tool_calls"][0] + .get("extra_content") + .is_none()); + } + + #[test] + fn bedrock_sse_accumulates_redacted_reasoning_bytes() { + use aws_sdk_bedrockruntime::types::ReasoningContentBlockDelta; + + let mut state = + BedrockSseStreamState::new("chatcmpl-test".to_string(), "model".to_string(), 1); + for fragment in [b"ab".as_slice(), b"cd".as_slice()] { + assert!(bedrock_sse_chunks_for_event( + &reasoning_delta( + 0, + ReasoningContentBlockDelta::RedactedContent(fragment.to_vec().into()), + ), + &mut state, + ) + .is_empty()); + } + + let encoded = state.reasoning.unwrap().redacted_content.unwrap(); + let decoded = + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded).unwrap(); + assert_eq!(decoded, b"abcd"); + } } diff --git a/backend/windmill-ai/src/providers/google_ai.rs b/backend/windmill-ai/src/providers/google_ai.rs index cf00ddc142..660e820409 100644 --- a/backend/windmill-ai/src/providers/google_ai.rs +++ b/backend/windmill-ai/src/providers/google_ai.rs @@ -4,7 +4,7 @@ use crate::{ openai_tools_to_gemini, parse_gemini_response, parse_gemini_sse_event, sanitize_schema_for_google, GeminiFunctionDeclaration, GeminiGenerationConfig, GeminiImageContent, GeminiImageRequest, GeminiImageResponse, GeminiInlineData, GeminiPart, - GeminiPredictContent, GeminiTextRequest, GeminiTool, + GeminiPredictContent, GeminiTextRequest, GeminiThinkingConfig, GeminiTool, }, image_handler::{download_and_encode_s3_image, prepare_messages_for_api}, proxy::{ProxyBuildArgs, ProxyRequest}, @@ -158,6 +158,8 @@ impl GoogleAIQueryBuilder { args.max_tokens, response_mime_type, response_schema, + // Worker AI-agent requests carry no reasoning knob; keep provider defaults. + None, ) } } @@ -187,23 +189,73 @@ fn build_gemini_generation_config( max_tokens: Option, response_mime_type: Option, response_schema: Option, + thinking_config: Option, ) -> Option { if temperature.is_some() || max_tokens.is_some() || response_mime_type.is_some() || response_schema.is_some() + || thinking_config.is_some() { Some(GeminiGenerationConfig { temperature, max_output_tokens: max_tokens, response_mime_type, response_schema, + thinking_config, }) } else { None } } +/// Map an OpenAI-style `reasoning_effort` token onto Gemini's native thinking +/// controls. Gemini models think by default, so this is what makes the chat +/// effort setting (including explicit `none`) actually change model behavior. +/// +/// - Gemini 3+: `thinkingLevel` takes the token verbatim (provider-native open +/// vocabulary: `low`/`medium`/`high`, plus `minimal` on Flash). `none` maps to +/// the lowest supported level — thinking cannot be fully disabled on Pro. +/// - Gemini 2.5: `thinkingBudget` in tokens, using the same tiering as Google's +/// own OpenAI-compatibility layer. 2.5 Pro cannot disable thinking and has a +/// minimum budget of 128; Flash accepts 0 (off). Unknown tokens fall back to +/// `-1` (dynamic, the provider default). +fn gemini_thinking_config(model: &str, effort: &str) -> GeminiThinkingConfig { + let model = model.to_lowercase(); + let is_flash = model.contains("flash"); + let is_gemini_2_5 = model.contains("gemini-2.5"); + // Thought summaries are free to return (thinking tokens are billed either + // way); skip them only when the user explicitly turned reasoning off. + let include_thoughts = (effort != "none").then_some(true); + + if is_gemini_2_5 { + let budget = match effort { + "none" if is_flash => 0, + "none" => 128, + "low" => 1024, + "medium" => 8192, + "high" => 24576, + _ => -1, + }; + GeminiThinkingConfig { + thinking_level: None, + thinking_budget: Some(budget), + include_thoughts, + } + } else { + let level = match effort { + "none" if is_flash => "minimal".to_string(), + "none" => "low".to_string(), + other => other.to_string(), + }; + GeminiThinkingConfig { + thinking_level: Some(level), + thinking_budget: None, + include_thoughts, + } + } +} + #[derive(Deserialize, Debug)] struct GoogleAIProxyChatRequest { model: String, @@ -214,6 +266,11 @@ struct GoogleAIProxyChatRequest { temperature: Option, #[serde(default)] max_tokens: Option, + /// OpenAI-style effort token from the chat reasoning setting (e.g. `low`, + /// `high`, or `none` for an explicit off). Mapped to Gemini's native + /// `thinkingConfig` — see [`gemini_thinking_config`]. + #[serde(default)] + reasoning_effort: Option, #[serde(default)] tools: Option>, } @@ -344,10 +401,20 @@ fn build_google_ai_chat_proxy_request( vec![GeminiTool { function_declarations: Some(declarations), google_search: None }] }); + let thinking_config = request + .reasoning_effort + .as_deref() + .map(|effort| gemini_thinking_config(&request.model, effort)); let body = build_gemini_text_request_body( &request.messages, gemini_tools, - build_gemini_generation_config(request.temperature, request.max_tokens, None, None), + build_gemini_generation_config( + request.temperature, + request.max_tokens, + None, + None, + thinking_config, + ), )? .into_bytes(); @@ -752,9 +819,108 @@ mod tests { let body: serde_json::Value = serde_json::from_slice(&request.request.body).unwrap(); assert_eq!(body["generationConfig"]["maxOutputTokens"], 123); assert_eq!(body["generationConfig"]["temperature"], 0.2); + // No reasoning_effort in the request -> no thinkingConfig (provider defaults). + assert!(body["generationConfig"].get("thinkingConfig").is_none()); assert!(body["contents"].is_array()); } + fn proxy_body_for(model: &str, reasoning_effort: &str) -> serde_json::Value { + let credentials = credentials( + "https://generativelanguage.googleapis.com/v1beta/", + AIPlatform::Standard, + ); + let method = Method::POST; + let headers = HeaderMap::new(); + let body = format!( + r#"{{ + "model": "{model}", + "messages": [{{"role": "user", "content": "hello"}}], + "reasoning_effort": "{reasoning_effort}", + "stream": false + }}"# + ); + + let request = build_google_ai_chat_proxy_request(&ProxyBuildArgs { + method: &method, + path: "chat/completions", + headers: &headers, + body: body.as_bytes(), + credentials: &credentials, + }) + .unwrap(); + + serde_json::from_slice(&request.request.body).unwrap() + } + + #[test] + fn maps_reasoning_effort_to_thinking_level_on_gemini_3() { + let body = proxy_body_for("gemini-3-pro-preview", "low"); + assert_eq!( + body["generationConfig"]["thinkingConfig"]["thinkingLevel"], + "low" + ); + // Thought summaries are requested whenever reasoning is on... + assert_eq!( + body["generationConfig"]["thinkingConfig"]["includeThoughts"], + true + ); + // ...but not when the user explicitly turned it off. + let body = proxy_body_for("gemini-3-pro-preview", "none"); + assert!(body["generationConfig"]["thinkingConfig"] + .get("includeThoughts") + .is_none()); + assert!(body["generationConfig"]["thinkingConfig"] + .get("thinkingBudget") + .is_none()); + } + + #[test] + fn maps_reasoning_effort_none_per_gemini_3_model() { + // Pro cannot disable thinking -> lowest level. + let body = proxy_body_for("gemini-3-pro-preview", "none"); + assert_eq!( + body["generationConfig"]["thinkingConfig"]["thinkingLevel"], + "low" + ); + // Flash supports `minimal`. + let body = proxy_body_for("gemini-3-flash-preview", "none"); + assert_eq!( + body["generationConfig"]["thinkingConfig"]["thinkingLevel"], + "minimal" + ); + } + + #[test] + fn maps_reasoning_effort_to_thinking_budget_on_gemini_2_5() { + let body = proxy_body_for("gemini-2.5-flash", "medium"); + assert_eq!( + body["generationConfig"]["thinkingConfig"]["thinkingBudget"], + 8192 + ); + assert!(body["generationConfig"]["thinkingConfig"] + .get("thinkingLevel") + .is_none()); + + // Off: Flash can fully disable; Pro has a 128-token minimum. + let body = proxy_body_for("gemini-2.5-flash", "none"); + assert_eq!( + body["generationConfig"]["thinkingConfig"]["thinkingBudget"], + 0 + ); + let body = proxy_body_for("gemini-2.5-pro", "none"); + assert_eq!( + body["generationConfig"]["thinkingConfig"]["thinkingBudget"], + 128 + ); + + // Unknown token -> dynamic budget (provider default behavior). + let body = proxy_body_for("gemini-2.5-pro", "custom-level"); + assert_eq!( + body["generationConfig"]["thinkingConfig"]["thinkingBudget"], + -1 + ); + } + #[test] fn builds_standard_google_ai_endpoint_from_model_resource_name() { assert_eq!( diff --git a/backend/windmill-ai/src/sse.rs b/backend/windmill-ai/src/sse.rs index 35a4e43acc..bb1b9d9731 100644 --- a/backend/windmill-ai/src/sse.rs +++ b/backend/windmill-ai/src/sse.rs @@ -522,6 +522,7 @@ impl SSEParser for GeminiSSEParser { let extra_content = tool_call.thought_signature.map(|sig| ExtraContent { google: Some(GoogleExtraContent { thought_signature: Some(sig) }), + bedrock: None, }); self.accumulated_tool_calls.insert( diff --git a/frontend/src/lib/components/copilot/chat/AIChat.svelte b/frontend/src/lib/components/copilot/chat/AIChat.svelte index 52a46972eb..f186149039 100644 --- a/frontend/src/lib/components/copilot/chat/AIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChat.svelte @@ -143,6 +143,7 @@ role: 'assistant', content: aiChatManager.currentReply, ...(aiChatManager.currentReasoning ? { reasoning: aiChatManager.currentReasoning } : {}), + streaming: true, contextElements: aiChatManager.contextManager .getSelectedContext() .filter((c) => c.type === 'code') diff --git a/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte b/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte index ee370ad626..dd6e6a73a2 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte @@ -19,14 +19,15 @@ let { message }: Props = $props() - // Trimmed: rendered in a whitespace-pre-wrap block, so provider-sent leading/ - // trailing whitespace would otherwise show as a stray space before the content. const reasoning = $derived( message.role === 'assistant' ? message.reasoning?.trim() || undefined : undefined ) - // Spinner while the reasoning text streams before the answer. Textless reasoning - // (e.g. OpenAI) is surfaced on the typing indicator instead of a card. - const reasoningStreaming = $derived(!!reasoning && !message.content) + // Spinner while the reasoning text streams before the answer. Only the live + // synthetic message carries `streaming` — a finalized reasoning-only message + // (thinking that led straight to a tool call) must not look in-progress. + const reasoningStreaming = $derived( + !!reasoning && message.role === 'assistant' && !!message.streaming && !message.content + ) // Expand while still thinking, collapse once the answer begins — unless toggled. let reasoningToggled = $state(undefined) const reasoningExpanded = $derived(reasoningToggled ?? reasoningStreaming) @@ -71,9 +72,7 @@ {#if reasoning} -
+
(nbDisplayed += 30)}>load 30 more {/if} {/if} From eba70ce735e8bb032d6c7992805d72fc697dd36f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 14 Jun 2026 10:18:05 +0200 Subject: [PATCH 014/246] dedup folder labels to prevent each_key_duplicate crash (#9565) Folder labels are exposed verbatim as `inherited_labels` (via the `folder_labels` SQL function) and rendered in keyed `{#each}` blocks that throw Svelte's `each_key_duplicate` on a repeated key, crashing the list views. The UI dedups labels on entry, but API / CLI / git-sync writes do not, so a folder.yaml with `labels: [foo, foo]` persists duplicates. - Dedup on write in create_folder and update_folder (order-preserving). - Make folder_labels() dedup on read so it is resilient regardless of how a row was populated, plus a one-time cleanup of already-persisted duplicates so direct folder.labels reads (folder list, editor) are safe too. Co-authored-by: Claude Opus 4.8 (1M context) --- ...0260614075900_dedup_folder_labels.down.sql | 6 ++++ .../20260614075900_dedup_folder_labels.up.sql | 33 +++++++++++++++++++ backend/windmill-api-groups/src/folders.rs | 16 +++++++-- 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/20260614075900_dedup_folder_labels.down.sql create mode 100644 backend/migrations/20260614075900_dedup_folder_labels.up.sql diff --git a/backend/migrations/20260614075900_dedup_folder_labels.down.sql b/backend/migrations/20260614075900_dedup_folder_labels.down.sql new file mode 100644 index 0000000000..51650bf747 --- /dev/null +++ b/backend/migrations/20260614075900_dedup_folder_labels.down.sql @@ -0,0 +1,6 @@ +-- Restore the original passthrough definition (the one-time data cleanup is not reverted). +CREATE OR REPLACE FUNCTION folder_labels(w_id text, item_path text) RETURNS text[] +LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ + SELECT labels FROM folder + WHERE workspace_id = w_id AND item_path LIKE 'f/%' AND name = split_part(item_path, '/', 2) +$$; diff --git a/backend/migrations/20260614075900_dedup_folder_labels.up.sql b/backend/migrations/20260614075900_dedup_folder_labels.up.sql new file mode 100644 index 0000000000..fbf0be8177 --- /dev/null +++ b/backend/migrations/20260614075900_dedup_folder_labels.up.sql @@ -0,0 +1,33 @@ +-- Folder labels are exposed verbatim as `inherited_labels` (via folder_labels) and +-- rendered in keyed `{#each}` blocks in the UI, which throw `each_key_duplicate` on a +-- repeated key. The write paths now dedup, but make the read resilient regardless of +-- how a row was populated, and clean up any duplicates already persisted. + +-- Dedup while preserving first-seen order. +CREATE OR REPLACE FUNCTION folder_labels(w_id text, item_path text) RETURNS text[] +LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ + SELECT ( + SELECT array_agg(l ORDER BY first_ord) + FROM ( + SELECT u.l, min(u.ord) AS first_ord + FROM unnest(f.labels) WITH ORDINALITY AS u(l, ord) + GROUP BY u.l + ) deduped + ) + FROM folder f + WHERE f.workspace_id = w_id AND item_path LIKE 'f/%' AND f.name = split_part(item_path, '/', 2) +$$; + +-- One-time cleanup of rows that already contain duplicate labels, so direct reads of +-- folder.labels (folder list, editor) are also safe. +UPDATE folder +SET labels = ( + SELECT array_agg(l ORDER BY first_ord) + FROM ( + SELECT u.l, min(u.ord) AS first_ord + FROM unnest(labels) WITH ORDINALITY AS u(l, ord) + GROUP BY u.l + ) deduped +) +WHERE labels IS NOT NULL + AND cardinality(labels) <> (SELECT count(DISTINCT x) FROM unnest(labels) AS x); diff --git a/backend/windmill-api-groups/src/folders.rs b/backend/windmill-api-groups/src/folders.rs index 8db0130cb5..1cd875fa17 100644 --- a/backend/windmill-api-groups/src/folders.rs +++ b/backend/windmill-api-groups/src/folders.rs @@ -86,6 +86,14 @@ pub struct UpdateFolder { pub labels: Option>, } +// Folder labels are surfaced verbatim as `inherited_labels` and rendered in keyed +// `{#each}` blocks; a repeated label is a duplicate key that crashes the list views. +// The UI dedups on entry but API/CLI/git-sync writes do not, so normalize on write. +fn dedup_labels(labels: &mut Vec) { + let mut seen = std::collections::HashSet::new(); + labels.retain(|l| seen.insert(l.clone())); +} + #[derive(Deserialize)] pub struct Owner { pub owner: String, @@ -226,8 +234,11 @@ async fn create_folder( Extension(webhook): Extension, Extension(cache): Extension>, Path(w_id): Path, - Json(ng): Json, + Json(mut ng): Json, ) -> Result { + if let Some(labels) = ng.labels.as_mut() { + dedup_labels(labels); + } if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, AuditAuthorable::username(&authed), @@ -471,7 +482,8 @@ async fn update_folder( ); } - if let Some(labels) = ng.labels.as_ref() { + if let Some(labels) = ng.labels.as_mut() { + dedup_labels(labels); if labels.is_empty() { // normalize cleared labels to NULL so the field stays out of API/tarball output sqlb.set("labels", "NULL"); From d98efb5711cdb9a619e6aaebfc6feed1fc79302b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 14 Jun 2026 22:31:18 +0200 Subject: [PATCH 015/246] prevent path traversal via log_file_index in log endpoints (#9569) Co-authored-by: Claude Opus 4.8 (1M context) --- backend/windmill-api-jobs/src/jobs_export.rs | 16 +++++- backend/windmill-api/src/jobs.rs | 17 ++++--- backend/windmill-common/src/jobs.rs | 53 ++++++++++++++++++-- backend/windmill-object-store/src/lib.rs | 4 ++ 4 files changed, 79 insertions(+), 11 deletions(-) diff --git a/backend/windmill-api-jobs/src/jobs_export.rs b/backend/windmill-api-jobs/src/jobs_export.rs index 5de2c51795..d8f1826948 100644 --- a/backend/windmill-api-jobs/src/jobs_export.rs +++ b/backend/windmill-api-jobs/src/jobs_export.rs @@ -16,7 +16,7 @@ use uuid::Uuid; use windmill_common::{ db::UserDB, error, - jobs::{JobKind, JobStatus, JobTriggerKind}, + jobs::{is_safe_log_file_path, JobKind, JobStatus, JobTriggerKind}, scripts::ScriptLang, utils::{paginate, paginate_without_limits, require_admin, Pagination}, }; @@ -328,6 +328,20 @@ pub async fn import_completed_jobs( ) -> error::Result { require_admin(authed.is_admin, &authed.username)?; + // log_file_index is read back by the log endpoints as paths under the windmill + // log directory; an attacker-supplied traversal here would become an arbitrary + // file read. Reject anything that could escape the log directory at ingestion. + for job in &jobs { + if let Some(file_index) = &job.log_file_index { + if file_index.iter().any(|p| !is_safe_log_file_path(p)) { + return Err(error::Error::BadRequest(format!( + "Invalid log_file_index for job {}: entries must be relative paths without '..'", + job.id + ))); + } + } + } + let mut tx = user_db.begin(&authed).await?; for job in jobs { diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 895dbed886..45de557aa8 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -34,8 +34,8 @@ use windmill_common::db::UserDbWithAuthed; use windmill_common::error::JsonResult; use windmill_common::flow_status::{JobResult, RestartedFrom}; use windmill_common::jobs::{ - format_completed_job_result, format_result, is_valid_entrypoint_name, DynamicInput, - ENTRYPOINT_OVERRIDE, + format_completed_job_result, format_result, is_safe_log_file_path, is_valid_entrypoint_name, + DynamicInput, ENTRYPOINT_OVERRIDE, }; #[cfg(feature = "run_inline")] use windmill_common::jobs::{ @@ -1912,12 +1912,17 @@ async fn get_logs_from_disk( if log_offset > 0 { if let Some(file_index) = log_file_index.clone() { for file_p in &file_index { - if !tokio::fs::metadata(format!("{}/{file_p}", *WINDMILL_DIR)) - .await - .is_ok() - { + if !is_safe_log_file_path(file_p) { return None; } + let local_file = format!("{}/{file_p}", *WINDMILL_DIR); + // Defense in depth: refuse to read through a symlink so a planted + // symlink under the log directory cannot exfiltrate arbitrary files. + match tokio::fs::symlink_metadata(&local_file).await { + Ok(meta) if meta.file_type().is_symlink() => return None, + Ok(_) => {} + Err(_) => return None, + } } let logs = logs.to_string(); diff --git a/backend/windmill-common/src/jobs.rs b/backend/windmill-common/src/jobs.rs index fd05c03ec0..78f5a6ee5a 100644 --- a/backend/windmill-common/src/jobs.rs +++ b/backend/windmill-common/src/jobs.rs @@ -283,6 +283,19 @@ pub fn format_completed_job_result(mut cj: CompletedJob) -> CompletedJob { cj } +/// `log_file_index` is normally written by the worker as job-id-scoped relative +/// paths under the windmill log directory. Any code path that lets a request +/// control this value (e.g. job import) must reject entries that could escape +/// that directory, otherwise the log-reading endpoints become an arbitrary file +/// read primitive. Rejects path traversal (`..`) and absolute paths; on-disk +/// readers additionally refuse symlinks (see `get_logs_from_disk`). +pub fn is_safe_log_file_path(file_p: &str) -> bool { + !file_p.is_empty() + && !file_p.starts_with('/') + && !file_p.starts_with('\\') + && !file_p.split(['/', '\\']).any(|c| c == "..") +} + pub async fn get_logs_from_disk( log_offset: i32, logs: &str, @@ -291,12 +304,17 @@ pub async fn get_logs_from_disk( if log_offset > 0 { if let Some(file_index) = log_file_index.clone() { for file_p in &file_index { - if !tokio::fs::metadata(format!("{}/{file_p}", *WINDMILL_DIR)) - .await - .is_ok() - { + if !is_safe_log_file_path(file_p) { return None; } + let local_file = format!("{}/{file_p}", *WINDMILL_DIR); + // Defense in depth: refuse to read through a symlink so a planted + // symlink under the log directory cannot exfiltrate arbitrary files. + match tokio::fs::symlink_metadata(&local_file).await { + Ok(meta) if meta.file_type().is_symlink() => return None, + Ok(_) => {} + Err(_) => return None, + } } let logs = logs.to_string(); @@ -439,3 +457,30 @@ pub struct WorkerInternalServerInlineUtils { // The server cannot call the worker functions directly because they are independent crates pub static WORKER_INTERNAL_SERVER_INLINE_UTILS: OnceCell = OnceCell::new(); + +#[cfg(test)] +mod tests { + use super::is_safe_log_file_path; + + #[test] + fn safe_log_file_paths_are_accepted() { + // Legit worker-written entries are job-id-scoped relative paths. + assert!(is_safe_log_file_path( + "0190d3e2-0000-7000-8000-000000000000/0.txt" + )); + assert!(is_safe_log_file_path("logs/abc/chunk1.log")); + assert!(is_safe_log_file_path("file..with..dots.txt")); + } + + #[test] + fn traversal_and_absolute_paths_are_rejected() { + assert!(!is_safe_log_file_path("")); + assert!(!is_safe_log_file_path("../../../../etc/passwd")); + assert!(!is_safe_log_file_path("a/../../etc/passwd")); + assert!(!is_safe_log_file_path("..")); + assert!(!is_safe_log_file_path("/etc/passwd")); + assert!(!is_safe_log_file_path("/proc/self/environ")); + assert!(!is_safe_log_file_path("\\windows\\path")); + assert!(!is_safe_log_file_path("a\\..\\..\\b")); + } +} diff --git a/backend/windmill-object-store/src/lib.rs b/backend/windmill-object-store/src/lib.rs index aea5a0e4cd..fe2d8ce347 100644 --- a/backend/windmill-object-store/src/lib.rs +++ b/backend/windmill-object-store/src/lib.rs @@ -52,6 +52,7 @@ use tokio::task; #[cfg(feature = "parquet")] use windmill_common::error::to_anyhow; #[cfg(feature = "parquet")] +use windmill_common::jobs::is_safe_log_file_path; use windmill_common::utils::rd_string; #[cfg(all(feature = "parquet", feature = "private"))] pub mod job_s3_helpers_ee; @@ -1296,6 +1297,9 @@ pub async fn get_logs_from_store( ) -> Option>> { if log_offset > 0 { if let Some(file_index) = log_file_index.clone() { + if file_index.iter().any(|p| !is_safe_log_file_path(p)) { + return None; + } if let Some(os) = get_object_store().await { let logs = logs.to_string(); let stream = async_stream::stream! { From d0aaba0f167dc1b4603719884f8048af96ba77bf Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 14 Jun 2026 23:48:03 +0200 Subject: [PATCH 016/246] require pinned sha for inline raw_code in viewer app run mode (#9570) * fix: require pinned sha for inline raw_code in viewer app run mode * fix: gate rd_string import behind parquet feature to fix oss build * fix: also require pin for raw_code with app_script id in viewer run mode --- backend/tests/app_preview_auth.rs | 92 ++++++++++++++++++++- backend/tests/fixtures/app_preview_auth.sql | 8 ++ backend/windmill-api/src/apps.rs | 19 ++++- backend/windmill-object-store/src/lib.rs | 1 + 4 files changed, 118 insertions(+), 2 deletions(-) diff --git a/backend/tests/app_preview_auth.rs b/backend/tests/app_preview_auth.rs index 4653303392..2a69004a7b 100644 --- a/backend/tests/app_preview_auth.rs +++ b/backend/tests/app_preview_auth.rs @@ -17,7 +17,12 @@ //! must not over-block the legitimate editor flow), //! - preview is confined to paths the caller can read (defense-in-depth //! against scoped tokens / cross-namespace preview), and -//! - run mode (no `force_viewer_static_fields`) is unaffected by the guard. +//! - run mode (no `force_viewer_static_fields`) is unaffected by the preview +//! guard, and +//! - run mode against a deployed Viewer app rejects caller-supplied inline +//! `raw_code` whose sha is not publisher-pinned (CVE-2026-22683 residual: +//! the Viewer default-triggerable fallback let any caller / an operator run +//! arbitrary code as themselves, bypassing the content-hash pin). use serde_json::json; use sqlx::{Pool, Postgres}; @@ -254,5 +259,90 @@ async fn test_app_preview_authorization(db: Pool) -> anyhow::Result<() "rejection must be the jobs:run scope gate, got: {body}" ); + // 9. RUN-MODE REGRESSION (CVE-2026-22683 residual): in run mode (no + // `force_viewer_static_fields`) against a deployed Viewer-mode app, + // caller-supplied inline `raw_code` whose `rawscript/` is not pinned + // in the app's `triggerables_v2` must be rejected by the policy — it must + // not resolve via the Viewer default triggerable and run as the caller + // (the same preview-class execution an operator is denied in step 1). + let run_mode_raw_code = json!({ + "args": {}, + "component": "comp", + "raw_code": { + "language": "bash", + "content": "id; echo RCE_$(whoami)", + "path": "x" + } + }); + let resp = authed( + client().post(format!("{base}/u/test-user/vapp")), + "OPERATOR_TOKEN", + ) + .json(&run_mode_raw_code) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!( + status, 400, + "run-mode inline raw_code against a Viewer app must be rejected, not run as the caller (got {status}): {body}" + ); + assert!( + body.contains("forbidden by policy"), + "rejection must be the content-hash pin (unpinned rawscript), got: {body}" + ); + + // 10. The content-pin fix is not operator-specific: even a regular + // non-operator member (who could run their own code via + // `/jobs/run/preview`) must not be able to substitute unpinned code into + // someone else's deployed Viewer app — the deployed-app integrity break. + let resp = authed( + client().post(format!("{base}/u/test-user/vapp")), + "SECRET_TOKEN_2", + ) + .json(&run_mode_raw_code) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!( + status, 400, + "run-mode unpinned raw_code must be rejected for any caller, not just operators (got {status}): {body}" + ); + + // 11. The pin requirement also covers `raw_code` carrying an `app_script` + // `id`. The id resolves to `rawscript/` of any app_script row + // by number (no app scoping), so without the pin a caller could run a + // script belonging to another app against this Viewer app. Here `999777` + // belongs to `u/test-user/private`, not to the targeted `vapp`, and its + // sha is absent from `vapp`'s empty `triggerables_v2` — it must be + // rejected, not fall back to the Viewer default. + let resp = authed( + client().post(format!("{base}/u/test-user/vapp")), + "OPERATOR_TOKEN", + ) + .json(&json!({ + "args": {}, + "component": "comp", + "id": 999777, + "raw_code": { + "language": "deno", + "content": "export function main() { return 1; }", + "path": "x" + } + })) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!( + status, 400, + "run-mode raw_code with an unpinned app_script id must be rejected against a Viewer app (got {status}): {body}" + ); + assert!( + body.contains("forbidden by policy"), + "rejection must be the content-hash pin (unpinned app_script sha), got: {body}" + ); + Ok(()) } diff --git a/backend/tests/fixtures/app_preview_auth.sql b/backend/tests/fixtures/app_preview_auth.sql index 9fcda61c51..b94e08f3fd 100644 --- a/backend/tests/fixtures/app_preview_auth.sql +++ b/backend/tests/fixtures/app_preview_auth.sql @@ -32,3 +32,11 @@ INSERT INTO app (id, workspace_id, path, summary, policy, versions) VALUES (999002, 'test-workspace', 'u/test-user-2/ownapp', 'own app', '{}'::jsonb, '{}'); INSERT INTO app_script (id, app, hash, code, code_sha256) VALUES (999778, 999002, repeat('c', 64), 'export function main(){ return "ok" }', repeat('d', 64)); + +-- A deployed empty Viewer-mode app owned by `test-user` with NO runnables pinned +-- in `triggerables_v2`. Used to assert run mode rejects caller-supplied inline +-- `raw_code` whose sha is not publisher-pinned (the CVE-2026-22683 residual: +-- the Viewer default fallback let any caller / an operator run arbitrary code). +INSERT INTO app (id, workspace_id, path, summary, policy, versions) VALUES + (999003, 'test-workspace', 'u/test-user/vapp', 'empty viewer app', + '{"execution_mode": "viewer", "triggerables_v2": {}}'::jsonb, '{}'); diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index ed75c25c6f..4a245530e5 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -2333,6 +2333,15 @@ async fn execute_component( &policy }; + // Caller-supplied inline code (`raw_code`), with or without an + // `app_script` id. Its resolved `rawscript/` key must be present + // in the policy triggerables below — it must never resolve via the + // Viewer default fallback. Without `id` the caller supplies the code + // verbatim; with `id` it selects any `app_script` row by number (no + // app/workspace scoping), so both let a caller run code the publisher + // never pinned for this app. + let is_inline_raw_code = payload.raw_code.is_some(); + // Compute the path for the triggerables map: // - flow: `flow/` // - script: `script/` @@ -2370,7 +2379,15 @@ async fn execute_component( .get(path) // start with `path` in case we can avoid the next` format!`. .or_else(|| triggerables_v2.get(&format!("{}:{}", payload.component, &path))) .or(match policy.execution_mode { - ExecutionMode::Viewer => Some(&policy_triggerables_default), + // A Viewer app may invoke any deployed `script`/`flow` it + // references (resolved as the caller), but caller-supplied + // inline `raw_code` must match a publisher-pinned + // `rawscript/` entry — otherwise an unauthorized caller + // (e.g. an operator, barred from `/jobs/run/preview`) could + // run code the publisher never pinned for this app. + ExecutionMode::Viewer if !is_inline_raw_code => { + Some(&policy_triggerables_default) + } _ => None, }) .ok_or_else(|| Error::BadRequest(format!("Path {path} forbidden by policy")))?; diff --git a/backend/windmill-object-store/src/lib.rs b/backend/windmill-object-store/src/lib.rs index fe2d8ce347..4b1279609f 100644 --- a/backend/windmill-object-store/src/lib.rs +++ b/backend/windmill-object-store/src/lib.rs @@ -53,6 +53,7 @@ use tokio::task; use windmill_common::error::to_anyhow; #[cfg(feature = "parquet")] use windmill_common::jobs::is_safe_log_file_path; +#[cfg(feature = "parquet")] use windmill_common::utils::rd_string; #[cfg(all(feature = "parquet", feature = "private"))] pub mod job_s3_helpers_ee; From 317a8629d1c8436d2a6f3443bd25b81d606ce283 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 14 Jun 2026 23:50:18 +0200 Subject: [PATCH 017/246] fix(ai): enforce resource authz when loading MCP tools in agent worker (#9571) * fix(ai): enforce resource authz when loading MCP tools in agent worker Co-Authored-By: Claude Opus 4.8 (1M context) * test: describe MCP authz test invariants instead of drafting history Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- backend/tests/fixtures/mcp_resource_authz.sql | 25 +++++ backend/tests/mcp_resource_authz.rs | 102 ++++++++++++++++++ backend/windmill-worker/src/ai/utils.rs | 29 +++-- backend/windmill-worker/src/lib.rs | 6 ++ 4 files changed, 147 insertions(+), 15 deletions(-) create mode 100644 backend/tests/fixtures/mcp_resource_authz.sql create mode 100644 backend/tests/mcp_resource_authz.rs diff --git a/backend/tests/fixtures/mcp_resource_authz.sql b/backend/tests/fixtures/mcp_resource_authz.sql new file mode 100644 index 0000000000..ecf3f01dc3 --- /dev/null +++ b/backend/tests/fixtures/mcp_resource_authz.sql @@ -0,0 +1,25 @@ +-- Fixture for the MCP resource-authorization regression test (WIN-2041, +-- GHSA-7qg3-pr4g-cq5x). +-- +-- Models a low-privileged developer (test-user-3, a plain workspace member) who +-- references, from an AI Agent flow, an MCP resource living in a private folder +-- they have NO access to. The AI agent worker must refuse to load that resource +-- because the developer's identity lacks resources:read on it. + +-- Private folder the developer cannot read (empty extra_perms, owned by admin). +INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by) +VALUES ('test-workspace', 'private', 'Private Folder', '{"u/test-user"}', '{}', 'test-user'); + +-- MCP resource the developer is NOT allowed to read. The URL is a non-resolvable +-- public host so that, for an authorized caller, resolution succeeds but the +-- later connection step fails deterministically without network access. +INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, created_by) +VALUES ( + 'test-workspace', + 'f/private/admin_mcp', + '{"name": "admin_mcp", "url": "https://mcp.invalid.windmill.test"}', + 'Private MCP resource only admin can read', + 'mcp', + '{}', + 'test-user' +); diff --git a/backend/tests/mcp_resource_authz.rs b/backend/tests/mcp_resource_authz.rs new file mode 100644 index 0000000000..0fa35933f4 --- /dev/null +++ b/backend/tests/mcp_resource_authz.rs @@ -0,0 +1,102 @@ +//! Regression test for the MCP resource-authorization bypass +//! (WIN-2041, GHSA-7qg3-pr4g-cq5x). +//! +//! Invariant: the AI Agent worker (`load_mcp_tools`) must load an MCP resource +//! only when the job identity is allowed to read it — the same +//! `resources:read:{path}` + RLS gate the regular MCP tools API (`get_mcp_tools`) +//! enforces. It loads the resource through the job's permissioned client +//! (`AuthedClient::get_resource_value`, the `resources/get_value` path), not the +//! raw DB pool. Otherwise a low-privileged user who can edit a flow could make +//! the agent load and use an MCP resource (URL + inline headers + token) they are +//! not allowed to read: a confused-deputy bypass. +//! +//! This test pins, against the `mcp_resource_authz` fixture (an MCP resource in +//! a folder only the admin can read): +//! - a plain developer (test-user-3) is DENIED loading the private resource, +//! before any MCP connection is attempted, and the resource never leaks; +//! - an admin (test-user) clears the gate, the resource resolves, and loading +//! only fails later at the connect step — proving no over-blocking. +#![cfg(feature = "mcp")] + +use sqlx::{Pool, Postgres}; +use windmill_common::client::AuthedClient; +use windmill_test_utils::*; +use windmill_worker::{load_mcp_tools, McpResourceConfig}; + +fn config() -> Vec { + vec![McpResourceConfig { + resource_path: "$res:f/private/admin_mcp".to_string(), + include_tools: None, + exclude_tools: None, + }] +} + +// The Ok variant ((HashMap<_, Arc>, Vec)) does not implement +// Debug, so `expect_err` is unavailable — extract the error explicitly. +async fn load_err(db: &Pool, client: &AuthedClient, ctx: &str) -> String { + match load_mcp_tools(db, "test-workspace", config(), client).await { + Ok(_) => panic!("{ctx}"), + Err(e) => e.to_string(), + } +} + +#[sqlx::test(fixtures("base", "mcp_resource_authz"))] +async fn test_mcp_resource_not_loaded_without_authorization( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let base_internal_url = format!("http://localhost:{}", server.addr.port()); + + // ---- CORE REGRESSION: the developer cannot read the private MCP resource, + // so the worker must refuse to load it — before any MCP connection is + // attempted, and without the resource ever leaking. + let dev_client = AuthedClient::new( + base_internal_url.clone(), + "test-workspace".to_string(), + "SECRET_TOKEN_3".to_string(), + None, + ); + let msg = load_err( + &db, + &dev_client, + "developer must be denied loading a resource they can't read", + ) + .await; + assert!( + msg.contains("don't have access"), + "denial should come from the resource-RLS gate, not a connection error: {msg}" + ); + // An unauthorized caller must be blocked before MCP client creation, so no + // resource material (URL, headers, token) is ever loaded for them. + assert!( + !msg.contains("Failed to create MCP client"), + "developer must be blocked before the connection step (would mean the resource was loaded): {msg}" + ); + + // ---- NO OVER-BLOCKING: an admin clears the resource-RLS gate, so the + // resource resolves and loading only fails later at the connect step. + let admin_client = AuthedClient::new( + base_internal_url, + "test-workspace".to_string(), + "SECRET_TOKEN".to_string(), + None, + ); + let msg = load_err( + &db, + &admin_client, + "the fixture URL is non-resolvable, so the connect step must fail", + ) + .await; + assert!( + !msg.contains("don't have access"), + "admin must clear the resource-RLS gate, not be denied: {msg}" + ); + assert!( + msg.contains("Failed to create MCP client"), + "admin should resolve the resource and only fail at the connect step: {msg}" + ); + + Ok(()) +} diff --git a/backend/windmill-worker/src/ai/utils.rs b/backend/windmill-worker/src/ai/utils.rs index 353bab17a0..6e80551650 100644 --- a/backend/windmill-worker/src/ai/utils.rs +++ b/backend/windmill-worker/src/ai/utils.rs @@ -557,21 +557,20 @@ pub async fn load_mcp_tools( tracing::debug!("Loading MCP tools from resource: {}", config.resource_path); let path = config.resource_path.trim_start_matches("$res:"); - let mcp_resource = { - // Fetch the resource from database - let resource= sqlx::query_scalar!( - "SELECT value as \"value: sqlx::types::Json>\" FROM resource WHERE path = $1 AND workspace_id = $2", - &path, - &workspace_id - ) - .fetch_optional(db) - .await? - .ok_or_else(|| Error::NotFound(format!("Could not find the resource {}, update the resource path in the workspace settings", config.resource_path)))? - .ok_or_else(|| Error::BadRequest(format!("Empty resource value for {}", config.resource_path)))?; - - serde_json::from_str::(resource.0.get()) - .context("Failed to parse MCP resource")? - }; + // Load the resource through the job's permissioned (RLS + scope) path so + // a flow author cannot make the agent use an MCP resource their identity + // is not allowed to read (resources:read:{path}). Reading through the raw + // db pool here would bypass the authorization enforced by the regular MCP + // tools API (get_mcp_tools) and act as a confused deputy. + let mcp_resource = client + .get_resource_value::(path) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to load MCP resource {}: {}", + config.resource_path, e + )) + })?; let resource_name = mcp_resource.name.clone(); diff --git a/backend/windmill-worker/src/lib.rs b/backend/windmill-worker/src/lib.rs index f8b3edd069..9fa0b514c4 100644 --- a/backend/windmill-worker/src/lib.rs +++ b/backend/windmill-worker/src/lib.rs @@ -22,6 +22,12 @@ mod r_executor; mod ai; mod ai_executor; + +// Exposed for the MCP resource-authorization regression test +// (tests/mcp_resource_authz.rs): the AI agent worker must load MCP resources +// through the job's permissioned client, not the raw DB pool. +#[cfg(feature = "mcp")] +pub use ai::utils::{load_mcp_tools, McpResourceConfig}; mod bun_executor; pub mod common; mod config; From 066d7a4726192d412357adbb30c907b76ea3ad58 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 15 Jun 2026 00:07:46 +0200 Subject: [PATCH 018/246] block operators from inline preview job execution (#9572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /api/w/{workspace}/jobs/run_inline/preview` ran request-supplied code inline (in-process, e.g. DuckDB) but was missing the operator authorization guard that its sibling `/jobs/run/preview` enforces. An authenticated operator — the most restricted role, which must not run preview jobs — could execute arbitrary code in a single request (file read/write, and OS command execution via the DuckDB `shellfs` extension when worker egress is available). This is the incomplete-fix residual of CVE-2026-22683 / GHSA-9q9g-rp9x-244h, whose v1.615.0 patch covered the entity-CRUD endpoints but left this direct inline-exec sink uncovered. Add the same `is_operator` guard from `run_preview_script`. Audited the rest of the preview/inline arbitrary-code endpoints (run_preview_script, run_bundle_preview_script, run_preview_flow_job, the wait_result wrappers, run_dynamic_select inline variant, dependency jobs) — all already carry the guard. The `run_inline_script_by_path`/`by_hash` endpoints run deployed scripts (operator-allowed, scope-checked) and correctly remain ungated. Fixes WIN-2043 (GHSA-pp5h-96x3-3wqq). Co-authored-by: Claude Opus 4.8 (1M context) --- .../tests/fixtures/inline_preview_auth.sql | 14 +++ backend/tests/inline_preview_auth.rs | 90 +++++++++++++++++++ backend/windmill-api/src/jobs.rs | 10 ++- 3 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 backend/tests/fixtures/inline_preview_auth.sql create mode 100644 backend/tests/inline_preview_auth.rs diff --git a/backend/tests/fixtures/inline_preview_auth.sql b/backend/tests/fixtures/inline_preview_auth.sql new file mode 100644 index 0000000000..59fe2dc917 --- /dev/null +++ b/backend/tests/fixtures/inline_preview_auth.sql @@ -0,0 +1,14 @@ +-- Fixture for the inline preview authorization regression test (GHSA-pp5h-96x3-3wqq). +-- Layered on top of `base` (which provides test-workspace and the non-operator +-- `test-user-2`/SECRET_TOKEN_2). Adds an Operator member so we can assert that +-- Operators cannot reach the arbitrary-code inline preview path +-- (`POST /jobs/run_inline/preview`). + +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name) + VALUES ('operator@windmill.dev', 'not-a-real-hash', 'password', false, true, 'Operator User'); + +INSERT INTO usr(workspace_id, email, username, is_admin, operator, role) VALUES + ('test-workspace', 'operator@windmill.dev', 'operator-user', false, true, 'Operator'); + +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES + (encode(sha256('OPERATOR_TOKEN'::bytea), 'hex'), 'OPERATOR_T', 'OPERATOR_TOKEN', 'operator@windmill.dev', 'operator token', false); diff --git a/backend/tests/inline_preview_auth.rs b/backend/tests/inline_preview_auth.rs new file mode 100644 index 0000000000..97b70fb545 --- /dev/null +++ b/backend/tests/inline_preview_auth.rs @@ -0,0 +1,90 @@ +//! Regression test for the inline preview authorization bypass (GHSA-pp5h-96x3-3wqq). +//! +//! `POST /api/w/:workspace/jobs/run_inline/preview` -> `run_inline_preview_script` +//! runs request-supplied code inline (in-process via DuckDB), i.e. it is an +//! arbitrary-code-execution sibling of `/jobs/run/preview`. The bug was that +//! this handler was missing the Operator guard that `run_preview_script` +//! enforces, so an authenticated Operator (a run-only user who must not be able +//! to run preview jobs) could execute arbitrary code in a single request. This +//! was the incomplete-fix residual of CVE-2026-22683, whose v1.615.0 patch only +//! covered the entity-CRUD endpoints and left this direct inline-exec sink open. +//! +//! This test pins down: +//! - an Operator is rejected by the operator guard (the core fix; pre-fix this +//! reached the inline executor instead of returning 401), and +//! - a regular non-operator passes the guard (the fix must not over-block the +//! legitimate inline preview flow): in the test harness the worker inline +//! utils are not registered, so a caller past the guard gets the distinct +//! "worker inline functions" error rather than the operator rejection. + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + builder.header("Authorization", format!("Bearer {}", token)) +} + +/// An inline preview request: request-supplied `content` to run via DuckDB. +fn inline_preview_body() -> serde_json::Value { + json!({ + "language": "duckdb", + "content": "SELECT content FROM read_text(['/etc/passwd']);", + "args": {} + }) +} + +const OPERATOR_GUARD_MSG: &str = "Operators cannot run preview jobs"; + +#[sqlx::test(fixtures("base", "inline_preview_auth"))] +async fn test_inline_preview_authorization(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let url = format!("http://localhost:{port}/api/w/test-workspace/jobs/run_inline/preview"); + + // 1. CORE REGRESSION: an Operator must be rejected by the operator guard. + // Pre-fix this fell through to the inline executor (arbitrary code + // execution); post-fix it returns 401 with the operator guard message. + let resp = authed(client().post(&url), "OPERATOR_TOKEN") + .json(&inline_preview_body()) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!( + status, 401, + "Operator must be rejected from inline preview (got {status}): {body}" + ); + assert!( + body.contains(OPERATOR_GUARD_MSG), + "rejection must be the operator guard, got: {body}" + ); + + // 2. The fix must NOT over-block a legitimate non-operator: a regular member + // passes the operator + scope checks. The test harness does not register + // the worker inline utils, so the request proceeds past the guard and + // fails later with the distinct "worker inline functions" error — proving + // the operator guard did not reject it. + let resp = authed(client().post(&url), "SECRET_TOKEN_2") + .json(&inline_preview_body()) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_ne!( + status, 401, + "non-operator must not be blocked by the operator guard (got {status}): {body}" + ); + assert!( + !body.contains(OPERATOR_GUARD_MSG), + "non-operator must not hit the operator guard, got: {body}" + ); + + Ok(()) +} diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 45de557aa8..483541cf2c 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -6320,8 +6320,14 @@ async fn run_inline_preview_script( Path(w_id): Path, Json(preview): Json, ) -> error::Result { - // Same arbitrary-code class as run_preview_script: a narrowly-scoped token - // must not be able to run request-supplied code through inline preview. + // Same arbitrary-code class as run_preview_script: operators are blocked from + // running request-supplied code, and a narrowly-scoped token must not escape + // its scope through inline preview. + if authed.is_operator { + return Err(error::Error::NotAuthorized( + "Operators cannot run preview jobs for security reasons".to_string(), + )); + } check_scopes(&authed, || format!("jobs:run"))?; if let Some(job_id) = job_id { register_potential_assets_on_inline_execution(job_id, &w_id, &preview); From 84df11177f2009bff007e9b722b55a9a5a63c06a Mon Sep 17 00:00:00 2001 From: Amey Pawar <138877912+ameyypawar@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:34:07 +0530 Subject: [PATCH 019/246] fix(folders): allow hyphens in folder names (#9566) The create-folder UI and API rejected hyphens in folder names, even though hyphens are valid in paths elsewhere: owner/path validation already permits them, and folders with hyphens can be created via the CLI or by deploying to an `f//...` path. This inconsistency blocked the common `folder-name` convention (e.g. relative imports like `../folder-name/logic.ts`). Allow `-` in the folder-name regex on both the backend (create endpoint) and the frontend create form, and update the validation messages. Fixes #8474 --- backend/windmill-api-groups/src/folders.rs | 23 +++++++++++++++++-- .../src/lib/components/FolderPicker.svelte | 4 ++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/backend/windmill-api-groups/src/folders.rs b/backend/windmill-api-groups/src/folders.rs index 1cd875fa17..eedb43bc1a 100644 --- a/backend/windmill-api-groups/src/folders.rs +++ b/backend/windmill-api-groups/src/folders.rs @@ -223,7 +223,7 @@ async fn check_name_conflict<'c>( } lazy_static! { - static ref VALID_FOLDER_NAME: Regex = Regex::new(r#"^[a-zA-Z_0-9]+$"#).unwrap(); + static ref VALID_FOLDER_NAME: Regex = Regex::new(r#"^[a-zA-Z_0-9-]+$"#).unwrap(); } async fn create_folder( @@ -255,7 +255,7 @@ async fn create_folder( if !VALID_FOLDER_NAME.is_match(&ng.name) { return Err(windmill_common::error::Error::BadRequest(format!( - "Folder name can only contain alphanumeric characters, underscores" + "Folder name can only contain alphanumeric characters, underscores, and hyphens" ))); } check_name_conflict(&mut tx, &w_id, &ng.name).await?; @@ -979,3 +979,22 @@ pub async fn log_folder_permission_change<'c, E: sqlx::Executor<'c, Database = P .await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn folder_name_allows_hyphens() { + // #8474: hyphens are valid in folder names, consistent with owner/path + // validation (which already permits them) and with folders created via + // the CLI / by deploying to an `f//...` path. + assert!(VALID_FOLDER_NAME.is_match("folder-name")); + assert!(VALID_FOLDER_NAME.is_match("foo_bar")); + assert!(VALID_FOLDER_NAME.is_match("Foo123")); + // Disallowed characters are still rejected. + assert!(!VALID_FOLDER_NAME.is_match("foo/bar")); + assert!(!VALID_FOLDER_NAME.is_match("foo bar")); + assert!(!VALID_FOLDER_NAME.is_match("")); + } +} diff --git a/frontend/src/lib/components/FolderPicker.svelte b/frontend/src/lib/components/FolderPicker.svelte index beb8300136..12656a791d 100644 --- a/frontend/src/lib/components/FolderPicker.svelte +++ b/frontend/src/lib/components/FolderPicker.svelte @@ -11,7 +11,7 @@ import { tick } from 'svelte' import { sendUserToast } from '$lib/toast' - const VALID_FOLDER_NAME = /^[a-zA-Z_0-9]+$/ + const VALID_FOLDER_NAME = /^[a-zA-Z_0-9-]+$/ let folders: { name: string; write: boolean }[] = $state([]) let filterText: string = $state('') @@ -125,7 +125,7 @@ !newFolderName ? '' : !VALID_FOLDER_NAME.test(newFolderName) - ? 'Folder name can only contain alphanumeric characters and underscores' + ? 'Folder name can only contain alphanumeric characters, underscores, and hyphens' : folders.some((f) => f.name === newFolderName) ? 'A folder with this name already exists' : '' From 251266cd8119dbef314314daed43aa43ab92f1c9 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 15 Jun 2026 08:40:07 +0200 Subject: [PATCH 020/246] fix(frontend): load resource value in JSON editor when resource type is missing (#9574) When a resource's type is not present in the workspace, ResourceForm falls back to the raw JSON editor regardless of the "As JSON" toggle. The rawCode seeding effect only ran when viewJsonSchema was true, so the editor stayed empty and the resource value was not shown. Seed rawCode whenever the raw editor is the active input (toggle on, or no schema-based form available). Fixes WIN-2044 Co-authored-by: Claude Opus 4.8 (1M context) --- frontend/src/lib/components/ResourceForm.svelte | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/ResourceForm.svelte b/frontend/src/lib/components/ResourceForm.svelte index 956f5561b4..90ba39275b 100644 --- a/frontend/src/lib/components/ResourceForm.svelte +++ b/frontend/src/lib/components/ResourceForm.svelte @@ -82,12 +82,21 @@ args = { content: textFileContent } } + // The raw JSON editor is the active input whenever the "As JSON" toggle is on, + // or no schema-based form can be rendered (e.g. the resource type is missing + // from the workspace). In both cases rawCode must be seeded from args. + let usesRawEditor = $derived( + !loadingSchema && + (viewJsonSchema || + (!resourceTypeInfo?.is_fileset && !(resourceSchema && resourceSchema.properties))) + ) + $effect(() => { if (rawCode !== undefined) parseJson() }) $effect(() => { - if (viewJsonSchema && rawCode === undefined) { + if (usesRawEditor && rawCode === undefined) { rawCode = JSON.stringify(args, null, 2) } }) From d8d48826ff9d1db035641b1cf73fa501312877be Mon Sep 17 00:00:00 2001 From: brohebot Date: Mon, 15 Jun 2026 14:43:18 +0800 Subject: [PATCH 021/246] fix windmill-client package metadata (#9564) Co-authored-by: hebo --- typescript-client/package.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/typescript-client/package.json b/typescript-client/package.json index 7dc37b2f7f..4f62b3cf43 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -4,6 +4,15 @@ "version": "1.723.0", "author": "Ruben Fiszel", "license": "Apache 2.0", + "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/windmill-labs/windmill.git", + "directory": "typescript-client" + }, + "bugs": { + "url": "https://github.com/windmill-labs/windmill/issues" + }, "sideEffects": false, "devDependencies": { "@types/node": "^20.17.16", From 2b471805bf1c92bb210cfacda217a4341e1f989c Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Mon, 15 Jun 2026 08:43:47 +0200 Subject: [PATCH 022/246] feat(frontend): precise AI chat context usage tracking + indicator (#9551) * feat: track real ai chat token usage and show context indicator * fix: keep context anchor full-history accurate after trimmed sends * nits * feat: restyle context indicator and disable trim for unknown windows * feat: hide context indicator below 50% usage when window is known * fix: gate 1M claude context window to sonnet/opus 4.6+ * refactor: import context window helpers from modelConfig directly * fix: keep base gpt-5 models at 400k context window * fix: exclude date-suffixed claude 4 ids from 1M window gate * refactor: replace context window heuristics with explicit model table * fix: account for context overhead in trim loop stop condition * fix: re-base context anchor when mode switch changes system prompt or tools * refactor: replace context estimation with usage-report-driven compaction Co-Authored-By: Claude Fable 5 * feat: chars/4 fallback for context usage when provider reports none Co-Authored-By: Claude Fable 5 * fix: remove unused slide import failing svelte-check Co-Authored-By: Claude Fable 5 * fix: re-seed context usage estimate on rewind so retry can compact Co-Authored-By: Claude Fable 5 * refactor: lazy read-side estimate fallback for context usage Co-Authored-By: Claude Fable 5 * test: fix reasoningRegistry mock to match resolveRequestReasoning Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../copilot/autocomplete/Autocompletor.ts | 3 +- .../copilot/chat/AIChatDisplay.svelte | 3 + .../copilot/chat/AIChatManager.svelte.ts | 199 ++++++++++--- .../copilot/chat/AIChatManager.test.ts | 281 +++++++++++++++++- .../copilot/chat/ContextUsageIndicator.svelte | 45 +++ .../copilot/chat/HistoryManager.svelte.ts | 22 +- .../components/copilot/chat/chatLoop.test.ts | 50 +++- .../lib/components/copilot/chat/chatLoop.ts | 21 +- .../components/copilot/chat/script/core.ts | 2 +- .../lib/components/copilot/chat/tokenUsage.ts | 70 ++++- .../src/lib/components/copilot/lib.test.ts | 51 +++- frontend/src/lib/components/copilot/lib.ts | 15 - .../src/lib/components/copilot/modelConfig.ts | 49 +++ 13 files changed, 713 insertions(+), 98 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte diff --git a/frontend/src/lib/components/copilot/autocomplete/Autocompletor.ts b/frontend/src/lib/components/copilot/autocomplete/Autocompletor.ts index b2484ff909..0ee2ad3f78 100644 --- a/frontend/src/lib/components/copilot/autocomplete/Autocompletor.ts +++ b/frontend/src/lib/components/copilot/autocomplete/Autocompletor.ts @@ -3,7 +3,8 @@ import { sleep } from '$lib/utils' import { editor as meditor, Position, languages, type IDisposable } from 'monaco-editor' import { LRUCache } from 'lru-cache' import { autocompleteRequest } from './request' -import { FIM_MAX_TOKENS, getModelContextWindow } from '../lib' +import { FIM_MAX_TOKENS } from '../lib' +import { getModelContextWindow } from '../modelConfig' import { setGlobalCSS } from '../shared' import { supportsAutocomplete } from '../utils' import { get } from 'svelte/store' diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 980376f64b..6eb1374ed9 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -27,6 +27,7 @@ import type { ContextElement } from './context' import ChatQuickActions from './ChatQuickActions.svelte' import ProviderModelSelector from './ProviderModelSelector.svelte' + import ContextUsageIndicator from './ContextUsageIndicator.svelte' import AIChatSettingsMenu from './AIChatSettingsMenu.svelte' import ChatMode from './ChatMode.svelte' import DatatableCreationPolicy from './DatatableCreationPolicy.svelte' @@ -535,6 +536,8 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> {#if inputPreface} {@render inputPreface()} {/if} + + (false) displayMessages = $state([]) messages = $state([]) + /** Provider-reported context size of the last committed turn (prompt + + * completion of its latest completion — exact, includes system prompt and + * tools), or undefined whenever no report describes the current history + * (provider never reported, turn failed, history rewound). Never holds a + * guess: readers go through `contextTokens`, which estimates lazily. */ + contextUsage = $state(undefined) autonomyMode = $state(getPersistedAutonomyMode()) autoAcceptEditsAvailable = $derived(supportsAutoAcceptEdits(this.mode)) autoAcceptEditsActive = $derived( @@ -267,47 +281,89 @@ export class AIChatManager { open = $derived(chatState.size > 0) - checkTokenUsageOverLimit = (messages: ChatCompletionMessageParam[]) => { - const estimatedTokens = messages.reduce((acc, message) => { - // one token is ~ 4 characters + // one token is ~ 4 characters + private estimateMessagesTokens = (messages: ChatCompletionMessageParam[]) => { + return messages.reduce((acc, message) => { const tokenPerCharacter = 4 - // handle content - if (message.content) { + if (typeof message.content === 'string') { acc += message.content.length / tokenPerCharacter + } else if (message.content) { + acc += JSON.stringify(message.content).length / tokenPerCharacter } - // Handle tool calls if (message.role === 'assistant' && message.tool_calls) { acc += JSON.stringify(message.tool_calls).length / tokenPerCharacter } return acc }, 0) - const model = getCurrentModel() - const modelContextWindow = getModelContextWindow(model.model) - return ( - estimatedTokens > - modelContextWindow - - Math.max(modelContextWindow * MAX_TOKENS_THRESHOLD_PERCENTAGE, MAX_TOKENS_HARD_LIMIT) - ) } - deleteOldestMessage = (messages: ChatCompletionMessageParam[], maxDepth: number = 10) => { - if (maxDepth <= 0 || messages.length <= 1) { - return messages - } - const removed = messages.shift() + /** Estimated tokens of the parts the messages array doesn't carry: the + * current system prompt and tool definitions. */ + private estimateOverheadTokens = () => { + const tokenPerCharacter = 4 + const systemTokens = + typeof this.systemMessage.content === 'string' + ? this.systemMessage.content.length / tokenPerCharacter + : 0 + const toolTokens = + this.tools.length > 0 + ? JSON.stringify(this.tools.map((t) => t.def)).length / tokenPerCharacter + : 0 + return systemTokens + toolTokens + } - // if the removed message is an assistant with tool calls, we need to delete correspding tool response. - if (removed?.role === 'assistant' && removed.tool_calls) { - if (messages.length > 0 && messages[0]?.role === 'tool') { - messages.shift() + /** + * chars/4 estimate of the full context as currently stored: messages plus + * the system prompt and tool definitions the next request would carry. + * Recomputed from scratch at each read — never accumulated — so errors + * don't compound. + */ + private estimateWholeContextTokens = () => + Math.round(this.estimateMessagesTokens(this.messages) + this.estimateOverheadTokens()) + + /** + * How full the context is right now — the single fallback rule, shared by + * the compaction trigger and the usage indicator: the provider's exact + * report when one describes the current history, a fresh estimate + * otherwise. Estimating at the read point (rather than writing estimates + * into `contextUsage`) means no code path that mutates history can leave + * a stale or missing value behind. + */ + contextTokens = $derived.by(() => this.contextUsage ?? this.estimateWholeContextTokens()) + + /** + * Drop-oldest compaction. Deletes messages from the front of the STORED + * history (the API messages — displayMessages keep the full conversation + * for the user) until at least `tokensToFree` estimated tokens are freed + * AND the remaining history starts on a user message: a leading tool + * result or assistant turn would dangle without the messages that + * introduced it. The most recent user message is never dropped. Returns + * the estimated tokens freed. + */ + compactOldestMessages = (tokensToFree: number): number => { + const last = this.messages.length - 1 + let drop = 0 + let freed = 0 + while (drop < last) { + if (freed >= tokensToFree && this.messages[drop].role === 'user') { + break } + freed += this.estimateMessagesTokens([this.messages[drop]]) + drop++ } - - // keep deleting messages until we are under the limit - if (this.checkTokenUsageOverLimit(messages)) { - return this.deleteOldestMessage(messages, maxDepth - 1) + if (drop === 0) { + return 0 } - return messages + this.messages = this.messages.slice(drop) + // User display messages carry the index of their API message so restart + // can rewind to it; re-base them on the compacted history. A message + // whose API counterpart was dropped clamps to 0: everything before it + // was dropped too (compaction only removes prefixes), so restarting + // from it restarts from an empty history. + this.displayMessages = this.displayMessages.map((m) => + m.role === 'user' ? { ...m, index: Math.max(0, m.index - drop) } : m + ) + return freed } loadApiTools = async () => { @@ -799,7 +855,7 @@ export class AIChatManager { } } }) - return result.addedMessages + return result } catch (err) { console.log('chatRequest error', err) console.error('chatRequest error', err) @@ -1062,18 +1118,43 @@ export class AIChatManager { break } + // Size of the request about to go out: contextTokens (provider report + // when current, fresh chars/4 estimate otherwise) plus the message + // being added below. Must be read BEFORE the push — the estimate path + // covers the stored history, so pushing first would double-count the + // new message. + const projectedContextTokens = this.contextTokens + this.estimateMessagesTokens([userMessage]) + this.messages.push(userMessage) - const modelLenAfterUser = this.messages.length - await this.historyManager.saveChat(this.displayMessages, this.messages) + await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) this.currentReply = '' this.currentReasoning = '' this.currentReasoningActive = false - let trimmedMessages = [...this.messages] - if (this.checkTokenUsageOverLimit(trimmedMessages)) { - trimmedMessages = this.deleteOldestMessage(trimmedMessages) + // Compaction trigger. Without a known context window there is no limit + // to enforce, so compaction stays off rather than guessing one. + const contextWindow = model ? getKnownModelContextWindow(model.model) : undefined + if ( + contextWindow !== undefined && + projectedContextTokens >= contextWindow * COMPACTION_TRIGGER_RATIO + ) { + const freed = this.compactOldestMessages( + projectedContextTokens - contextWindow * COMPACTION_TARGET_RATIO + ) + // A report stays meaningful only debited by what was dropped; the + // estimate path needs no bookkeeping — the next read re-estimates + // the compacted history. chars/4 can underestimate the freed + // tokens, which errs toward compacting again — never toward + // overflowing. + if (this.contextUsage !== undefined) { + this.contextUsage = Math.max(0, this.contextUsage - freed) + } + await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) } + // Rollback anchor for restoreUnsentTurn: captured after compaction so it + // indexes into the (possibly compacted) stored history. + const modelLenAfterUser = this.messages.length const params: { messages: ChatCompletionMessageParam[] @@ -1083,7 +1164,7 @@ export class AIChatManager { onMessageEnd: () => void } } = { - messages: trimmedMessages, + messages: [...this.messages], abortController: this.abortController, callbacks: { onNewToken: (token) => (this.currentReply += token), @@ -1159,7 +1240,7 @@ export class AIChatManager { await this.loadApiTools() } - await this.chatRequest({ + const result = await this.chatRequest({ ...params, addedMessages: collectedMessages, onWebSearchUnavailable: () => { @@ -1180,7 +1261,14 @@ export class AIChatManager { if (this.autoAcceptEditsActive) { this.acceptPendingFlowEdits() } - await this.historyManager.saveChat(this.displayMessages, this.messages) + // The report from the last completed iteration still describes the + // stored history it was sent with (the kept partial tail is a small + // undercount the trigger headroom absorbs). Without one, clear the + // stale value — readers estimate via contextTokens. + this.contextUsage = result?.lastIterationUsage + ? result.lastIterationUsage.prompt + result.lastIterationUsage.completion + : undefined + await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) // Still counts as the saved first turn — skipping the hook here would // permanently miss it (the next turn isn't "first" anymore). if (isFirstUserTurn && this.afterFirstTurnSaved) { @@ -1191,6 +1279,8 @@ export class AIChatManager { } else if (wasAborted || !hasUsableOutput) { // Cancelled before anything usable, or the model returned nothing // (or only reasoning) — treat the turn as unsent (matches Claude Code). + // contextUsage is left as-is: the turn is rolled back, so the last + // report (pre-turn, possibly debited by compaction) still stands. this.restoreUnsentTurn(displayLenAfterUser, modelLenAfterUser, sentInstructions, sentPastes) if (this.displayMessages.length === 0) { // saveChat no-ops on an empty transcript; the chat persisted earlier @@ -1198,7 +1288,7 @@ export class AIChatManager { // user message on reload. Remove it instead. this.historyManager.deletePastChat(this.historyManager.getCurrentChatId()) } else { - await this.historyManager.saveChat(this.displayMessages, this.messages) + await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) } if (!wasAborted) { sendUserToast('The model returned no response — your message was restored to the input.') @@ -1206,10 +1296,18 @@ export class AIChatManager { } else { // Clean turn with output → commit as-is. this.messages = [...this.messages, ...collectedMessages] + // The provider's report describes the stored history exactly: + // compaction mutates it before sending, so what was sent IS what is + // stored — no anchoring or index bookkeeping needed. Without a + // report, clear the now-stale value — readers estimate via + // contextTokens. + this.contextUsage = result?.lastIterationUsage + ? result.lastIterationUsage.prompt + result.lastIterationUsage.completion + : undefined if (this.autoAcceptEditsActive) { this.acceptPendingFlowEdits() } - await this.historyManager.saveChat(this.displayMessages, this.messages) + await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) if (isFirstUserTurn && this.afterFirstTurnSaved) { void Promise.resolve(this.afterFirstTurnSaved()).catch((e) => { console.error('AIChatManager afterFirstTurnSaved hook failed', e) @@ -1223,8 +1321,13 @@ export class AIChatManager { // re-committing would duplicate the turn's messages. if (!turnOutcomeHandled) { this.commitInterruptedTurn(collectedMessages, partialReply) + // Any prior report no longer describes the history (a partial turn + // was just committed); clear it so readers estimate instead. When + // the failure WAS a context-length error, that high estimate forces + // compaction on the next send instead of failing the same way again. + this.contextUsage = undefined try { - await this.historyManager.saveChat(this.displayMessages, this.messages) + await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) } catch (saveErr) { console.error('Failed to persist partial chat after error', saveErr) } @@ -1286,6 +1389,12 @@ export class AIChatManager { this.messages = this.messages.slice(0, actualMessageIndex) + // The last report described the pre-rewind history; clear it. Readers + // fall back to estimating the rewound history (contextTokens), so the + // compaction trigger stays armed — e.g. for Retry after a context-length + // error, which rewinds through here. + this.contextUsage = undefined + // Resend the request with the same instructions this.instructions = newContent ?? userMessage.content this.sendRequest({ pastes: pastes ?? userMessage.pastes }) @@ -1319,9 +1428,10 @@ export class AIChatManager { saveAndClear = async () => { this.cancel('saveAndClear') - await this.historyManager.save(this.displayMessages, this.messages) + await this.historyManager.save(this.displayMessages, this.messages, this.contextUsage) this.displayMessages = [] this.messages = [] + this.contextUsage = undefined } loadPastChat = async (id: string) => { @@ -1329,6 +1439,7 @@ export class AIChatManager { if (chat) { this.displayMessages = chat.displayMessages this.messages = chat.actualMessages + this.contextUsage = normalizeContextUsage(chat.contextUsage) this.#automaticScroll = true } } diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 0eee2fdb00..672a9acc6c 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -52,7 +52,6 @@ vi.mock('$lib/aiStore', () => ({ })) vi.mock('../lib', () => ({ - getModelContextWindow: () => 128000, workspaceAIClients: { subscribe: () => () => undefined, getOpenaiClient: mocks.getOpenaiClient, @@ -311,6 +310,264 @@ describe('AIChatManager persisted autonomy default', () => { }) }) +describe('AIChatManager context compaction', () => { + // claude-sonnet-4-6 resolves to a known 1M window (modelConfig is + // unmocked): compaction triggers at a projected 800k and drops head + // messages until ~700k. + const anthropicModel = { provider: 'anthropic', model: 'claude-sonnet-4-6' } + + // The turn-outcome handling rolls back turns with no usable output, so every + // sendRequest here must produce a reply to take the clean-commit path. + const replyWith = ( + reply: string, + lastIterationUsage: { prompt: number; completion: number; total: number } | null = null + ) => + mocks.runChatLoop.mockImplementation(async (config: any) => { + const message = { role: 'assistant' as const, content: reply } + config.addedMessages?.push(message) + return { + addedMessages: [message], + tokenUsage: lastIterationUsage ?? { prompt: 0, completion: 0, total: 0 }, + lastIterationUsage, + hitMaxIterations: false + } + }) + + beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + mocks.getCurrentModel.mockReturnValue(anthropicModel) + mocks.tryGetCurrentModel.mockReturnValue(anthropicModel) + replyWith('done') + }) + + it('compacts the stored history before sending once reported usage projects over the trigger', async () => { + const manager = new AIChatManager() + manager.messages = [ + { role: 'user', content: 'a'.repeat(400_000) }, // ~100k estimated tokens + { role: 'assistant', content: 'b'.repeat(400_000) }, // ~100k + { role: 'user', content: 'c'.repeat(400) }, + { role: 'assistant', content: 'd'.repeat(400) } + ] + // Provider fact: 850k used. Projected past the 800k trigger, so ~150k + // must be freed to come back to the 700k target — the first user + + // assistant pair (~200k estimated). + manager.contextUsage = 850_000 + manager.instructions = 'next question' + const saveChat = vi.spyOn(manager.historyManager, 'saveChat') + + await manager.sendRequest() + + const sent = mocks.runChatLoop.mock.calls[0][0].messages + expect(sent.length).toBe(3) + expect(sent[0]).toMatchObject({ role: 'user', content: 'c'.repeat(400) }) + // The mutation is on the stored history, not a per-send copy: the head + // pair is gone for good and the turn's reply was committed on top + expect(manager.messages.length).toBe(4) + expect(manager.messages[0]).toMatchObject({ role: 'user', content: 'c'.repeat(400) }) + // Mid-turn, the report is debited by the freed estimate (visible in the + // compaction-time save) so a rolled-back turn keeps a consistent value + expect(saveChat).toHaveBeenCalledWith(expect.anything(), expect.anything(), 650_000) + // At commit, the no-report turn clears the stored value; the readable + // number falls back to estimating the now-tiny compacted history + expect(manager.contextUsage).toBeUndefined() + expect(manager.contextTokens).toBeGreaterThan(0) + expect(manager.contextTokens).toBeLessThan(50_000) + // The display message for the sent prompt re-bases onto the compacted history + const userDisplay = manager.displayMessages.find((m) => m.role === 'user') + expect(userDisplay && 'index' in userDisplay ? userDisplay.index : undefined).toBe(2) + }) + + it('updates the reported usage after every send, including compacted ones', async () => { + const manager = new AIChatManager() + manager.messages = [ + { role: 'user', content: 'a'.repeat(400_000) }, + { role: 'assistant', content: 'b'.repeat(400_000) }, + { role: 'user', content: 'c'.repeat(400) } + ] + manager.contextUsage = 850_000 + manager.instructions = 'next question' + replyWith('done', { prompt: 720_000, completion: 1_000, total: 721_000 }) + + await manager.sendRequest() + + // The report describes exactly what was sent (the compacted history), so + // it replaces the debited estimate wholesale. + expect(manager.contextUsage).toBe(721_000) + }) + + it('does not compact while the estimated context stays under the trigger', async () => { + const manager = new AIChatManager() + manager.messages = [ + { role: 'user', content: 'a'.repeat(400_000) }, + { role: 'assistant', content: 'b'.repeat(400_000) } + ] + // no report: the trigger runs off the ~200k estimate, well under 800k + manager.instructions = 'next question' + + await manager.sendRequest() + + expect(mocks.runChatLoop.mock.calls[0][0].messages.length).toBe(3) + }) + + it('compacts off the estimate alone when no report ever arrived', async () => { + const manager = new AIChatManager() + manager.messages = [ + { role: 'user', content: 'a'.repeat(1_600_000) }, // ~400k estimated tokens + { role: 'assistant', content: 'b'.repeat(1_600_000) }, // ~400k + { role: 'user', content: 'c'.repeat(400) }, + { role: 'assistant', content: 'd'.repeat(400) } + ] + // ~800k estimated with no provider report ever seen (e.g. a gateway that + // strips usage): the lazily-estimated projection trips the 800k trigger + // and frees down to ~700k — the first user + assistant pair goes + manager.instructions = 'next question' + + await manager.sendRequest() + + const sent = mocks.runChatLoop.mock.calls[0][0].messages + expect(sent.length).toBe(3) + expect(sent[0]).toMatchObject({ role: 'user', content: 'c'.repeat(400) }) + }) + + it('estimates lazily instead of storing a guess when the provider reports no usage', async () => { + const manager = new AIChatManager() + manager.messages = [ + { role: 'user', content: 'a'.repeat(400_000) }, + { role: 'assistant', content: 'b'.repeat(400_000) } + ] + manager.instructions = 'next question' + + await manager.sendRequest() // replyWith('done') reports no usage + + // the stored value stays a pure provider fact… + expect(manager.contextUsage).toBeUndefined() + // …while the readable number estimates the stored context: ~200k for the + // messages plus the real navigator system prompt, tool defs and the small + // new-turn messages; the prompt templates aren't pinned here, so assert + // the magnitude rather than the byte count + expect(manager.contextTokens).toBeGreaterThan(200_000) + expect(manager.contextTokens).toBeLessThan(250_000) + }) + + it('prefers the provider report over the estimate once one arrives', async () => { + const manager = new AIChatManager() + manager.messages = [{ role: 'user', content: 'a'.repeat(400) }] + manager.instructions = 'first' + await manager.sendRequest() + expect(manager.contextUsage).toBeUndefined() + expect(manager.contextTokens).toBeGreaterThan(0) + + replyWith('done', { prompt: 1_234, completion: 56, total: 1_290 }) + manager.instructions = 'second' + await manager.sendRequest() + expect(manager.contextUsage).toBe(1_290) + expect(manager.contextTokens).toBe(1_290) + }) + + it('does not compact when the model context window is unknown', async () => { + mocks.getCurrentModel.mockReturnValue({ provider: 'custom', model: 'mystery-model-9000' }) + mocks.tryGetCurrentModel.mockReturnValue({ provider: 'custom', model: 'mystery-model-9000' }) + const manager = new AIChatManager() + manager.messages = [ + { role: 'user', content: 'a'.repeat(400_000) }, + { role: 'assistant', content: 'b'.repeat(400_000) } + ] + manager.contextUsage = 10_000_000 + manager.instructions = 'next question' + + await manager.sendRequest() + + expect(mocks.runChatLoop.mock.calls[0][0].messages.length).toBe(3) + }) + + it('never drops the most recent message', () => { + const manager = new AIChatManager() + manager.messages = [{ role: 'user', content: 'a'.repeat(400_000) }] + expect(manager.compactOldestMessages(Number.MAX_SAFE_INTEGER)).toBe(0) + expect(manager.messages.length).toBe(1) + }) + + it('keeps dropping past dangling turns so the history restarts on a user message', () => { + const manager = new AIChatManager() + manager.messages = [ + { + role: 'assistant', + content: 'calling tools', + tool_calls: [ + { id: '1', type: 'function', function: { name: 'x', arguments: '{}' } }, + { id: '2', type: 'function', function: { name: 'y', arguments: '{}' } } + ] + }, + { role: 'tool', content: 'result 1', tool_call_id: '1' }, + { role: 'tool', content: 'result 2', tool_call_id: '2' }, + { role: 'user', content: 'follow-up' }, + { role: 'user', content: 'latest' } + ] + // Freeing 1 token is satisfied by the first drop alone, but the tool + // results would dangle without their assistant tool_calls message + manager.compactOldestMessages(1) + expect(manager.messages.map((m) => m.role)).toEqual(['user', 'user']) + }) + + it('re-bases display message indices and clamps fully-compacted ones to 0', () => { + const manager = new AIChatManager() + manager.messages = [ + { role: 'user', content: 'a'.repeat(400) }, // ~100 estimated tokens + { role: 'assistant', content: 'b'.repeat(400) }, // ~100 + { role: 'user', content: 'c' }, + { role: 'user', content: 'd' } + ] + manager.displayMessages = [ + { role: 'user', content: 'first', index: 0 }, + { role: 'assistant', content: 'answer' }, + { role: 'user', content: 'second', index: 2 }, + { role: 'user', content: 'third', index: 3 } + ] + manager.compactOldestMessages(150) + expect(manager.messages.map((m) => m.content)).toEqual(['c', 'd']) + expect(manager.displayMessages.map((m) => ('index' in m ? m.index : undefined))).toEqual([ + 0, + undefined, + 0, + 1 + ]) + }) + + it('falls back to estimating the rewound history after a rewind', () => { + const manager = new AIChatManager() + manager.messages = [ + { role: 'user', content: 'a'.repeat(400) }, // ~100 estimated tokens + { role: 'assistant', content: 'b'.repeat(400) }, // ~100 + { role: 'user', content: 'q2' }, + { role: 'assistant', content: 'a2' } + ] + manager.displayMessages = [ + { role: 'user', content: 'q1', index: 0 }, + { role: 'assistant', content: 'a1' }, + { role: 'user', content: 'q2', index: 2 }, + { role: 'assistant', content: 'a2' } + ] + // A report that described the pre-rewind history must not survive the + // rewind as-is… + manager.contextUsage = 999_999 + manager.restartGeneration(2) + expect(manager.contextUsage).toBeUndefined() + // …but the readable number stays armed by estimating what remains (the + // two surviving messages, plus the prompt/tools the resend installed), + // so e.g. Retry after a context-length error still compacts + expect(manager.contextTokens).toBeGreaterThanOrEqual(200) + expect(manager.contextTokens).toBeLessThan(50_000) + }) + + it('clears the reported usage when saveAndClear resets the conversation', async () => { + const manager = new AIChatManager() + manager.contextUsage = 1000 + await manager.saveAndClear() + expect(manager.contextUsage).toBeUndefined() + }) +}) + const assistantToolCall = (id: string): ChatCompletionMessageParam => ({ role: 'assistant', content: '', @@ -325,8 +582,9 @@ const toolResult = (id: string): ChatCompletionMessageParam => ({ describe('AIChatManager sendRequest lifecycle', () => { beforeEach(() => { localStorage.clear() - // checkTokenUsageOverLimit reads getCurrentModel().model, so it must be a - // real object (the file-level beforeEach defaults it to undefined). + // The send path reads the current model (request logging + context window + // lookup), so it must be a real object (the file-level beforeEach defaults + // it to undefined). 'test-model' has no known window → compaction stays off. mocks.getCurrentModel.mockReturnValue({ model: 'test-model', provider: 'openai' }) }) @@ -340,6 +598,7 @@ describe('AIChatManager sendRequest lifecycle', () => { vi.mocked(runChatLoop).mockResolvedValue({ addedMessages: [], tokenUsage: {} as any, + lastIterationUsage: null, hitMaxIterations: false }) @@ -366,7 +625,12 @@ describe('AIChatManager sendRequest lifecycle', () => { config.callbacks.onReasoningStart?.() config.callbacks.onReasoningDelta?.('hmm...') config.callbacks.onMessageEnd() - return { addedMessages: [], tokenUsage: {} as any, hitMaxIterations: false } + return { + addedMessages: [], + tokenUsage: {} as any, + lastIterationUsage: null, + hitMaxIterations: false + } }) manager.instructions = 'do a thing' @@ -387,7 +651,12 @@ describe('AIChatManager sendRequest lifecycle', () => { vi.mocked(runChatLoop).mockImplementation(async (config) => { config.callbacks.onNewToken('hello') config.callbacks.onMessageEnd() - return { addedMessages: [], tokenUsage: {} as any, hitMaxIterations: false } + return { + addedMessages: [], + tokenUsage: {} as any, + lastIterationUsage: null, + hitMaxIterations: false + } }) manager.instructions = 'do a thing' @@ -543,6 +812,7 @@ describe('AIChatManager sendRequest lifecycle', () => { return { addedMessages: config.addedMessages!, tokenUsage: {} as any, + lastIterationUsage: null, hitMaxIterations: false } }) @@ -573,6 +843,7 @@ describe('AIChatManager sendRequest lifecycle', () => { vi.mocked(runChatLoop).mockResolvedValue({ addedMessages: [], tokenUsage: {} as any, + lastIterationUsage: null, hitMaxIterations: false }) const deletePastChat = vi.spyOn(manager.historyManager, 'deletePastChat') diff --git a/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte b/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte new file mode 100644 index 0000000000..4fe1c342f0 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte @@ -0,0 +1,45 @@ + + +{#if visible} +
+ + context window usage: ~{formatTokenCount(usedTokens)}{contextWindow + ? ` / ${formatTokenCount(contextWindow)}` + : ''} + +
+{/if} diff --git a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts index a2400b88df..365b85e515 100644 --- a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts @@ -3,6 +3,7 @@ import type { DisplayMessage } from './shared' import { expanded, messageDraft } from './chatDraft' import { createLongHash } from '$lib/editorLangUtils' import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs' +import type { PersistedContextUsage } from './tokenUsage' interface ChatSchema extends IDBSchema { chats: { key: string @@ -13,6 +14,9 @@ interface ChatSchema extends IDBSchema { title: string lastModified: number sessionId?: string + // New writes store the plain reported token count; chats persisted by + // earlier versions may still hold the legacy anchor object. + contextUsage?: PersistedContextUsage } } } @@ -29,6 +33,7 @@ export default class HistoryManager { id: string lastModified: number sessionId?: string + contextUsage?: PersistedContextUsage } > = $state({}) @@ -105,7 +110,11 @@ export default class HistoryManager { return Object.values(this.savedChats) } - async saveChat(displayMessages: DisplayMessage[], messages: ChatCompletionMessageParam[]) { + async saveChat( + displayMessages: DisplayMessage[], + messages: ChatCompletionMessageParam[], + contextUsage?: number + ) { if (displayMessages.length > 0) { // Expand any collapsed-paste tokens so the title is readable text, not // the chip label + its zero-width id chars. @@ -120,7 +129,8 @@ export default class HistoryManager { title, id: this.currentChatId, lastModified: Date.now(), - ...(this.sessionId ? { sessionId: this.sessionId } : {}) + ...(this.sessionId ? { sessionId: this.sessionId } : {}), + ...(contextUsage !== undefined ? { contextUsage } : {}) } this.savedChats = { ...this.savedChats, @@ -133,8 +143,12 @@ export default class HistoryManager { } } - async save(displayMessages: DisplayMessage[], messages: ChatCompletionMessageParam[]) { - await this.saveChat(displayMessages, messages) + async save( + displayMessages: DisplayMessage[], + messages: ChatCompletionMessageParam[], + contextUsage?: number + ) { + await this.saveChat(displayMessages, messages, contextUsage) this.currentChatId = createLongHash() } diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.test.ts b/frontend/src/lib/components/copilot/chat/chatLoop.test.ts index 9e19a52cb4..9c1863ae54 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.test.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.test.ts @@ -12,7 +12,7 @@ const mocks = vi.hoisted(() => ({ parseOpenAIResponsesCompletion: vi.fn(), getAnthropicCompletion: vi.fn(), parseAnthropicCompletion: vi.fn(), - resolveEffectiveReasoning: vi.fn() + resolveRequestReasoning: vi.fn() })) vi.mock('../lib', () => ({ @@ -22,7 +22,7 @@ vi.mock('../lib', () => ({ })) vi.mock('../reasoningRegistry', () => ({ - resolveEffectiveReasoning: mocks.resolveEffectiveReasoning + resolveRequestReasoning: mocks.resolveRequestReasoning })) vi.mock('./openai-responses', () => ({ @@ -83,7 +83,7 @@ describe('runChatLoop web search fallback', () => { mocks.providerSupportsWebSearch.mockImplementation( (provider) => provider === 'openai' || provider === 'anthropic' ) - mocks.resolveEffectiveReasoning.mockReturnValue(undefined) + mocks.resolveRequestReasoning.mockReturnValue(undefined) mocks.parseOpenAICompletion.mockResolvedValue({ shouldContinue: false, tokenUsage @@ -255,9 +255,7 @@ describe('runChatLoop web search fallback', () => { ) .mockResolvedValue({}) - await runChatLoop( - createConfig({ workspace, callbacks, modelProvider, onWebSearchUnavailable }) - ) + await runChatLoop(createConfig({ workspace, callbacks, modelProvider, onWebSearchUnavailable })) expect(mocks.getAnthropicCompletion).toHaveBeenCalledTimes(2) expect(mocks.getAnthropicCompletion.mock.calls[0][3]).toEqual( @@ -293,6 +291,46 @@ describe('runChatLoop web search fallback', () => { }) }) +describe('runChatLoop lastIterationUsage', () => { + beforeEach(() => { + vi.resetAllMocks() + mocks.resolveRequestReasoning.mockReturnValue(undefined) + }) + + it('keeps the usage of the last completion that reported it', async () => { + const workspace = `workspace-${randomUUID()}` + mocks.getOpenAIResponsesCompletion.mockResolvedValue({}) + mocks.parseOpenAIResponsesCompletion + .mockResolvedValueOnce({ + shouldContinue: true, + tokenUsage: { prompt: 1000, completion: 50, total: 1050 } + }) + .mockResolvedValueOnce({ + shouldContinue: false, + tokenUsage: { prompt: 1200, completion: 80, total: 1280 } + }) + + const result = await runChatLoop({ ...createConfig({ workspace }), maxIterations: 2 }) + + expect(result.lastIterationUsage).toEqual({ prompt: 1200, completion: 80, total: 1280 }) + // the aggregate keeps summing across iterations + expect(result.tokenUsage).toEqual({ prompt: 2200, completion: 130, total: 2330 }) + }) + + it('ignores empty usage reports and returns null when none are real', async () => { + const workspace = `workspace-${randomUUID()}` + mocks.getOpenAIResponsesCompletion.mockResolvedValue({}) + mocks.parseOpenAIResponsesCompletion.mockResolvedValue({ + shouldContinue: false, + tokenUsage: { prompt: 0, completion: 0, total: 0 } + }) + + const result = await runChatLoop(createConfig({ workspace })) + + expect(result.lastIterationUsage).toBeNull() + }) +}) + // Builders for the message shapes the chat loop accumulates. const assistant = (content: string): ChatCompletionMessageParam => ({ role: 'assistant', content }) const assistantTools = (...ids: string[]): ChatCompletionMessageParam => ({ diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.ts b/frontend/src/lib/components/copilot/chat/chatLoop.ts index b00f145bae..0e5e4923d7 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.ts @@ -59,7 +59,9 @@ export interface ChatLoopConfig { export interface ChatLoopResult { addedMessages: ChatCompletionMessageParam[] + /** Sum of usage across all loop iterations (suitable for cost accounting). */ tokenUsage: ChatTokenUsage + lastIterationUsage: ChatTokenUsage | null hitMaxIterations: boolean } @@ -215,9 +217,18 @@ export async function runChatLoop(config: ChatLoopConfig): Promise { + tokenUsage = addChatTokenUsage(tokenUsage, usage) + // Some providers/paths report no usage (prompt 0); keep the last real one. + if (usage && usage.prompt > 0) { + lastIterationUsage = usage + } + } + while (true) { if (maxIterations !== undefined && iterations >= maxIterations) { hitMaxIterations = true @@ -283,7 +294,7 @@ export async function runChatLoop(config: ChatLoopConfig): Promise { }) }) }) + +describe('model context windows', () => { + it('maps Sonnet/Opus 4.6+ Claude models to the 1M window', () => { + expect(getKnownModelContextWindow('claude-sonnet-4-6')).toBe(1000000) + expect(getKnownModelContextWindow('claude-opus-4-6')).toBe(1000000) + expect(getKnownModelContextWindow('claude-opus-4-8')).toBe(1000000) + expect(getKnownModelContextWindow('anthropic.claude-sonnet-4-6-v1:0')).toBe(1000000) + }) + + it('keeps Haiku and older Claude models at 200K', () => { + expect(getKnownModelContextWindow('claude-haiku-4-5')).toBe(200000) + expect(getKnownModelContextWindow('global.anthropic.claude-haiku-4-5-20251001-v1:0')).toBe( + 200000 + ) + expect(getKnownModelContextWindow('claude-3-5-sonnet-latest')).toBe(200000) + expect(getKnownModelContextWindow('claude-sonnet-4-5-20250929')).toBe(200000) + expect(getKnownModelContextWindow('claude-opus-4-1')).toBe(200000) + // date-suffixed base ids without a minor version: the date must not be + // captured as the version + expect(getKnownModelContextWindow('claude-sonnet-4-20250514')).toBe(200000) + expect(getKnownModelContextWindow('anthropic.claude-sonnet-4-20250514-v1:0')).toBe(200000) + }) + + it('keeps base GPT-5 models at 400K while GPT-5.4+ get the 1M window', () => { + expect(getKnownModelContextWindow('gpt-5')).toBe(400000) + expect(getKnownModelContextWindow('gpt-5-mini')).toBe(400000) + expect(getKnownModelContextWindow('gpt-5.2')).toBe(400000) + expect(getKnownModelContextWindow('gpt-5.4')).toBe(1000000) + expect(getKnownModelContextWindow('gpt-5.5')).toBe(1000000) + }) + + it('maps recent Gemini and DeepSeek models to the 1M window', () => { + expect(getKnownModelContextWindow('gemini-3.1-pro')).toBe(1000000) + expect(getKnownModelContextWindow('gemini-3-flash')).toBe(1000000) + expect(getKnownModelContextWindow('gemini-2.5-flash')).toBe(1000000) + expect(getKnownModelContextWindow('deepseek-v4-pro')).toBe(1000000) + expect(getKnownModelContextWindow('deepseek-chat')).toBe(1000000) + expect(getKnownModelContextWindow('deepseek-reasoner')).toBe(1000000) + }) + + it('returns undefined for unrecognized models, 128K via the defaulting wrapper', () => { + expect(getKnownModelContextWindow('some-custom-model')).toBeUndefined() + expect(getModelContextWindow('some-custom-model')).toBe(128000) + }) +}) diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index 5ec3f748b2..ffa4e5db24 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -287,21 +287,6 @@ export function getModelMaxTokens(provider: AIProvider, model: string) { return 8192 } -export function getModelContextWindow(model: string) { - if (model.includes('gpt-4.1') || model.includes('gemini')) { - return 1000000 - } else if (model.includes('gpt-5')) { - return 400000 - } else if (model.includes('gpt-4o') || model.includes('llama-3.3')) { - return 128000 - } else if (model.includes('claude') || model.includes('o4-mini') || model.includes('o3')) { - return 200000 - } else if (model.includes('codestral')) { - return 32000 - } else { - return 128000 - } -} function getModelSpecificConfig( modelProvider: AIProviderModel, diff --git a/frontend/src/lib/components/copilot/modelConfig.ts b/frontend/src/lib/components/copilot/modelConfig.ts index 726ff4e387..4ab08ce02c 100644 --- a/frontend/src/lib/components/copilot/modelConfig.ts +++ b/frontend/src/lib/components/copilot/modelConfig.ts @@ -9,3 +9,52 @@ export function requiresMaxCompletionTokens(model: string) { const baseModel = normalizedModel.split('/').pop() ?? normalizedModel return baseModel.startsWith('gpt-5') || /^o\d/.test(baseModel) } + +// Context windows of the models we know, most specific entry first — the first +// name included in the model id wins, so provider-prefixed and date-suffixed +// ids (anthropic.claude-sonnet-4-6-...-v1:0, gpt-5.2-2026-01-01) still resolve. +// Conservative family fallbacks sit below the explicit entries; models not +// listed at all resolve to undefined, which disables auto-trimming and the +// indicator denominator. +const MODEL_CONTEXT_WINDOWS: [name: string, contextWindow: number][] = [ + // Anthropic — Sonnet/Opus 4.6+ ship a 1M window at standard pricing (GA); + // Haiku, older Claude models (3.x, 4.0, 4.1, 4.5) and date-suffixed Claude 4 + // base ids (claude-sonnet-4-20250514) fall through to 200K + ['claude-fable-5', 1_000_000], + ['claude-opus-4-8', 1_000_000], + ['claude-opus-4-7', 1_000_000], + ['claude-opus-4-6', 1_000_000], + ['claude-sonnet-4-6', 1_000_000], + ['claude', 200_000], + // OpenAI — gpt-5 covers the base family (-mini / -nano) and the 5.1/5.2 + // revisions, all 400K; only 5.4+ moved to 1M + ['gpt-5.5', 1_000_000], + ['gpt-5.4', 1_000_000], + ['gpt-5', 400_000], + ['gpt-4.1', 1_000_000], + ['gpt-4o', 128_000], + ['o4-mini', 200_000], + ['o3', 200_000], + // Google — the 2.5 / 3 / 3.1 Gemini families are all 1M + ['gemini-3.1', 1_000_000], + ['gemini-3', 1_000_000], + ['gemini-2.5', 1_000_000], + // DeepSeek — the V4 family is 1M; deepseek-chat / deepseek-reasoner are + // aliases of V4-Flash since April 2026 + ['deepseek-v4', 1_000_000], + ['deepseek-chat', 1_000_000], + ['deepseek-reasoner', 1_000_000], + ['deepseek', 128_000], + // Others + ['llama', 128_000], + ['codestral', 32_000] +] + +export function getKnownModelContextWindow(model: string): number | undefined { + return MODEL_CONTEXT_WINDOWS.find(([name]) => model.includes(name))?.[1] +} + +export function getModelContextWindow(model: string) { + // Trim/compaction logic needs a number; assume a conservative window when unknown. + return getKnownModelContextWindow(model) ?? 128000 +} From 598ce40f561966fdd97f35bfbba0f3d0caa571fc Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 15 Jun 2026 08:47:36 +0200 Subject: [PATCH 023/246] chore(main): release 1.724.0 (#9556) * chore(main): release 1.724.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 23 ++ backend/Cargo.lock | 278 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +-- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/core/constants.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 207 insertions(+), 174 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5145bcd26..139604fb8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [1.724.0](https://github.com/windmill-labs/windmill/compare/v1.723.0...v1.724.0) (2026-06-15) + + +### Features + +* **cli:** add --yes, --secret/--no-secret and --description to variable add ([#9548](https://github.com/windmill-labs/windmill/issues/9548)) ([4e9e0c0](https://github.com/windmill-labs/windmill/commit/4e9e0c024b4b95f9676b1646591d8f0c662e84ab)) +* **frontend:** improve AI chat cancel and interrupted-turn handling ([#9539](https://github.com/windmill-labs/windmill/issues/9539)) ([114c412](https://github.com/windmill-labs/windmill/commit/114c41251a8c738b58a1a3dd9434d09d33feb6f1)) +* **frontend:** precise AI chat context usage tracking + indicator ([#9551](https://github.com/windmill-labs/windmill/issues/9551)) ([2b47180](https://github.com/windmill-labs/windmill/commit/2b471805bf1c92bb210cfacda217a4341e1f989c)) +* wire chat reasoning effort through gemini and bedrock proxies ([#9545](https://github.com/windmill-labs/windmill/issues/9545)) ([aaf0563](https://github.com/windmill-labs/windmill/commit/aaf05635cedadc73455dc522474b673719f9fd5c)) + + +### Bug Fixes + +* actually isolate windows job children from CTRL_BREAK_EVENT + reap on worker death ([#9563](https://github.com/windmill-labs/windmill/issues/9563)) ([61f3291](https://github.com/windmill-labs/windmill/commit/61f3291b240bdb5c26bee8947351a9590bc3bd45)) +* **ai:** enforce resource authz when loading MCP tools in agent worker ([#9571](https://github.com/windmill-labs/windmill/issues/9571)) ([317a862](https://github.com/windmill-labs/windmill/commit/317a8629d1c8436d2a6f3443bd25b81d606ce283)) +* append system CA bundle to tracing proxy cert file ([#9549](https://github.com/windmill-labs/windmill/issues/9549)) ([3cf4083](https://github.com/windmill-labs/windmill/commit/3cf40839602e5c3d1df51f0a29b01736bade09da)) +* **cli:** consistent flow inline lock filenames for compound extensions ([#9555](https://github.com/windmill-labs/windmill/issues/9555)) ([f0659a7](https://github.com/windmill-labs/windmill/commit/f0659a755a161420833e3bfdbe04befc6ebeb977)) +* **flows:** skip_if evaluates wrong previous_result during retry ([#9547](https://github.com/windmill-labs/windmill/issues/9547)) ([2aab352](https://github.com/windmill-labs/windmill/commit/2aab35245c362c2f911c60ea435f29bfb1369ebf)) +* **folders:** allow hyphens in folder names ([#9566](https://github.com/windmill-labs/windmill/issues/9566)) ([84df111](https://github.com/windmill-labs/windmill/commit/84df11177f2009bff007e9b722b55a9a5a63c06a)), closes [#8474](https://github.com/windmill-labs/windmill/issues/8474) +* **frontend:** load resource value in JSON editor when resource type is missing ([#9574](https://github.com/windmill-labs/windmill/issues/9574)) ([251266c](https://github.com/windmill-labs/windmill/commit/251266cd8119dbef314314daed43aa43ab92f1c9)) +* isolate windows job children from worker CTRL_BREAK_EVENT ([#9562](https://github.com/windmill-labs/windmill/issues/9562)) ([1d6191e](https://github.com/windmill-labs/windmill/commit/1d6191ebb75843917eec6c76a4be347f9ac4cb72)) +* stop sending temperature for AI chat across all providers ([#9553](https://github.com/windmill-labs/windmill/issues/9553)) ([3585716](https://github.com/windmill-labs/windmill/commit/358571687296fbe5c378533b3c1662707955c64a)) + ## [1.723.0](https://github.com/windmill-labs/windmill/compare/v1.722.0...v1.723.0) (2026-06-11) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 0837c09b54..11f00f7ad1 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -106,9 +106,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -1786,13 +1786,13 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.3" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", - "brotli-decompressor 5.0.1", + "brotli-decompressor 5.0.3", ] [[package]] @@ -1807,9 +1807,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.1" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -2056,9 +2056,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.2.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", "jobserver", @@ -3930,7 +3930,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -6961,6 +6960,29 @@ dependencies = [ "malachite-nz", ] +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + [[package]] name = "mappable-rc" version = "0.1.1" @@ -7032,9 +7054,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memmap2" @@ -7203,15 +7225,15 @@ checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" [[package]] name = "mysql-common-derive" -version = "0.32.1" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66f62cad7623a9cb6f8f64037f0c4f69c8db8e82914334a83c9788201c2c1bfa" +checksum = "a4db8a44120571277accfaa3f3d91e7d3989d601d817c2fc01a9391b86135666" dependencies = [ - "darling 0.20.11", + "darling 0.23.0", "heck", + "manyhow", "num-bigint", "proc-macro-crate", - "proc-macro-error2", "proc-macro2", "quote", "syn 2.0.117", @@ -7855,9 +7877,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.80" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ "bitflags 2.13.0", "cfg-if", @@ -7901,9 +7923,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.116" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -8255,7 +8277,7 @@ dependencies = [ "arrow-schema", "arrow-select", "base64 0.22.1", - "brotli 8.0.3", + "brotli 8.0.4", "bytes", "chrono", "flate2", @@ -8734,28 +8756,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "proc-macro-rules" version = "0.4.0" @@ -8779,6 +8779,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -9816,9 +9827,9 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.42.0" +version = "1.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" dependencies = [ "arrayvec", "borsh", @@ -12192,12 +12203,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -12207,15 +12217,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" dependencies = [ "num-conv", "time-core", @@ -13446,9 +13456,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen 0.57.1", ] @@ -13782,7 +13792,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "async-nats", @@ -13864,7 +13874,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.723.0" +version = "1.724.0" dependencies = [ "async-stream", "async-trait", @@ -13897,7 +13907,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.723.0" +version = "1.724.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13910,7 +13920,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "argon2", @@ -14048,7 +14058,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.723.0" +version = "1.724.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14071,7 +14081,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.723.0" +version = "1.724.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14084,7 +14094,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14110,7 +14120,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.723.0" +version = "1.724.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14120,7 +14130,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.723.0" +version = "1.724.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14137,7 +14147,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.723.0" +version = "1.724.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14159,7 +14169,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14182,7 +14192,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.723.0" +version = "1.724.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14198,7 +14208,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.723.0" +version = "1.724.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14219,7 +14229,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.723.0" +version = "1.724.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14240,7 +14250,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.723.0" +version = "1.724.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14254,7 +14264,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "async-nats", @@ -14289,7 +14299,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14314,7 +14324,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.723.0" +version = "1.724.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14332,7 +14342,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14354,7 +14364,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.723.0" +version = "1.724.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14374,7 +14384,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.723.0" +version = "1.724.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14405,7 +14415,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14433,7 +14443,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.723.0" +version = "1.724.0" dependencies = [ "lazy_static", "serde", @@ -14445,7 +14455,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.723.0" +version = "1.724.0" dependencies = [ "argon2", "axum 0.8.9", @@ -14470,7 +14480,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.723.0" +version = "1.724.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14484,7 +14494,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.723.0" +version = "1.724.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14517,7 +14527,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.723.0" +version = "1.724.0" dependencies = [ "chrono", "lazy_static", @@ -14531,7 +14541,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14550,7 +14560,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.723.0" +version = "1.724.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14651,7 +14661,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.723.0" +version = "1.724.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14670,7 +14680,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.723.0" +version = "1.724.0" dependencies = [ "regex", "serde", @@ -14685,7 +14695,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14709,7 +14719,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "futures", @@ -14726,7 +14736,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.723.0" +version = "1.724.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14742,7 +14752,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "async-trait", @@ -14763,7 +14773,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "async-trait", @@ -14794,7 +14804,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "arc-swap", @@ -14819,7 +14829,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "async-stream", @@ -14853,7 +14863,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "futures", @@ -14871,7 +14881,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.723.0" +version = "1.724.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14880,7 +14890,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "lazy_static", @@ -14892,7 +14902,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "serde_json", @@ -14904,7 +14914,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "gosyn", @@ -14916,7 +14926,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "lazy_static", @@ -14928,7 +14938,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "serde_json", @@ -14940,7 +14950,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "nu-parser", @@ -14951,7 +14961,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14962,7 +14972,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14974,7 +14984,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "rustpython-ast", @@ -14985,7 +14995,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "async-recursion", @@ -15007,7 +15017,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "serde_json", @@ -15019,7 +15029,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "lazy_static", @@ -15033,7 +15043,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15050,7 +15060,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "lazy_static", @@ -15063,7 +15073,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "serde", @@ -15075,7 +15085,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "lazy_static", @@ -15093,7 +15103,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15109,7 +15119,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15125,7 +15135,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "serde", @@ -15136,7 +15146,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "async-recursion", @@ -15174,7 +15184,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "const_format", @@ -15212,7 +15222,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.723.0" +version = "1.724.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15223,7 +15233,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "async-recursion", @@ -15255,7 +15265,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "async-trait", @@ -15279,7 +15289,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "async-trait", @@ -15312,7 +15322,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "async-trait", @@ -15345,7 +15355,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "async-trait", @@ -15365,7 +15375,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "async-trait", @@ -15399,7 +15409,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "async-trait", @@ -15435,7 +15445,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "async-trait", @@ -15458,7 +15468,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "async-trait", @@ -15482,7 +15492,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "async-nats", @@ -15506,7 +15516,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "async-trait", @@ -15541,7 +15551,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "async-trait", @@ -15569,7 +15579,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "async-trait", @@ -15594,7 +15604,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -15613,7 +15623,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "async-once-cell", @@ -15723,7 +15733,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.723.0" +version = "1.724.0" dependencies = [ "bytes", "futures", @@ -16578,9 +16588,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index f517c7668a..cb663af953 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.723.0" +version = "1.724.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.723.0" +version = "1.724.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 5717e59c3d..14c5c96765 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.723.0" +version = "1.724.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.723.0" +version = "1.724.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.723.0" +version = "1.724.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.723.0" +version = "1.724.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 3c9bf1ed61..d4765430ec 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.723.0" +version = "1.724.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index a4775a6878..535f5982d3 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.723.0 + version: 1.724.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 66f8277026..e49c305570 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.723.0"; +export const VERSION = "v1.724.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 12ba23d6a5..5204d0ddf0 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.723.0"; +export const VERSION = "1.724.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e19800cd3f..26ee1468d9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.723.0", + "version": "1.724.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.723.0", + "version": "1.724.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 2ea4054166..5128b9bb98 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.723.0", + "version": "1.724.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/lsp/Pipfile b/lsp/Pipfile index c94b37bb9a..886a888233 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.723.0" +wmill = ">=1.724.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index ada2724ff0..ea51409694 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.723.0 + version: 1.724.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index df571540d8..5ce3b828b9 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.723.0' + ModuleVersion = '1.724.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index e77a35d27a..565fb8ee96 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.723.0" +version = "1.724.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index b5d34ee10a..5903f9afe6 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.723.0", + "version": "1.724.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 4f62b3cf43..62493356eb 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.723.0", + "version": "1.724.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index b193deeea5..4fa80efa92 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.723.0 +1.724.0 From 1fc355709c025fd256c5a4035356e15a5a05b23d Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:23:16 +0200 Subject: [PATCH 024/246] feat: Db-backed user drafts (#9351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Db draft removal * refactor: drop unsaved-changes confirmation modal from editors * fix: remove nodraft from flow row edit link * fix: remove nodraft from app and raw app edit buttons * fix: remove nodraft from all edit links * fix: merge backend defaults into legacy autosaves to avoid spurious restore toast on raw apps * feat: add username column to draft table for user-scoped drafts * feat: add sync_drafts and list_users_with_draft_on_path endpoints * feat: add UserDraftDbSyncer service for bi-directional draft sync * feat: wire UserDraft.save through DbSyncer + conflict modal * refactor: gate useLocalStorageValue nested-update effect behind opt-in flag * refactor: move sync force flag from request-level to per-entry * feat: sync all userdraft kinds, switch draft owner to email FK, add id PK, scope draft list to readable paths * refactor: route draft permission check through authed.folders + RLS, drop client-supplied email * feat: support draft deletion via sync (value: null) with same conflict semantics * feat: surface other users' drafts in editors with diff+fork action * refactor: unify draft schema migrations and type kinds via DRAFT_KIND enum * perf: add (workspace_id, email, created_at) partial index for sync hot path * chore: update ee-repo-ref to a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd This commit updates the EE repository reference after PR #597 was merged in windmill-ee-private. Previous ee-repo-ref: 55c19293232be379a3044eb78f677b545882ffd6 New ee-repo-ref: a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd Automated by sync-ee-ref workflow. * fix(userdraft): trigger sync on deep mutations via readFieldsRecursively * Rollback UserDraft * remove queuing logic * pushDrafts * refactor: remove draft sync layer and conflict modal * feat: add save_draft, list_drafts, get_draft routes * feat: add get_draft overlay to getScriptByPath * feat: extend get_draft overlay to flow, app, resource, variable, schedule, triggers * feat: support null value in save_draft for deletes * readLastSyncMap * feat: redirect /add pages to /edit/draft_uuid with new_draft flag * fix: inline get_draft query field instead of flattening * fix: drop dangling nobackenddraft assignment in flows edit * feat: include user drafts in list endpoints with is_draft flag * fix: prefix draft paths with u/{user} and seed editor state on new_draft * fix: route draft-only deletes through UserDraftDbSyncer on home page * feat: delete user drafts when their underlying item is deleted * fix: empty path seed on new_draft so friendly auto-name fires * feat: re-add Draft and Draft only badges on home page rows * fix: synthesize value wrapper on draft-only raw_app response * fix: tolerate missing latest-version on draft-only flow reload * fix: skip first observable change in DB sync effect to match LS persist * fix: remove URL-hash sync from script editor (already marked TEMP) * refactor: drop localStorage layer from UserDraft * refactor: drop vestigial LS-era code from UserDraft * feat: migrate localStorage drafts to DB on layout mount * fix: migrate session runtime + script view to per-user draft API * feat: add 'Reset to deployed' action on draft-loaded toast * feat: hide 'Reset to deployed' action when no deployed version exists * createCoalescingKeyedRunner * example ts doc * createDebouncerByKey * refactor: drop await on draft-delete in reset flows, refetch deployed directly * fix: bridge saved-draft shape to wire shape in apps/resources/variables loaders * feat: route UserDraftDbSyncer.save through debouncer + coalescing runner * feat: add immediate-save bypass that cancels pending debouncer + runner tasks * fix: seed UserDraft cell from spec defaultValue on acquire * fix: redirect /add routes at load phase to eliminate white flash * fix: drop +page.js files in /add routes that conflicted with +page.ts * refactor: send draft as separate .draft field instead of deep-merging onto deployed * feat: surface draft path in home list when user typed one different from URL * feat: add UserDraft.stopSync/restartSync, wire on script + low-code app /add init * fix: thread URL path into ScriptBuilder.stopSync (was using empty initialPath) * fix: also stopSync in route's new_draft branch + queue pre-acquire suspensions * feat: add AutosaveIndicator backed by reactive UserDraftDbSyncer.getState * refactor: drop draft-loaded toast in non-route editors, banner now compares draft vs deployed * fix: gate per-user draft-only rows in listings on include_draft_only flag * feat: flush pending draft saves via keepalive fetch on tab hide / pagehide * autosave indicator nits * fix: route create-vs-update on /add deploys; seed policy.execution_mode; sync script template * chore: add [draft-sync] console logs to trace script bootstrap autosave * fix: seed auto-generated path in script new-draft route to suppress Path widget's autosave-triggering mutation * fix: defer script restartSync until script.path lands (Path widget gated on $userStore + $workspaceStore) * fix: poll script.path via tick() until Path widget settles before restartSync * chore: log inferArgs underlying error on deploy to diagnose 'Could not parse code' toast * fix: wait for script.path to stabilize across two ticks before restartSync * revert: drop unsuccessful path-stabilization heuristics + leftover [draft-sync] logs * fix: seed new-draft script schema as emptySchema() so inferArgs doesn't trip on undefined properties * fix: heal legacy drafts with schema={} (no .properties) on deploy * autosave indicator * refactor(editors): drop UnsavedConfirmationModal mount + Show diff button * feat(drafts): collaboration banner, cross-tab conflict detection, raw app template picker - Other-users-drafts banner (Modal2): the deployed-overlay response now carries `other_drafts_users` (workspace usernames only, never emails); each row offers View JSON + Fork. Drops the standalone `listUsersWithDraftOnPath` endpoint; `getDraftForUser` now takes a workspace `username` query param (resolved to email server-side). - Cross-tab/browser save conflict detection: the syncer attaches `last_sync` to every save (defaults to non-force); on a `conflict` response it parks a snapshot in a reactive map. Each route mounts a `DraftSyncConflictModal` and seeds the per-tab `last_sync` via `recordRemoteSync(query, draft_saved_at)` on every `get_draft` load. Keepalive flush also respects optimistic concurrency. - Raw app template picker re-added after the /add ⇒ /edit refactor: framework (React 19 / 18 / Svelte 5), data table + schema config, and optional AI prompt — extracted into `RawAppTemplatePicker.svelte` and driven by `new_draft=true` on the edit route. * fix(drafts): suppress autosave during /add template seeding on script + raw app editors - ScriptBuilder: delay `restartSync` 500ms past `initContent` + stores- ready so the Path widget's `$workspaceStore && $userStore`-gated `initPath → reset → onMetaChange → bind:path` cascade lands inside the suspension window. Two `tick()` waits weren't enough — the bind:path mutation fired ~100ms after the prior `restartSync` and posted as a "user edit". - apps_raw route: suspend autosave on `new_draft=true` and resume only after the framework picker closes (via `onStart` or X dismissal), with a two-tick settle so the picker's seeded `files/runnables/data/policy` mirror to `draftHandle.draft` observably advances `lastSerialized` before sync re-arms. * fix(drafts): land /add redirects on the real workspace username, not "me" The `/add` → `/edit/u/{username}/draft_{uuid}` redirects ran during SvelteKit's load phase, BEFORE the (logged) layout's async `getUserExt` populated `userStore`. `get(userStore)?.username` returned undefined and fell back to the `'me'` placeholder on every fresh nav, producing `u/me/draft_{uuid}` paths instead of the user's real namespace — broke ownership checks against `authed.username` and silently scoped autosaves under the wrong path. Layout now persists `username` to localStorage on every successful `getUserExt`, and `getUsernameForNamespace` (new shared helper, used by all four `/add/+page.ts` files) reads the live store first, falls back to the cached value, and only then to `'me'` for true first-ever loads. * fix(drafts): key low-code app autosave on the URL path, not the empty string `AppEditor` keyed its `UserDraft.use` handle on `newApp ? '' : path` — a legacy leftover from when `/apps/add` was its own URL (no path). With the `/add` ⇒ `/edit/u/{user}/draft_{uuid}` redirect, `newApp=true` made autosaves land on the `('app', '')` row instead of the URL path: - The `apps/list?include_draft_only=true` query joins drafts onto `app.path`, surfacing drafts at the URL path. The empty-path row didn't match the user's URL so the draft never appeared in the home list. - Refreshing `/apps/edit/u/{user}/draft_{uuid}` re-fetches at the URL path with `?get_draft=true`, finds nothing, and 404s. Drop the ternary so the handle always uses `path` — the same as scripts/flows/raw_apps. The route's `?new_draft=true` branch already seeds the empty-template baseline, so there's no longer a "the draft sits under '' until first save" race to worry about. * fix(raw_app): propagate template picker X / Esc dismissal so autosave resumes The picker mounted `` (one-way prop, not `bind:open`). When the user dismissed via X / Esc / click-outside, the inner Modal flipped its own local `open` to false (hiding the UI) but never wrote back to the picker's `open` $bindable. The route's `templatePicker → false` watcher — the one that calls `restartSync` two ticks after the picker closes — never fired, so autosave stayed suspended and the user's edits after dismissal were silently dropped. Switch the inner Modal to `bind:open` so the dismissal bubbles all the way up to the route's state. "Start without AI" already worked because its `onStart` handler explicitly sets the picker's `open = false`. * nit unused * fix(drafts): make the home-page View/Edit JSON action work on draft-only apps The "View/Edit JSON" entry on the home page called `AppService.getAppByPath` without `get_draft=true`, so for draft-only items at `u/{user}/draft_{uuid}` the backend 404'd with "App not found at path …". Pass `get_draft=true` and render the synthesized stand-in's editable shape: - App drafts come back as `{summary, value, path, policy, ...}` — `value` is the App definition the editor was working on; show that. - Raw-app drafts come back as the flattened `{files, runnables, data, summary, policy, ...}` with no nested `value`; show the whole shape. On save, draft-only items can't go through `updateApp` (no deployed row). Route the edit through `UserDraftDbSyncer.save` (with `immediate: true` so `await` resolves after the POST lands) and relabel the button "Save draft" + Save icon. Deployed items keep the existing "Deploy" flow unchanged. * fix(drafts): render the right shape in View/Edit JSON for draft-only items The previous fix landed `fapp.value` into the editor, but the deployed-overlay flattens the bare editable shape into `inner`/the top-level response — drafts have no nested `.value`. So: - App drafts (`{grid, breakpoints, hiddenInlineScripts, …}`) rendered as empty (`fapp.value` was undefined). - Raw-app drafts 404'd outright: `get_draft=true` with no `rawApp` flag can't tell which draft kind to look up, defaults to `app`, doesn't find one. Thread the row's `raw_app` flag from AppRow → `appExport.open(path, rawApp)` → `getAppByPath({..., rawApp})` so raw-app drafts resolve to the right `UserDraftItemKind`. Read `fapp.draft` (the bare editable shape from `fetch_draft_only`) into the JSON editor for draft-only items — clean payload, no `is_draft` / `no_deployed` / overlay noise. Save the same bare shape back through the syncer so the regular editor reads it unchanged on the next mount. * fix(drafts): skip public-secret-URL fetch in the Deploy drawer for draft-only apps Opening the Deploy drawer on a `/edit/u/{user}/draft_{uuid}` app fired `AppService.getPublicSecretOfApp` immediately because the gating effect only checked `appPath != ''` + `savedApp`. The `/secret_of/{path}` route plain-SELECTs `app.id`, so a draft-only path 404'd with "App not found at name …" and the public-URL ClipboardPanel spun forever waiting on `secretUrl`. Thread the existing `newApp` signal (already on `AppEditorHeader` / `RawAppEditorHeader`) into `AppEditorHeaderDeploy`, gate the fetch behind `!newApp`, and render the existing "Deploy this app once to get the public secret URL" placeholder instead of the spinner for draft-only items. * fix(drafts): disable Diff button on draft-only items across the 4 editors Diff has no baseline to compare against on draft-only items — the button used to be gated by the pre-PR `/add` route's own state, but the `/add → /edit` redirect landed everything under the regular `/edit` page where the gate was missing. - ScriptBuilder: gate the topbar Diff on `savedScript.no_deployed`; seed `no_deployed: true` on the route's `new_draft` empty NewScript so the gate fires before the first deploy. - FlowBuilder: gate the topbar Diff on `newFlow` (route already sets it from `backendFlow.no_deployed` and the new-draft branch). - AppEditorHeader: gate both the "Diff" dropdown action and the Deploy-drawer's "Diff" button on `newApp`. - RawAppEditorHeader: gate the topbar Diff + the Deploy-drawer's "Diff" button on `newApp`. Each gate also rewrites the tooltip ("Deploy this … once to compare against the deployed version") so the hover state explains why. * fix(drafts): disable the "No login required" toggle on draft-only apps Flipping the toggle called `setPublishState`, which POSTs the new `policy` through `AppService.updateApp` — that handler's `UPDATE app ... RETURNING path` finds nothing on a draft-only path and `not_found_if_none` 404s with "App not found at name …" (apps.rs:1975). Gate the Toggle on `!newApp` too so the user has to deploy once before configuring the publish state. * refactor(drafts): drop dead draft_path field from list responses The draft-only listing branches in scripts/flows/apps computed a `draft_path` from the draft JSON (when the user-typed path differed from the URL's autogenerated `u/{user}/draft_{uuid}`), and `{Script,Flow,App} Row.svelte` preferred it over `path` for the row title. In practice that path is never written: the app, raw-app and flow editors all warn "Deploy the X to make the path change effective" — the rename only lands on deploy, never in the draft. So the field is always None and the home rows always show the autogenerated slot anyway. Drop the field from the three `Listable*` structs, the three draft-only push sites, the three OpenAPI response schemas, and the three frontend row components. Client regenerated. * fix(drafts): seed a friendly name on /flows/add The flow route passed `initialPath={page.params.path ?? ''}` to FlowBuilder, so on the `/flows/add → /flows/edit/u/{user}/draft_{uuid}` redirect the Path widget's `initPath` saw a non-empty `initialPath` and skipped the `reset()` branch that auto-generates the friendly `_flow` name. The other three editors all clear `initialPath` in their `new_draft` branch for exactly this reason. Track `initialPath` as route-owned state (defaults to the URL path) and clear it to '' inside the `new_draft` branch, then bind it through to FlowBuilder so any post-deploy update from the editor still propagates. * feat(drafts): render friendly user-typed path on home list for all 4 kinds Reinstate `draft_path` on `Listable{Script,Flow,App}` so the home rows prefer the user-typed name over the autogenerated `u/{user}/draft_{uuid}` URL slot, with two source rules — one per how each editor wires the Path widget: - Scripts already work: `ScriptBuilder` binds the Path widget directly to `script.path`, so the typed path round-trips through the draft JSON's own `path` field. Backend extracts `v["path"]` when it differs from `row.path`. - Flows / apps / raw apps don't write the typed path into the autosaved value (`Flow.path` is one-way-bound to `$pathStore`; the bare `App` / raw-app value has no `path` field at all). Introduce an explicit `draft_path` field on the draft JSON, written by the editor ONLY when the typed path differs from the deployed/seeded `savedX.path`: - FlowBuilder: $effect on `$pathStore` mutates `flow.draft_path`. - AppEditorHeader: $effect on `newEditedPath` mutates `$app.draft_path`. - RawAppEditorHeader: $effect surfaces `pendingDraftPath` up via the bind chain (RawAppEditor → route); the route's draftHandle.draft spread includes `draft_path` when set. Backend extracts `v["draft_path"]` and `None` when unchanged or after deploy (deploy clears the whole draft, so the field naturally disappears post-deploy without bookkeeping). Flow route's `new_draft` branch now stops sync around the Path widget cascade, with a 700ms scheduled `restartSync` (mirrors the existing scripts/apps/raw_apps stoppers) — the new draft_path mutation lands inside that window so `/flows/add` no longer fires an autosave before the user's first edit. openapi/sqlx regenerated. * fix(drafts): preserve the user-typed draft_path on reload of draft-only items The flow / app / raw-app editors all dropped the saved `draft_path` back to the URL's `u/{user}/draft_{uuid}` slot the moment the user reloaded a draft-only edit page: the route sourced the Path widget's initial path from `page.params.path` instead of the previously-saved `draft_path`, and the first user edit then mirrored that URL path back into the autosaved draft — silently overwriting the friendly name in both the row and the editor. - Flow route: after computing `effectiveFlow`, override `flowInitialPath` with `effectiveFlow.draft_path` when set. - App route: pass `newPath={(app.value as any)?.draft_path ?? app.path}` through to `AppEditor`; AppEditorHeader's `newEditedPath` default now prefers a non-empty `newPath` over the random `_app` seed (the `newApp && !newPath` branch keeps the `/apps/add` friendly auto-name). - Raw-app route: surface `savedRawAppDraft.draft_path` onto `backendApp` so the `extractRawApp` path seeds `newPath` with the friendly name. Reload + a subsequent edit now leaves `draft_path` intact for all three kinds; verified end-to-end via the `/drafts/get_draft/...` endpoint. * fix(ui): default Modal2 target to 'body' so omitting the prop doesn't throw Modal2 defaulted `target = ''` and forwarded it to `Portal`, which calls `document.querySelector(target)` — an empty selector throws "Failed to execute 'querySelector' on 'Document': The provided selector is empty" and the modal silently fails to mount. That's why `OtherUsersDraftsModal` (and `DraftSyncConflictModal`) never appeared on editors where another user had a draft — both omit the `target` prop. Other Modal2 callers (StorageSettings, CriticalAlert, CustomInstanceDbWizardModal, …) pass an explicit `target="#content"` and were unaffected. Match Portal's own default of `'body'` so omitting the prop is now a no-op rather than a runtime throw. * fix(drafts): Reset to deployed no longer resurrects the draft The toast's "Reset to deployed" callback POSTed `value: null` to the syncer, then handed control to the route's `onResetToDeployed` (which wipes the in-memory handle and reloads the deployed payload via `getDraft: false`). Both writes flowed through the reactive sync effect: the wipe scheduled a delete, the reload scheduled a re-save of the deployed value as the new draft. Coalescing collapsed them and the draft came back — making the "discard" action effectively a no-op. Wrap the whole callback in `UserDraft.stopSync` / `restartSync`. The explicit `value: null` POST still goes through (it's a direct `UserDraftDbSyncer.save` that doesn't depend on the reactive effect), the route's wipe-then-reload mutations advance `lastSerialized` silently under suspension, and the next user edit (after two ticks past the deployed-seed write) is the first real save again. * ui nit * feat(drafts): autosave-indicator popover with Reset-to-deployed action Click the cloud icon → popover with "All changes are saved as a draft on the server. The draft is per-user — your teammates' editors keep their own." When the editor isn't on a draft-only path AND the user has a draft (UserDraft.has returns true), a "Reset to deployed" button mirrors the load-time toast action — stops sync, POSTs `value: null`, runs the route's reload-without-draft callback, restarts sync past two ticks so the deployed-seed write doesn't resurrect the draft. Threaded `onResetToDeployed` from each route down to its builder (ScriptBuilder / FlowBuilder / AppEditorHeader / RawAppEditorHeader) and into the indicator. `draftOnly` is wired from `savedScript.no_deployed` / `newFlow` / `newApp` so the action hides where there's nothing to fall back to. The indicator's trigger now has a hover affordance + matches Portal's default target ('body') via Modal2's earlier fix. * fix(drafts): wait for the fork POST to land before navigating OtherUsersDraftsModal's Fork action called UserDraft.save, which routes through the autosave debouncer (1500ms). The subsequent goto fired within the same tick, so the destination editor's get_draft=true read ran before the POST landed and 404'd — refreshing worked because by then the debounced save had fired. Call UserDraftDbSyncer.save with immediate: true and await it. The syncer cancels any queued debouncer task for the key and resolves the promise only after the POST completes, so the route load can find the forked draft on the first try. * fix(drafts): conflict detection — keep last_sync map tab-local instead of in localStorage Two tabs editing the same draft both load with last_sync = T0. Tab-1 saves; the server accepts, returns T1, and the syncer wrote T1 into localStorage. Tab-2 then tries to save: it reads the SHARED localStorage map, sees T1 instead of its own baseline T0, sends last_sync = T1, and the backend's WHERE clause (`created_at <= last_sync`) is true → tab-2 clobbers tab-1's edit without ever seeing a conflict. Move the map to tab-local memory (`new Map`). Reload of the tab now starts with an empty map; that's fine because the editor's load path calls `recordRemoteSync(query, draft_saved_at)` right after `get_draft=true` returns, reseeding from the authoritative server timestamp before any user edit could fire a save. * fix(drafts): OtherUsersDraftsModal — close on Fork, don't leak clicks through nested JSON Two bugs in the per-editor "another user has a draft" banner: - Fork landed the immediate save but didn't close the banner before navigating. Svelte hadn't torn down the previous route's components by the time goto returned, so the banner lingered on top of the destination editor. Comment the explicit isOpen=false on the happy path so it's clear it MUST run before goto. - Clicking anywhere on the screen while the View JSON drilldown was open closed the underlying banner too. Modal2's clickOutside action fired on every Modal2 instance — both the JSON modal and the underlying banner — because both attach their own listener at the document level. Add `closeOnOutsideClick` opt-out on Modal2 and pass `closeOnOutsideClick={!jsonOpen}` to the outer modal so clicks outside the JSON drilldown only close the drilldown. Drive-by: Modal2's keydown handler now ignores Escape when its own isOpen is false (was a no-op closer that would still preventDefault on every key press, swallowing key events for any siblings). * fix(drafts): conflict modal wording — drafts are user-scoped, not teammate-scoped * fix(drafts): defer reset-to-deployed restart until first user interaction Two-tick `restartSync` was too aggressive: editor remounts emit a tail of cascading writes (Monaco setValue acks, schema re-infer, UI Builder iframe handshakes, schedule-config recomputes, …) that land well after two ticks and would clobber the just-deleted draft with an upsert of the deployed value — making "Reset to deployed" a no-op in practice, the user kept seeing the draft come back. Centralise the suspension lifecycle in a new `runResetToDeployed` helper. It stopSyncs around the reset, POSTs the explicit delete, runs the route's wipe-and-reload, and then arms a one-shot listener on document keydown / input / pointerdown that restartSyncs on the user's next real interaction. A 5-second fallback re-arms sync if the user walks away without touching the editor, so suspensions don't leak. Use it from both the load-time toast (`notifyDraftLoaded`) and the autosave-indicator popover so the two stay in sync — fixes both entry points. * indicator ui nits * fix(drafts): split tab-switch and unload flushes — kill self-conflict on visibility change The single keepalive flush bound to both `visibilitychange → hidden` and `pagehide` self-conflicted on tab switch: visibilitychange fires on every tab/app switch with the page still alive, the keepalive POST advanced the server's `created_at` to a fresh `now()`, the client discarded the response (no listener), the local `lastSync` stayed at the old value, and the next foreground autosave sent that stale timestamp → server saw `created_at > last_sync` → conflict modal for the user's own background-tab write. A still-pending debouncer task made it worse: it fired a second runner POST after the keepalive with the same stale `last_sync`, the second self-conflicted too. Split into two paths: - `visibilitychange → hidden` → `flushOnVisibilityHidden`: route through the normal runner pipeline. The page is alive, so the response can land and `setLastSync` keeps the baseline current. Call `debouncer.cancel(key)` first so a queued keystroke can't double-fire with the same stale `last_sync`. - `pagehide` → `flushOnPageHide`: keep the `keepalive: true` raw fetch for the genuinely-going-away case (the JS context is torn down, the response is necessarily discarded). Same `debouncer.cancel(key)` guard. On the next mount, the route's `recordRemoteSync(query, draft_saved_at)` reseeds `lastSync` from authoritative server state before any user edit can fire a save. * fix(drafts): drop the visibilitychange flush — debouncer keeps running on hidden tabs Tab switching just hides the page; the JS context survives and the debouncer's `setTimeout` keeps counting down. When it fires, the runner POSTs normally and the server's response updates `lastSync`. There's nothing left for a visibilitychange-driven flush to do that the ordinary pipeline doesn't already handle, and adding one only creates extra POSTs to reason about. `pagehide` remains the single trigger for the keepalive flush — that's the case where the JS context is actually being torn down and the runner's pending fetch would otherwise be killed mid-flight. * nit * refactor(drafts): drop LS-era pipeline; backend is canonical on load The PR's iteration left behind a meta/staleness pipeline carried over from the localStorage era — per-rev tracking, a LocalDraftStaleModal, a 'Restored from local storage' toast, and a localDraft-vs-backend comparison branch in every editor loader. With drafts now living in the DB and the optimistic-concurrency lastSync check handling divergence, that whole stack is dead weight. Worse, the comparison branch caused 'Load from server' in the conflict modal to do nothing: the loader preferred the in-memory cell over the backend, so the user-clicked 'load from server' just re-displayed the local edits AND fired two confusing toasts (Restored from local storage + Loaded your saved draft). The rip: * userDraft.svelte.ts: drop UserDraftMeta, StoredDraft.meta, checkStaleness, UserDraftStalenessCause, normalizeForCompare, localDraftDiffers, saveMeta, getMeta, setDraftAndMeta, setMeta, handle.meta/setDraftAndMeta/setMeta, force option. Handle is now just { draft }. * userDraftToast.ts: drop notifyRestoredFromLocal + RestoreFromLocalActions. Update copy. * LocalDraftStaleModal.svelte: deleted. * AppEditor.svelte: drop initialRevs prop and the firstMirror wipe-then-restore dance (it existed only to consume the meta-mismatch skip slot). * All 4 editor routes: backend is canonical on load — the in-memory cell is overwritten with the deployed+draft overlay, the syncer's seed guard swallows the first write so we don't POST it back. * VariableEditor / ResourceEditor: drop the staleness pipeline + rev bookkeeping; backend wins on open. * useTriggerDraftSync.svelte.ts: inline the JSON-normalize + deepEqual utility as a private cfgDiffers helper (kept for the form-vs-deployed dirty check, which is a genuine semantic compare, not LS legacy). * copilot core.ts / userDraftAdapter.ts: drop meta argument from saveAppDraft, loadAppDraftValue, write*Draft. Test assertions on getMeta dropped. Net: -22 typecheck errors, fewer moving parts, conflict modal works. EOF ) * refactor(drafts): remove dead endpoints + UserDraftDbSyncer.getLastSync The list_drafts and get_draft (own) routes were added during PR iteration and never wired up to any frontend caller — the editor overlay path uses the per-kind get-by-path getDraft query parameter, and the home page lists drafts via the per-kind list endpoints, not via /drafts. Drop both routes (+ sqlx caches + OpenAPI entries). UserDraftDbSyncer.getLastSync was a peep-hole for callers that never materialised — the per-tab lastSync map is only ever read by postSave internally, where the bookkeeping already lives inline. * refactor(drafts): extract DraftEditorModals trailer block The four editor routes (scripts/flows/apps/apps_raw) mounted an identical pair of trailer modals — DraftSyncConflictModal + OtherUsersDraftsModal — wrapped in the same guard chain and {#key path} remount. Lift the markup into one component; routes thread their itemKind, path, editPathFor, and loader callback. Pure markup extraction, no state ownership change. Drops the unused userStore import where the trailer was the only consumer. * refactor(drafts): UserDraft.useReactive — kill array-of-one boilerplate The script + flow routes both wanted a handle that re-keys when the URL path changes. UserDraft.use() can't do that (its opts getter is untracked), so each route hand-rolled the same useMany-array-of-one + proxy idiom: const handles = useMany(() => [{ kind, path: reactive }]) const handle = { get draft() { return handles[0]?.draft }, ... } Add UserDraft.useReactive(getSpec) that internally wraps useMany with a single spec and returns the stable proxy. Callers collapse to one line. * refactor(drafts): unify bootstrap suspension via armRestartOnFirstInteraction The flow and raw-app routes each rolled their own end-of-bootstrap resume: a 700ms setTimeout for flows and a templatePicker watcher with double-tick gating for raw-apps. Both are timing-fragile (the comments admit it) and drift from each other. armRestartOnFirstInteraction already existed in userDraftToast.ts for reset-to-deployed: keydown/input/pointerdown listeners (capture phase) that fire restartSync on the first real user touch, with a 5s belt-and-braces fallback. Export it and use it everywhere we'd previously have picked a magic number. For raw-apps this is a tiny behavioural change: the user's template choice now POSTs immediately (the pointerdown that picks the template also resumes sync, so the picker's onStart write rides the wake-up). Previously the choice only persisted on the user's NEXT edit. That's strictly better — navigating away preserves the choice now. * refactor(drafts): type App.draft_path; drop the as-any cast The audit asked for the three editors to converge on one draft_path injection pattern. For App and Flow, the in-builder $effect-mutates- the-store idiom is wedged into a shape that doesn't natively own the field — App's editor type genuinely has no draft_path so the writer had to cast through `as any`, and consumers downstream did the same. The minimum viable fix: declare draft_path on the local App type (it's already a field on the autosaved JSON). Lifting the writes upward into a route-side merger would mean restructuring the AppEditor mirror $effect and the FlowBuilder pathStore plumbing — larger change for the same shape, deferred to a follow-up. Flow already has the typed cast localised at one site. Will get the OpenAPI-level draft_path field as part of task 47 (drop as-any casts on backend overlay reads). * refactor(drafts): extract makeDraftAddLoad helper Four identical /add/+page.ts files differing only by the edit-route prefix. Lift the redirect into a factory, slim each entry point to two lines. * refactor(drafts): type UserDraftOverlay.other_drafts_users in the OpenAPI The backend response carried other_drafts_users on every get-by-path that supports the draft overlay, but the OpenAPI schema didn't declare the field. Each route had to cast the typed response to `any` to read it (and the sibling draft_saved_at), which obscured the real shape from the type system and rotted the discoverability of the draft surface. Add it to UserDraftOverlay. Frontend casts collapse to plain property reads in the three editor routes. * feat(drafts): list & open draft-only items for variables, resources, schedules, triggers For scripts/flows/apps the list and get-by-path endpoints already surface per-user drafts that have no deployed counterpart — that's what gates the home page from 404'ing on an AI-agent-created draft. Extend the same support to the other UserDraftItemKinds: Backend (list endpoints): - Add include_draft_only to ListVariableQuery, ListResourceQuery, ListScheduleQuery, StandardTriggerQuery (the latter covers the 11 trigger kinds via the generic TriggerCrud). - Append per-user draft rows whose path has no deployed row. Same gate as scripts/flows/apps: non-operators, page 0, no narrowing filters. Synthesis is per-kind: ListableVariable/Resource get field-for-field synthesis; ScheduleLight reads NewSchedule shape; Trigger uses a best-effort JSON merge + serde_json::from_value (rows skipped on deserialize failure rather than failing the list). - Add draft_only: Option with sqlx(default) to each row type so it serializes as the column is opt-in. Backend (get-by-path endpoints): - get_variable, get_resource, get_schedule, get_trigger fall back to fetch_draft_only when the deployed row is missing and the caller passed get_draft=true. Mirrors scripts/flows/apps. OpenAPI: - Shared IncludeDraftOnly parameter under components/parameters, wired into the 11 trigger list endpoints + listRawApps. Inline declarations on listVariable / listResource / listSchedules / listAzureTriggers. - draft_only field on ListableVariable, ListableResource, Schedule, TriggerExtraProperty. Frontend: - variables, resources, schedules, and the 10 trigger list pages (routes + 9 *_triggers) pass includeDraftOnly: true on the initial fetch and render on synthesized rows. Trigger pages got a sed/perl bulk update — pattern is the same across kinds. * fix(drafts): swap crypto.randomUUID() for the project's randomUUID helper crypto.randomUUID() is gated on a secure origin (HTTPS or localhost). Self-hosted Windmill instances often run on a bare HTTP origin or a LAN IP where the WebCrypto API is unavailable, so the /add redirect would throw before issuing the 307. Use the existing RFC4122 v4 helper in FlowChatManager that the rest of the codebase already imports for this exact reason. * fix(editor): leading-edge fire + max-wait cap on Monaco debounce The Editor debounced `onDidChangeModelContent` purely on the trailing edge — every keystroke rescheduled a 500ms timer, and uninterrupted typing held the bindable `code` prop stale until a pause. Stacked behind our 1.5s autosave debouncer that meant our clock didn't even start ticking until 500ms after the user paused, and the `code` binding never updated mid-burst for downstream consumers (lint, live preview, change listeners). Switch to leading + trailing + max-wait: * First keystroke of a burst fires `updateCode` synchronously, then stamps a wall-clock chain start. * Each subsequent keystroke (re)arms a trailing timer at `min(now + changeTimeout, chainStart + maxChangeTimeout)` — the cap is what makes continuous typing materialize at least once per maxChangeTimeout window instead of indefinitely. * When the trailing fires it resets the chain so the next keystroke after a pause is a fresh leading fire. New prop `maxChangeTimeout` (default 1000ms) sits next to the existing `changeTimeout` (default 500ms). Dispose path clears the chain stamp alongside the timer. * feat(drafts): wire Ctrl/Cmd+S to flush the pending autosave immediately Each builder already had a Ctrl/Cmd+S keybinding routed through a saveDraft() no-op left over from the LS-era — the comment said "persistence happens via the page-level UserDraft autosave" but the shortcut was the user's only way to actually force a save without waiting for the 1.5s debounce. Restore the intent. * UserDraftDbSyncer.flush({ workspace, itemKind, path }) — new method that re-submits whatever's queued in pendingSaveOpts with immediate: true. No-op when nothing's pending. * Editor.svelte.flushPendingChanges() — exposes a synchronous updateCode() with chain reset, so callers can drain Monaco's own trailing debounce before asking the syncer to flush. Without this step a Ctrl+S within ~500ms of typing would POST the pre-burst content. * ScriptBuilder.saveDraft() — editor?.flushPendingChanges() → await tick() → UserDraftDbSyncer.flush(). Toast on result. * FlowBuilder.saveDraft() — no direct Monaco ref (flows have many per-module editors); just flushes the syncer. Editor.svelte's new 1s max-wait cap means at most the last <1s of typing in a module Monaco won't be in this POST; it follows in the next autosave round. * RawAppEditor.handleKeydown — adds a 's' case that flushes before the focus guard, so the shortcut fires regardless of where focus is in the editor pane. * fix(drafts): low-code apps — drop spurious autosave on /edit + remount on Load from server Two bugs in low-code app editor (raw apps use a separate code path): 1. Every /edit visit looked like an autosave because loadApp() called UserDraft.discard('app', path, undefined). The comment claimed "this load doesn't POST" but discard always POSTs value: null server-side — that surfaced as a DELETE-my-draft on every page load AND a flash in the AutosaveIndicator. The discard was originally intended to wipe the in-memory cell so AppEditor remounts "fresh". But the path-change $effect upstream already sets app = undefined before each loadApp, which unmounts AppEditor and releases the handle's entry — so a remount via app = backendApp naturally starts with an empty handle. Drop the discard. 2. The conflict modal's "Load from server" called loadApp() but didn't remount AppEditor. Since AppEditor's stateApp is captured once at mount and doesn't react to prop changes, the editor kept showing the conflicting local edits even after a successful reload. Wrap the onLoadFromServer to await loadApp() then bump redraw to force a fresh mount. * feat(drafts): home-page Draft badge — show user-initial circles, drop the '+' The home-page Draft badge previously showed '+Draft' as a flat label. Add per-user awareness: up to 3 user-initial circles render to the left of the label, ordered alphabetically; with 4+ users we collapse to the first 2 + a '+N' overflow circle so rows stay compact. Backend: * New `DraftUserRef { username: Option }` in windmill-types::user_drafts, re-exported from windmill-common so the list endpoints in scripts/flows/apps crates share one import path (windmill-types/windmill-common can't be reordered without a cycle). * ListableScript / ListableFlow / ListableApp gain a `draft_users: Option>>` field. The list SQL adds a per-row subquery `SELECT json_agg(...) FROM draft d LEFT JOIN usr u ...` that aggregates the workspace users with a per-user draft at this path. NULL (no drafts) decodes to None; LEFT JOIN against `usr` lets orphaned drafts (user removed from workspace) still surface with username = None. * Synthesized draft-only rows set draft_users to a single-element vector with the authed user (those rows come from `email = $2`). OpenAPI: `draft_users` added to listScripts / listFlows / ListableApp response shapes as an array of `{ username }` with nullable username. Frontend DraftBadge: * Accepts `draft_users: { username?: string | null }[]`. Renders up to MAX_CIRCLES (3) initial circles; at 4+ users renders first 2 + a gray '+N' overflow circle. * Initials: 'john.doe'/'john_doe' → 'JD', 'alice' → 'AL', the legacy NULL-email row → '?'. * Color picked deterministically from a 6-entry palette so the same user gets the same circle color across rows. * Label is now just 'Draft' (dropped the '+'). 'Draft only' is unchanged. * Tooltip lists every user in full. ScriptRow / FlowRow / AppRow thread `draft_users` through their prop types and pass it to DraftBadge. * fix(drafts): suppress 'You have unsaved changes' banner when deployed baseline is null A brand-new variable/resource/trigger (no deployed row yet) has `getDeployed() == null`, but the caller's `show` prop is computed off `current != deployed` which is trivially true while the user types. Result: the banner appeared with 'Show diff' (no-op — the drawer early-returns on null deployed) and a 'Discard' that's semantically backwards (there's nothing to revert to). Gate `show` internally on `getDeployed() != null`. The check sits in the banner rather than each caller because every caller would otherwise need the same boilerplate guard. * fix(drafts): hide LocalDraftBanner when deployed and current match the DiffDrawer's compare Earlier I gated the banner on `getDeployed() != null`, but the user still saw it fire on entries where 'Show diff' opens to 'No changes detected'. That means `show` (the caller's coarse dirty check) flagged a difference the DiffDrawer treats as a no-op — typically toggle defaults (`false ↔ undefined`), removed empty arrays, or key-ordering noise that `cleanValueProperties + orderedYamlStringify` collapses. Replicate the drawer's comparison inside the banner: stringify both sides through the same pipeline and only render when the keys differ. A single `diffKey()` helper keeps the logic local; the catch-and-empty fallback survives a non-serializable side rather than throwing. * ui(drafts): nest user-initial circles inside the Draft badge Previously the circles sat alongside the Badge in a parent flex container; the result read as two separate UI elements. The Badge component already exposes its children as a snippet rendered inside its own flex row, so moving the circles into it makes them feel like part of the same chip. Knock-on tweaks: shrunk the circles from h-4/w-4 to h-3.5/w-3.5 so the badge stays compact, and tinted each circle's ring with the badge's indigo palette (instead of plain white) so the overlap reads as a deliberate stack rather than dots floating on top of the chip. * feat(drafts): drop the authed user's circle, mark own drafts with a '*' suffix Three tweaks to the home-page Draft badge: 1. Filter the authed user out of `draft_users` before rendering circles. The row already signals 'this user has a draft' via the asterisk (below), so a circle for them would be redundant noise. New `currentUsername` prop on DraftBadge — pass `$userStore?.username` from each row. The tooltip still lists every user (with `(you)` next to the authed one) so the full picture is one hover away. 2. The badge already showed whenever `is_draft || draft_users.length > 0` (per-user OR any-user). Spelled the rationale out in a comment — no logic change. 3. Append '*' to the displayed summary when `is_draft` is true. Falls back to `draft_path`/`path` when summary is empty so the marker never decorates an empty string. Threaded the same expression into ScriptRow / FlowRow / AppRow. Slice/overflow math now keys on the post-filter `otherUsers` list, so dropping the authed user doesn't silently shrink the visible count (e.g. 3 users incl. self → 2 circles, not 1 circle + a '+1' bubble). * feat(drafts): clone per-user drafts when forking a workspace `clone_workspace_data` clones every other workspace-scoped table on fork creation (resources, variables, scripts, flows, apps, raw apps, triggers, schedules) but quietly dropped the `draft` table. With per-user drafts that meant any open editor in the parent lost its pending edits the moment a fork was created — surprising and inconsistent with how forks treat the deployed surface. New `clone_drafts` mirrors the existing clone helpers: a single INSERT...SELECT into the target workspace, preserving `path`, `typ`, `value`, `created_at`, and `email`. The `email` FK targets `password.email` which is instance-scoped so it carries across workspaces without remap. `created_at` is preserved on purpose so the per-tab `last_sync` baseline lines up with the parent's timeline — otherwise the fork's next autosave would race a stale `last_sync` and trip the conflict modal on every cloned draft. Plain INSERT (not UPSERT) is safe because the fork target is empty at create time; no conflict against the partial unique indexes (`draft_pkey_with_user` / `draft_pkey_legacy`). The synthetic BIGSERIAL `id` PK is regenerated by the default so it stays out of the column list. * ui(drafts): pin the authed user to the first circle instead of hiding them Previously the authed user was filtered out of the circle row entirely on the theory that the row's '*' suffix already signalled 'this user has a draft'. New requirement: they should always lead the circle row when they have a draft so the visual half of the signal lines up across rows (consistent leading-slot identity, easy scan). Switch from a filter to a sort: `orderedUsers` finds the authed user in `draft_users` and splices them to index 0; everyone else keeps the backend's alphabetical order behind. Slice/overflow math now keys on `orderedUsers`, which guarantees the authed user never falls into the '+N' bubble — they're at position 0 and the slice keeps the head. The popover's '(you)' annotation moves to the circle's title attr too, so hovering the leading circle confirms the identity. * feat(drafts): drop draft_only column from script/flow/app Drafts now live in the `draft` table exclusively — `draft_only` stubs in script/flow/app are redundant. Migration `INSERT INTO draft ... ON CONFLICT (workspace_id, path, typ) WHERE email IS NULL DO NOTHING` so real per-user drafts already at the same path are preserved; only rare stubs that lost their draft get a synthesised workspace-level row. Stubs are then deleted (FKs cascade to *_version) and the column is dropped. List endpoints keep a synthesised `draft_only: true` on rows sourced from the draft table itself (sqlx default on the struct field). Co-Authored-By: Claude Opus 4.7 * ui(drafts): surface draft state in AutosaveIndicator instead of toast+auto-modal The "Loaded your saved draft" toast and the auto-opening OtherUsersDraftsModal both surprised users on every editor mount. Move both signals into the AutosaveIndicator label: "Loaded from draft" or "Others are working on this {kind}" (priority) sits where Saving/Saved do, with a one-shot light-green flash behind the indicator that fades to transparent. Saving/Saved still win when they fire. The popover gains a "See others' drafts" button that flips the modal open on demand; the modal itself is now externally controlled via a bindable \`isOpen\` threaded through DraftEditorModals. Co-Authored-By: Claude Opus 4.7 * ui(drafts): per-user View JSON / Fork actions in DraftBadge popover Hover popover used to be a plain text list of usernames. Now each row gets a colored circle icon + name + "(you)" for the authed user, and every OTHER user's row carries View JSON / Fork buttons mirroring the OtherUsersDraftsModal. For draft-only entries owned solely by the authed user, the popover ends with "Only you can see this {kind}" so the row's privacy is obvious. ScriptRow / FlowRow / AppRow thread workspace + itemKind + path + editPathFor through; AppRow switches between app / raw_app on app.raw_app. Co-Authored-By: Claude Opus 4.7 * nit * fix(drafts): clone only the forker's per-user drafts on workspace fork clone_drafts copied every user's drafts, but only the forker gets added to the fork's usr table. Drafts owned by absent users LEFT-JOIN to NULL in the home page's draft_users aggregate, surfacing as multiple legacy-style rows at one path and crashing the popover with each_key_duplicate. Filter the clone to email = forker OR email IS NULL, and key the popover's #each by index defensively so future legacy collisions can't crash the page either. Also re-adds `draft_only: None` to NewScript/CreateFlowBody literals in tests — the auto-generated windmill-api-client still carries the field and the previous commit dropped them too aggressively. Co-Authored-By: Claude Opus 4.7 * fix(drafts): always populate other_drafts_users in maybe_overlay_draft Reset-to-deployed reloads the deployed payload with get_draft=false, which made the backend return other_drafts_users=[]. The route then reassigned otherDraftsUsers to the empty list, dropping the count to 0 and hiding "See others' drafts" in the AutosaveIndicator popover — but the other users' drafts hadn't actually gone anywhere. Fetch the list independently of get_draft so the popover stays accurate across reset reloads. Co-Authored-By: Claude Opus 4.7 * feat(drafts): alert user when their draft is older than the latest deploy Open a modal on editor mount when the per-user draft was saved before the latest deploy at the same path — i.e. a teammate deployed a new version while this user's draft was sitting. Two choices: discard the stale draft and pick up the deploy, or keep editing the older draft. DraftEditorModals computes the staleness from the timestamps each route threads in (script.created_at, flow.edited_at, app_version.created_at) and the "Load latest deploy" callback reuses the route's existing reset-to-deployed logic. Wired for script / flow / app / raw_app editors; trigger / resource / variable drawer editors follow a different pattern and aren't covered here. Co-Authored-By: Claude Opus 4.7 * fix(drafts): deploy only wipes the deployer's draft, not everyone else's Script / flow / app deploys ran an unconditional DELETE on every draft at the path, so a teammate's deploy silently destroyed any other user's pending draft. After the wipe, the other user's tab kept auto-saving — re-creating the row at a NOW timestamp newer than the deploy — and StaleDraftModal never fired because draft_saved_at had been bumped past the deploy. Filter the DELETE to email = deployer (plus the legacy NULL row), so other users' drafts persist and the stale-draft prompt actually fires on their next reload. Co-Authored-By: Claude Opus 4.7 * fix(drafts): surface save failures in AutosaveIndicator instead of pretending Saved postSave caught network errors with `console.error` and let the runner finish normally. The indicator read the saving → none transition as a successful save and flashed "Saved" even when the request had thrown. Track failed keys in a SvelteMap, expose `'failed'` as a new UserDraftSyncState, render "Save failed" in red with a CloudOff icon. Failure clears on the next successful save for the same key, or when recordRemoteSync seeds a fresh authoritative timestamp. Co-Authored-By: Claude Opus 4.7 * fix(drafts): surface 'Save failed' inside the AutosaveIndicator popover too The popover used to repeat the cheerful "All changes are saved as a draft on the server..." copy even when the inline label said "Save failed", which read as contradictory. Add a red, text-xs warning at the top of the popover body when the sync state is `failed`, explaining that the latest edits didn't reach the server and that editing again retries the save. Co-Authored-By: Claude Opus 4.7 * fix(drafts): surface the actual error message in the AutosaveIndicator popover Replace the generic "your latest changes did not reach the server" copy with the real failure detail. The syncer now stores the extracted message in the failures map (formatSaveError walks body / message / statusText) and exposes it via the state handle's `failureMessage` getter. Popover renders it in red, monospaced, scrollable so a long server traceback doesn't blow out the popover. Co-Authored-By: Claude Opus 4.7 * fix(drafts): suppress Saving/Saved indicator during a reset-to-deployed discard A `value: null` POST is a discard, not a save, but it ran through the same runner the indicator watched — so resetting to deployed flashed "Saving..." → "Saved", reading as "your draft just landed" while we were actually wiping it. Track in-flight discards in a SvelteSet, expose a distinct `'discarding'` UserDraftSyncState, and the indicator stays quiet for it: no spinner, no label change, and the `discarding → none` transition deliberately skips the "Saved" flash. Co-Authored-By: Claude Opus 4.7 * Revert "fix(drafts): suppress Saving/Saved indicator during a reset-to-deployed discard" This reverts commit 625a47c5d290ff13ab35cd93db705c3e9bd21f6e. * fix(drafts): flush pending autosaves when the editor hook unmounts SPA navigation doesn't fire `pagehide`, so a debounced edit (up to maxDebounceMs old) silently disappeared when the editor was unmounted mid-typing. `UserDraft.useMany`'s onDestroy now walks every acquired entry and fires `UserDraftDbSyncer.flush(query)` before releasing, re-submitting the pending opts with `immediate: true`. The POST rides the runner's own lifetime and survives the component teardown. `use` / `useReactive` are thin wrappers around `useMany` so they inherit the flush automatically. Editors that don't go through the hook (sessions' `ScriptEditorView`, `AppJsonEditor`, copilot adapter, DraftBadge fork action) only call `UserDraftDbSyncer.save` for one-shot operations and don't need lifecycle flush. Co-Authored-By: Claude Opus 4.7 * nit * feat(ui): Modal2 fixedHeight='adaptive' sizes the modal to its content The fixed-height steps force either wasted whitespace or clipped content for small dialogs. `adaptive` emits no height rule (still capped by max-h-screen-80) so the modal hugs its content. Use it in StaleDraftModal, which only has two lines of copy and a button row. Co-Authored-By: Claude Opus 4.7 * feat(drafts): 'Create test drafts' button on the home page Dev/QA helper that seeds one per-user draft for every supported kind (script, flow, app, raw_app, trigger_schedule, resource, variable) at fixed u/{me}/draft_ paths, so the draft surfaces (home badges, editors, stale-draft modal, others' drafts modal) can be exercised without hand-creating items. Re-clicking overwrites the same paths. Value shapes mirror what each editor's autosave writes, matching the backend list synthesizers that parse them back. Co-Authored-By: Claude Opus 4.7 * fix(drafts): dedupe app list rows when a path holds both app and raw_app drafts The apps list LEFT JOINed draft with typ IN ('app', 'raw_app') for the is_draft flag — a path holding BOTH kinds for the same user (easy to hit: open a raw-app draft path in the regular app editor and its autosave writes the second kind) fanned the row out into two identical entries and crashed the home list with each_key_duplicate. Join a DISTINCT (path, workspace_id) subquery instead. Same dedup for the draft-only synthesis block via DISTINCT ON (path) keeping the most recently saved kind. Co-Authored-By: Claude Opus 4.7 * feat(drafts): asterisk on resource/variable/schedule/trigger rows with own draft Add an is_draft flag to ListableVariable / ListableResource / ScheduleLight / BaseTrigger list rows — a scalar EXISTS subquery on the draft table for the authed email (no join, so no row fan-out), plus is_draft: true on the synthesized draft-only rows. The list pages (variables, resources, schedules, all trigger kinds) append `*` to the displayed name when set, mirroring the home page's convention. Also fixes draft-only resources never appearing on the resources page: the page always lists with resource_type_exclude=cache,state,app_theme (its tab split) and the synthesis gate bailed on any type filter. The gate now keeps synthesizing and applies resource_type / resource_type_exclude per-row against the draft JSON instead. list_triggers (trait default) takes an authed_email: Option<&str> — Some from the list endpoint, None from workspace export. Co-Authored-By: Claude Opus 4.7 * Revert "feat(drafts): 'Create test drafts' button on the home page" This reverts commit 1f244a2a8b0407781e5f703b5927d03cf567c76c. * fix(drafts): P1 hardening — save authz, secret scrubbing, hot-path index 1. save_draft had no authorization check (a regression from the old create_draft's require_writer_of_path): any workspace member could plant drafts in another user's u/ namespace or unwritable folders, and those drafts get surfaced to every reader of the path (home circles, others'-drafts modal, View JSON / Fork). New require_can_write_path: admins; own u/ namespace; g/ namespace when in the group; f/ folders with the write/owner bit (with the same folder-claim refresh deploy endpoints use). Operators are rejected outright — they're excluded from every other draft surface. 2. Secret variable values were persisted in the draft table in plaintext. save_draft now blanks variable.value for is_secret drafts at write time (the editor never round-trips secret values anyway — it fetches with decrypt_secret=false), and a migration scrubs rows persisted before the guard. 3. fetch_other_drafts_users runs on every get-by-path request with (workspace_id, path, typ) and no email predicate — neither partial unique index covers it, so it seq-scanned a table that accumulates per-user autosaves across all workspaces. Add a plain btree index; it also serves get_draft_for_user's IS NOT DISTINCT FROM lookup. Co-Authored-By: Claude Opus 4.7 * fix(drafts): Ctrl/Cmd+S flush narrates via the indicator, not a toast The "Draft saved" toast fired even with the network down — flush never rejects (postSave catches errors internally and routes them to the failures map), so the success branch always ran. Drop the toasts from the script / flow / raw-app Ctrl+S handlers; the AutosaveIndicator already narrates the flush truthfully (Saving... → Saved / Save failed in red). Co-Authored-By: Claude Opus 4.7 * fix(drafts): Ctrl/Cmd+S always flashes Saved in the indicator After dropping the toast, an explicit Ctrl/Cmd+S with nothing pending (the common case — autosave already landed everything) gave zero feedback: flush() no-ops when pendingSaveOpts is empty and no state transition fires. flush() now bumps a reactive per-key counter on completion (no-op path included), exposed as flushCount on the state handle; the AutosaveIndicator flashes "Saved" on the bump when the pipeline is idle. Real flushes keep narrating through Saving... → Saved / Save failed as before. Co-Authored-By: Claude Opus 4.7 * ui(drafts): Ctrl/Cmd+S replays the green backdrop flash on the indicator Decouple the one-shot light-green → transparent backdrop from the load hint label: triggerFlash() owns the keyed span (mounted only while the animation runs), and both the on-mount hints and the Ctrl/Cmd+S confirmation route through it. The flush bump fires after the POST lands, so a real flush flashes too — not just the no-op path. Co-Authored-By: Claude Opus 4.7 * feat(drafts): 'Create test drafts' button on the home page Dev/QA helper that seeds one per-user draft for every supported kind (script, flow, app, raw_app, trigger_schedule, resource, variable) at fixed u/{me}/draft_ paths, so the draft surfaces (home badges, editors, stale-draft modal, others' drafts modal) can be exercised without hand-creating items. Re-clicking overwrites the same paths. Value shapes mirror what each editor's autosave writes, matching the backend list synthesizers that parse them back. Co-Authored-By: Claude Opus 4.7 * fix(drafts): Ctrl/Cmd+S reaches the raw-app flush from every editor surface The raw-app window keydown handler never fired in practice: the file editor is a VS Code workbench in a same-origin iframe (keydowns don't cross documents) and the inline-script / YAML Monacos swallow Ctrl+S via addCommand. Two hooks: - attach a capture-phase keydown listener inside the iframe document on each load (no preventDefault — VS Code's own save still runs, we flush the pending autosave alongside it); - Editor.svelte / SimpleEditor.svelte re-broadcast their swallowed Ctrl+S as a `wm-monaco-save-shortcut` window event, which RawAppEditor listens for. Co-Authored-By: Claude Opus 4.7 * fix(drafts): editing a draft-only item opens create mode prefilled from the draft Variable / resource / schedule / trigger editors treated every loaded path as deployed and routed saves through the update endpoints, which 404 for draft-only items ("Resource not found at name ..."). The get-by-path responses already mark the case (`no_deployed` from fetch_draft_only) — editors now flip to create mode when it's set: - VariableEditor / ResourceEditor: existedInitially = !no_deployed - ScheduleEditorInner + all 10 trigger editor inners: loadTrigger / loadSchedule return { overlay, noDeployed } and openEdit sets edit = !noDeployed The form opens prefilled from the draft and deploys via create, whose endpoints already delete the creator's draft on success. (The "Could not load schedule: Not Found" half of the report was a stale dev backend — getSchedule?get_draft=true verified working on the current build.) Co-Authored-By: Claude Opus 4.7 * fix(drafts): leading-edge draft saves for raw apps (no double debounce) Raw-app file changes reach the parent already coalesced — the UI Builder iframe holds a ~1s trailing debounce on its rebuild and only posts setFiles when it fires. The syncer then stacked its own 1.5s trailing window on top, so the draft landed ~2.5s after the user stopped typing. The debouncer now supports a leading edge (run immediately when the key is idle and cooled down; later schedules in the window coalesce trailing with the max-wait ceiling, mirroring the classic editor's first-keystroke-materializes-immediately logic), and raw_app saves opt into it. The app build keeps its own trailing debounce inside the iframe — only draft persistence is affected. Co-Authored-By: Claude Opus 4.7 * ui(drafts): blue flash for load hints, green for save confirmations The backdrop flash now carries meaning: green = "your save landed" (Ctrl/Cmd+S), blue = informational on-mount hints ("Loaded from draft", "Others are working on this ..."). Color is passed as an inline CSS custom property the keyframe reads, so the single keyframe serves both variants. Co-Authored-By: Claude Opus 4.7 * Revert "fix(drafts): leading-edge draft saves for raw apps (no double debounce)" This reverts commit 1b996fd73afac1b65750cac49d24b332726bf25f. * feat(drafts): 'Enable auto-save' toggle in the AutosaveIndicator popover Browser-wide preference (default on, persisted in localStorage). While off, the reactive keystroke mirror never POSTs — saves marked `auto: true` park their latest opts in pendingSaveOpts instead of scheduling, and the unload keepalive flush is skipped, so nothing leaves the tab except explicit actions: Ctrl/Cmd+S flush (sends the parked latest content), discard / reset-to-deployed, fork, conflict overwrite. The indicator shows a muted cloud-off while disabled (the idle check-mark would otherwise read as "everything saved") and the popover copy explains the Ctrl/Cmd+S-only behavior. Re-enabling re-schedules every parked unsaved draft so edits made while off catch up immediately. Co-Authored-By: Claude Opus 4.7 * Revert "feat(drafts): 'Create test drafts' button on the home page" This reverts commit fd7013b399b55c7609624f3e373e9dc68f6760f3. * feat(drafts): Review & Deploy covers variables/resources/schedules/triggers The drafts review page only assembled scripts/flows/apps from three paginated list endpoints, so drafts of every other kind were invisible. New GET /w/{ws}/drafts/list returns every draft of the authed user in one query over the draft table, with a per-kind draft_only flag (deployed-table EXISTS per kind); getDraftItems switches to it, which also drops the 3×N-page fan-out. CompareDrafts renders the new kinds (icon via a UserDraftItemKind → layout-Kind mapping, gray kind badge, list-page edit links for drawer-based editors), diffs them through a generic overlay GET, and deploys them by replaying the editor save: create/update for variables and resources, saveScheduleFromCfg for schedules, the per-kind save*TriggerFromCfg helpers for the ten standalone trigger kinds. Also fixes two paths stale since the draft_only column removal: draft-only flows/apps now deploy via create (update 404s — there is no row anymore), and discard always deletes the draft row (the old delete-the-item branch 404'd for the same reason). Co-Authored-By: Claude Opus 4.7 * feat(drafts): optimistic asterisk while editing in list-page drawers The `*` suffix on variable/resource/schedule/trigger rows came from the server's is_draft flag, which only updates on a refetch — editing an item in the drawer didn't mark its row until much later. New localDraftHints module (SvelteSet-backed): editors publish their dirty state (the same condition that shows the "You have unsaved changes" banner) and the 13 list pages OR the hint into the asterisk condition, so the suffix appears the moment the form diverges and clears on discard/teardown. Wired once in useTriggerDraftSync (covers the schedule editor and all ten trigger editors) plus VariableEditor and ResourceEditor. Co-Authored-By: Claude Opus 4.7 * ee repo * fix(drafts): draft hints persist past editor teardown, re-sync on reopen Clearing the optimistic asterisk on drawer close was wrong: the divergence the editor observed is autosaved server-side, so the draft outlives the drawer and the asterisk should too. Hints are now corrected rather than expired — while an editor is settled on an item it publishes the observed truth in both directions (divergence sets, sitting at the deployed baseline clears), so a draft discarded from another tab loses its stale asterisk the next time the item is opened. No teardown cleanup anywhere. Co-Authored-By: Claude Opus 4.7 * fix(drafts): list-page asterisk mirrors the editor's banner, not stale is_draft The asterisk was `is_draft || hint` — an OR can turn the asterisk on optimistically but can never turn it OFF, so after discarding a draft (or editing back to the deployed value) the stale server flag kept the asterisk until the next list refetch. Make the local hint a tri-state override instead: the editor publishes the live banner state (true/false) into a SvelteMap, and the list pages read `getLocalDraftHint(...) ?? is_draft` — the editor's observed truth wins over the stale server flag in both directions. Co-Authored-By: Claude Fable 5 * fix(drafts): autosaves equal to the deployed value delete the draft instead When the user edits back to exactly the deployed value, the reactive autosave mirror used to persist a baseline-equal copy — a useless draft row that kept `is_draft` (and the list asterisk) on after refetch. Add a `discardIfEqualTo` baseline getter to `UserDraft.useMany` specs: when the cell's value deep-equals the deployed baseline, the mirror POSTs `value: null` (delete) instead of the value. The variable and resource editors pass their `initialStates` baseline, guarded on `existedInitially` — draft-only/new items have no deployed copy, so equality must never delete their only data. Co-Authored-By: Claude Fable 5 * Draft encryption for secret variables * fix(drafts): discardIf predicate + deploys clear the asterisk and draft row Two follow-ups on the baseline-equal-autosave-deletes change: 1. `discardIfEqualTo` (baseline getter + raw deepEqual) becomes `discardIf` (predicate). Raw deepEqual reported spurious diffs after a refresh: drafts round-trip through JSON, which strips undefined-valued keys, so a restored draft (`{}`) never compared equal to the freshly built baseline (`{ labels: undefined }`) and the delete never fired. The editors now pass the SAME comparison that drives their "unsaved changes" banner — a new exported `draftValuesEqual` (JSON-normalized deep equality) used by both — so the banner and the synced draft can never disagree. 2. Truly saving (deploying) clears the asterisk and the draft row: - variable/resource editors: replace post-deploy `UserDraft.remove` (blanks the cell to `undefined`, which reads as dirty and keeps the banner + asterisk on) with `discard` to the just-saved state, and refresh `initialStates`/`existedInitially` so the editor settles clean. - trigger editors: `useTriggerDraftSync.discard` publishes the hint off explicitly — after a deploy the editor's `deployed()` baseline is stale, so the hint effect alone would keep the asterisk on. - Review & Deploy page: `deployDraft`/`discardDraft` clear the hint. Co-Authored-By: Claude Fable 5 * revert encryption just for the resources part * fix(drafts): required const DRAFT_KIND on TriggerCrud; deploy/delete cover raw_app The TriggerCrud::user_draft_item_kind() default matched on TRIGGER_TYPE and panic!'d on any unmapped string — a runtime crash on the first draft save for a trigger that forgot to map. Replace it with a required associated const DRAFT_KIND, so a missing mapping is a compile error. user_draft_item_kind() now just returns Self::DRAFT_KIND; every impl (OSS + EE) declares the const. Also fix the app deploy/delete draft cleanup to cover raw_app: raw apps deploy and delete through the same internal path, but the cleanup filtered typ = 'app' only, leaving raw_app drafts dangling (create_app_internal apps.rs:1465, update path apps.rs:2077) or un-archived on delete (apps.rs:1687). Co-Authored-By: Claude Fable 5 * fix(drafts): deleting an item wipes every user's draft, not just the caller's Scripts/flows/apps already wiped all users' drafts on delete, but resources/variables/schedules/triggers called delete_user_draft (caller-scoped), so a teammate's draft on the just-deleted item lived on forever — surfacing through fetch_other_drafts_users with no item left to deploy onto. Add delete_all_drafts_for_path (all emails + the legacy NULL row) and use it in every delete handler; keep delete_user_draft for the discard-my-own-draft flow where the item lives on. Co-Authored-By: Claude Fable 5 * perf(drafts): skip other-drafts query on non-editor reads (get_draft=false) maybe_overlay_draft ran fetch_other_drafts_users (a usr join) on every get-by-path, including worker/CLI reads of MB-scale flows & apps that pass get_draft=false and never render the draft overlay or "others editing" surfaces. Gate the query behind get_draft — only editor reads pay for it. Reset-to-deployed editor reloads still get it (they pass get_draft=true). (Eliminating the serde_json::to_value materialization of the deployed payload needs WithDraftOverlay to become generic over T, which is folded into the get-by-path choreography refactor.) Co-Authored-By: Claude Fable 5 * refactor(drafts): single-source the kind→table mapping via deployed_table() The kind→table dispatch lived in three places that could drift: the TriggerCrud string-match (already replaced by const DRAFT_KIND), the table_for_kind access-check map, and a hand-written draft_only CASE in list_drafts. Add UserDraftItemKind::deployed_table() as the single source (plus an ALL enumerator). table_for_kind now delegates to it, and the list_drafts draft_only CASE is generated from it at runtime (table names come from the closed enum, never user input — no injection). Drift between the access check and the existence check is now impossible by construction. Webhook and the native triggers (poll/cli/nextcloud/google/github) map to None: they have no path-keyed backing table and aren't draftable, so they report draft_only=true and use a path-only access check. This also fixes a latent bug where table_for_kind mapped native kinds to native_trigger, which has no `path` column — the access query `SELECT 1 FROM native_trigger WHERE path = $1` would have errored. Co-Authored-By: Claude Fable 5 * ee repo * fix(drafts): close variable draft-secret laundering oracle (sentinel + rehydrate) save_draft encrypts secret variable values with the workspace key, but the ciphertext was round-tripped to the client and the deploy endpoints decrypted whatever $encrypted: ciphertext the client submitted (variables.rs create/update). Any workspace member who can write a variable path could take an arbitrary workspace-key ciphertext (another user's secret draft via GET /drafts/get with only path-read, or a deployed secret's stored value) and submit it as their own secret variable's value — the server decrypted it and, since they own the path, they read the plaintext back. That bypasses the audited decrypt_secret permission. Fix: the ciphertext never leaves the server. get_variable swaps a draft secret's $encrypted: value for an opaque $draft_secret sentinel (both the draft overlay and the draft-only inner stand-in). On deploy the client sends the sentinel back and the server rehydrates the plaintext from the caller's OWN draft row — the only ciphertext it ever decrypts is one it encrypted for this exact (workspace, path, email). A raw $encrypted: submitted by a client is now rejected outright. Co-Authored-By: Claude Fable 5 * fix(drafts): don't clobber a secret draft when autosaving the $draft_secret sentinel After reload the client holds the $draft_secret sentinel for a secret variable (never the ciphertext). Editing some OTHER field (description, labels) triggers an autosave carrying value="$draft_secret" — and save_draft's encrypt_secret_variable_value, seeing a non-empty, non-$encrypted: string, encrypted the literal sentinel, overwriting the real ciphertext in the draft row and losing the secret. Treat the sentinel as "secret unchanged": restore the $encrypted: ciphertext already stored in this user's draft row instead of encrypting the placeholder (falling back to empty only if there's no prior ciphertext). The new lookup reuses the same query shape as the deploy- time rehydrate, so no new offline cache entry. Co-Authored-By: Claude Fable 5 * Revert "$draft_secret" sentinel approach for variable draft secrets Reverts 339c259fce and b2c38ef407. Instead of round-tripping a sentinel and rehydrating server-side, we close the laundering vector more simply by disabling cross-user draft visibility for triggers/resources/variables (next commit) — an attacker can no longer read another user's secret draft ciphertext to launder it. Co-Authored-By: Claude Fable 5 * feat(drafts): keep drafts private to their owner for resource/variable/trigger kinds Replaces the reverted $draft_secret sentinel: instead of laundering-proofing the ciphertext round-trip, simply don't expose other users' drafts for the drawer kinds (resource/variable/triggers). A viewer can no longer obtain another user's secret-variable draft ciphertext, so it can't be laundered into plaintext via deploy. UserDraftItemKind::shares_drafts_across_users() — true only for script/flow/app/raw_app. maybe_overlay_draft skips other_drafts_users for non-sharing kinds, and get_draft_for_user (View JSON / Fork) returns 404 for them. Own-draft load/save is unchanged. Co-Authored-By: Claude Fable 5 * refactor(drafts): make the list-page asterisk hint a shadow of UserDraftDbSyncer The optimistic `*` hint was written by three open-editor publishers, so draft deletions that didn't go through an editor (banner discard, autosave-back-to-baseline, Review & Deploy) left a stale asterisk that a server refetch couldn't clear (the hint overrides is_draft). Move ownership to the syncer — the one choke point where a draft's existence actually changes: - postSave sets the hint on a saved write (value !== null) and clears it on a delete (null), so every syncer-routed delete clears it for free. - save() lights it optimistically when a real save is scheduled, so the asterisk still tracks the editor's banner without the debounce lag. The editors no longer SET the hint; they only CLEAR it when settled at the deployed baseline (so a draft discarded from another tab disappears on reopen). discardDraft drops its explicit clear (postSave covers it); deployDraft keeps one (it deletes server-side, bypassing the syncer). Co-Authored-By: Claude Fable 5 * chore(migrations): fold draft index + secret scrub into the base sync migration Merge 20260610095349_draft_workspace_path_typ_index and 20260610100018_scrub_secret_variable_drafts into the base 20260528143710_draft_user_sync_schema migration (the index creation + secret-draft scrub in .up, the index drop in .down; the scrub stays irreversible). 20260609165313_remove_draft_only remains standalone. Verified the full chain applies and reverts cleanly on a fresh DB. (Rewrites an already-applied migration — existing dev DBs need a reset.) Co-Authored-By: Claude Fable 5 * refactor(drafts): promote the get-by-path draft choreography to one helper The "Some(deployed) → overlay / None+get_draft → draft-only / None → 404" dance was copy-pasted across the get-by-path handlers and had drifted (different 404 text, the trigger one missing the draft-only fallback at first). Promote it to windmill_common::overlay_or_draft_only, which takes the deployed entity as Option and a per-route not_found closure. Converts scripts, flows, apps, schedules, and triggers onto it. Resources keeps its own (it runs an async explain_resource_perm_error on the 404 path) and variables keeps its own (secret-decrypt logic interleaved with the draft fetch) — both genuinely diverge from the common shape. (The serde_json::to_value elimination via a generic WithDraftOverlay, and the list-only draft synthesis dedup, remain as follow-ups.) Co-Authored-By: Claude Fable 5 * perf(drafts): serialize the deployed overlay payload in one pass maybe_overlay_draft materialized the deployed entity into a serde_json::Value tree (serde_json::to_value) and then serialized that tree again into the response — two passes plus a full Value allocation over what can be an MB-scale flow or app, on every get-by-path (including get_draft=false worker/CLI reads). Hold WithDraftOverlay.inner as a boxed erased_serde::Serialize trait object instead, so the deployed payload flattens straight into the response in one pass. The struct stays non-generic, so the helper and all seven handler return types are unchanged; only the deployed type now needs Send + 'static (already true — they're owned rows; added 'static to TriggerCrud::Trigger to say so). Co-Authored-By: Claude Fable 5 * refactor(drafts): one helper for the draft-only list synthesis query The "draft rows at paths with no deployed counterpart" query was copy-pasted into the variable / resource / schedule / trigger list handlers, each hardcoding its own typ literal and NOT EXISTS table — a drift hazard. Promote it to windmill_common::fetch_draft_only_list_rows, which derives the absence-check table from kind.deployed_table() (the same single source as the access check and draft_only flag). Each handler keeps its own include_draft_only gating and per-type row mapping (genuinely entity-specific); only the shared SQL is deduped. The trigger handler's prior generated-SQL version is folded in too. Co-Authored-By: Claude Fable 5 * fix(drafts): route raw-app draft deploys through the raw-app endpoint [P1] deployDraft's raw-app guard was `kind === 'app' && rawApp`, but Review & Deploy passes `kind === 'raw_app'` (raw apps are their own DRAFT_KIND), so the guard never fired and the row fell into the visual-app branch. There `d.value` is undefined (a RawAppDraft has files/runnables/data, no `value`), so AppService.updateApp did a partial update — resetting policy to the publisher default, never bundling/deploying the files — the backend then deleted the user's raw_app draft rows, and the UI reported "deployed". The work-in-progress was destroyed without ever deploying. Route `kind === 'raw_app'` (or the editor's `app` + rawApp) through deployRawAppDraft. The now-unreachable `raw_app` arm of the visual-app branch is dropped. Co-Authored-By: Claude Fable 5 * fix(drafts): allow draft saves for item-level extra_perms writers [P1] require_can_write_path only accepted namespace rules (own u/, member g/, writable f/), dropping the item-level extra_perms check the old create_draft had. A user granted write on e.g. u/alice/script via the Share dialog could still deploy it (the update endpoints go through RLS) but could no longer save a draft — and because the editors autosave continuously with no permission gate, editing a shared item produced a persistent "Save failed: you don't have write permission" and Ctrl/Cmd+S failures. Add the item-level fallback: when a deployed row exists at the path, check its extra_perms for a write grant (every deployed table has extra_perms; the table comes from the closed deployed_table() mapping). Draft-only items have no row and stay governed by the namespace rules. Co-Authored-By: Claude Fable 5 * fix(drafts): pass rawApp on get-app for never-deployed raw-app drafts [P2] A raw app that has only ever been drafted has no `app` row, so get_app resolves the draft kind from the `rawApp` query param. getDraftDiffValues ("Show diff") and deployRawAppDraft both fetched with getDraft=true but without rawApp, so the backend looked up the visual-app draft kind, found nothing, and 404'd. Pass rawApp so the raw_app draft is found. Co-Authored-By: Claude Fable 5 * feat(drafts): surface the localStorage→DB migration with toasts migrateUserDraftsToDb already uploaded legacy "userdraft/..." entries and cleared them on success (and runs after the v1→userdraft normalizer). Add the user-facing surface: when real legacy entries are detected, show an info toast "Migrating local storage drafts ..."; on a per-draft failure show an error toast "Could not migrate draft in workspace " with a "Delete draft" action that drops the stuck localStorage entry (otherwise it retries every mount). Unparseable junk is still cleared silently up front, so the toast only fires for genuine drafts. Co-Authored-By: Claude Fable 5 * test(drafts): cover the autosave pipeline's pure-logic utilities [P2] The deleted draft tests left the new debouncer + coalescing runner — the core of the autosave pipeline — with zero coverage. Add vitest suites (16 cases) for debouncerByKey (debounce window, latest-task-wins, maxDebounceMs ceiling under a trickle, fresh-chain-after-fire, cancel, key independence) and coalescingRunner (immediate run when idle, coalesce burst to in-flight + latest, displaced-task drop, submitAndWait resolve/reject/displaced, cancel semantics, key independence). Broader replacement (save_draft conflict semantics + the require_can_* checks as backend integration tests) still outstanding. Co-Authored-By: Claude Fable 5 * feat(drafts): add UserDraft.seed — a one-shot baseline load that never POSTs The page editors bracket their new-draft / deployed-baseline loads with stopSync + restartSync so the programmatic write isn't synced as the user's edit. Forgetting restartSync silently disables autosave for the session — the footgun behind the three divergent resume strategies the review flagged. `UserDraft.seed(kind, path, value)` is the scoped alternative: it sets the cell (all reactive readers update) and arms a single-shot `seedNextWrite` flag the sync effect consumes — adopting the value as the new baseline and skipping exactly that one POST, with no suspension to resume. Additive: stopSync/restartSync are untouched and still used for the writes that fan out across editor components (initContent cascades). Foundation for converting the editor bootstraps off the bracket. Co-Authored-By: Claude Fable 5 * refactor(drafts): extract usePageDraftSync; convert the scripts editor onto it First step of unifying the four page editors' hand-rolled draft orchestration (three divergent handle-ownership models + an easy-to-forget recordRemoteSync). usePageDraftSync is the single model — the page analogue of useTriggerDraftSync — owning the re-keyed autosave handle, the live-editor-draft registry entry, recordRemoteSync (now a method, not a per-page ritual), seedBaseline (via UserDraft.seed), and draft removal. The scripts editor is converted as the reference adoption: its inline useReactive handle, live-editor-draft effect, recordRemoteSync, and the two UserDraft.remove calls now go through draftSync. The new-draft stopSync bracket stays (it spans ScriptBuilder's initContent cascade). Verified in a real browser against the dev stack: load fires no spurious save, a code edit triggers exactly one save_draft POST + a draft row, and the draft persists across reload. Flows / apps_raw / apps conversions follow. Co-Authored-By: Claude Fable 5 * refactor(drafts): convert the flows editor onto usePageDraftSync Replace the inline useReactive handle + UserDraftDbSyncer.recordRemoteSync + UserDraft.remove with draftSync. effectivePath is omitted — flows register their live-editor-draft entry through FlowBuilder (liveEditorDraftStoragePath), so the composable doesn't double-register. The new-draft stopSync + armRestartOnFirstInteraction bracket stays (it spans FlowBuilder's seed cascade). flowStore reads/writes draftSync.draft. Verified in a real browser: load fires no spurious save, a summary edit triggers exactly one save_draft POST + a draft row, and the edit persists across reload. Co-Authored-By: Claude Fable 5 * refactor(drafts): convert the apps_raw editor onto usePageDraftSync Replace the UserDraft.use handle + mirror, UserDraftDbSyncer.recordRemoteSync, and UserDraft.remove with draftSync. `path` is a mount-scoped plain `let` (the editor remounts per path), so the composable's useReactive re-keys only on workspace change — equivalent to the prior capture-once use(). effectivePath omitted (RawAppEditor owns the live-editor-draft entry); the new-draft stopSync + armRestartOnFirstInteraction bracket stays. Type-checked and behavior-equivalent (handle mechanism unchanged; the centralized recordRemoteSync/remove read the same `path`). Not browser-exercised here — no existing raw app in the dev workspace and the new-draft template-picker flow isn't scriptable quickly; scripts and flows (same composable) were verified live. Co-Authored-By: Claude Fable 5 * fix(drafts): remove app autosave at its canonical key after deploy/rename AppEditor keys the app autosave on the URL draft path and passes it down as userDraftPath, but AppEditorHeader's post-deploy cleanup re-derived the key from the just-typed deploy path (createApp) / the live $appPath (updateApp) instead. For a new app the autosave lives at u/{user}/draft_{uuid} while the typed path is the user's chosen name, and a rename leaves the autosave at the original key — so removing at path/$appPath missed the real draft row and orphaned it. Use the canonical userDraftPath AppEditor already provides. This is the "children re-derive the UserDraft key" fragility from the review, addressed without giving apps a page-level handle — apps deliberately lets AppEditor own the handle so the entry is destroyed on unmount (a page handle would keep it alive and reintroduce spurious autosaves on every /edit visit). Co-Authored-By: Claude Fable 5 * test(drafts): integration tests for save_draft conflict semantics + authz [P2] Replaces the deleted drafts.rs (which targeted the removed /drafts/create API) with tests for the new surface: - save_draft upsert → stale-last_sync conflict (rejected, value unchanged) → force overwrite → delete, the optimistic-concurrency contract. - require_can_write_path: own namespace allowed, another user's namespace rejected, operators rejected. - the item-level extra_perms fallback — a user granted write on a deployed item can save a draft on it (regression test for the authz drop). - cross-user draft privacy: GET /drafts/get is 404 for the drawer kinds (variable/resource/triggers), not blocked for script/flow/app. Co-Authored-By: Claude Fable 5 * chore(sqlx): refresh offline cache after the main merge The merge auto-combined both branches' additions inside the resource get-by-path query_as! (our draft_only/is_draft columns + main's folder_labels(...) inherited_labels), producing query text neither branch had cached — so the offline build failed for it. Regenerate the entry (rename to the new content hash) and refresh a re-described workspace query. Feature-gated/EE entries the local prepare can't compile are left as committed. Co-Authored-By: Claude Fable 5 * ee repo ref * chore(system_prompts): regenerate for draft_only/is_draft trigger schema fields The openapi.yaml trigger/schedule schemas gained draft_only + is_draft, but system_prompts/generate.py wasn't rerun, failing the freshness check. Co-Authored-By: Claude Fable 5 * refactor(drafts): defer save_draft write authz to RLS via a FOR UPDATE probe require_can_write_path re-implemented the item-level extra_perms write rule in Rust (SELECT extra_perms + get_perm_in_extra_perms_for_authed) — a third copy of rules whose canonical home is the RLS policies, and the exact lane that regressed once already. Replace it with an RLS write-probe: `SELECT 1 FROM {deployed_table} WHERE path/workspace ... FOR UPDATE` through UserDB. Postgres applies UPDATE policies to rows locked via FOR UPDATE, so a returned row means the canonical policies (see_own / see_member / folder-write / see_extra_perms_*_update / admin_policy) would let this user UPDATE the row — no write rule re-implemented, no drift possible. The probe's row lock is released by the immediate commit. The claim-based namespace checks stay, evaluated FIRST: they read the same JWT claims RLS does (so outcomes are identical), they spare the autosave hot path a DB round-trip for the common own-namespace case, and they are the entire check for draft-only paths — where no deployed row exists, so there is structurally nothing for RLS to evaluate. The u/own + folder-owner part now goes through the shared windmill_api_auth::require_owner_of_path instead of bespoke code. Adds a read-only-grant test case (extra_perms value false): the row is visible under the SELECT policy but FOR UPDATE filters it under the UPDATE policy — pinning the semantics the probe relies on. All 4 draft integration tests pass. Co-Authored-By: Claude Fable 5 * chore: point ee-repo-ref at the EE branch merge (has DRAFT_KIND consts) ee-repo-ref was set to main's EE commit (d45b9a6) while the EE branch was unpushed; building OSS (which requires const DRAFT_KIND on TriggerCrud) against that EE ref fails with E0046 on every EE trigger impl. The EE branch head e936e9a — the merge of d45b9a6 into the EE remove-workspace-drafts branch, carrying the DRAFT_KIND consts — is now pushed; point at it. Co-Authored-By: Claude Fable 5 * fix(drafts): ignore permissioned_as fields in the unsaved-changes comparison The schedule cfg carries permissioned_as / preserve_permissioned_as — run-as deploy directives, not user-edited draft content — and the editor round-trips them asymmetrically (preserve_… is rebuilt as !!cfg.permissioned_as on load but `|| undefined` on build), so the banner comparison could report a phantom diff. Extract the normalization into a shared normalizeDraftForCompare (JSON round-trip + a DRAFT_COMPARE_IGNORED_FIELDS list with the two fields) and use it from BOTH comparators: draftValuesEqual (variable/resource banner + discardIf) and useTriggerDraftSync's cfgDiffers (schedule and trigger banners, the persist-effect's at-baseline discard, restore) — one ignore-list, no way for the two to disagree. Co-Authored-By: Claude Fable 5 * nit * fix(drafts): at-baseline discard is auto-gated and only fires with a draft Two related fixes to useTriggerDraftSync's persist-effect: 1. The reactive at-baseline discard bypassed the "Enable auto-save" toggle: with autosave off, value saves were parked (correct) but the discard's value:null still POSTed — so the editor never wrote drafts yet kept reactively DELETING them, and the only network traffic was discards. Thread `auto` through UserDraft.discard to the syncer; the persist-effect passes auto:true (parked for Ctrl/Cmd+S when the toggle is off), explicit discards (banner button, post-deploy cleanup, reset-to-deployed) stay ungated. 2. The discard fired unconditionally whenever the form sat at the deployed baseline — including a spurious value:null POST on every drawer open. Guard on cfgDiffers(h.draft, deployed): undefined on a fresh open (nothing to discard) and equal to deployed right after a discard (no repeat per cfg recompute). Verified live as a non-admin user on a schedule: toggle on → no POST on open, edit → one value save, revert → one discard; toggle off → zero POSTs (everything parked), banner still functional. Co-Authored-By: Claude Fable 5 * feat(drafts): scope the "Enable auto-save" toggle to the page editors Add a canBeDisabled opt (default false) to UserDraft.use / useReactive / useMany specs, threaded through acquireEntry into the reactive mirror's save opts. The syncer's auto-save gate (and the pagehide-flush skip) now only applies to saves whose handle opted in: the four full-page editors — script / flow / raw app via usePageDraftSync, app via AppEditor's use() — which are exactly the surfaces whose AutosaveIndicator carries the toggle. Drawer editors (variables / resources / schedules / triggers) keep the default and always sync regardless of the toggle — previously a toggle flipped off in some browser silently disabled their autosave and the optimistic asterisk (both sit behind the same gate) with no toggle UI anywhere on those surfaces to explain it. Verified live: schedule edit with the toggle off now POSTs the value save (and the discard on revert); script editor with the toggle off still parks everything for Ctrl/Cmd+S. Co-Authored-By: Claude Fable 5 * fix(drafts): consume the import handoff stores in the new-draft bootstrap The /add pages used to read importStore / importFlowStore / importScriptStore / sessionStorage rawAppImport to seed the editor from "Import from YAML/JSON", "Build app" (from a script/flow), and the workflows-as-code import. Since /add became a pure redirect to /{kind}/edit/u/{user}/draft_{uuid}?new_draft=true, the writers kept firing but nothing consumed the payload — every import landed in an empty editor. Consume them (one-shot read + clear) in the four edit pages' new_draft branches, layering the imported content over the empty template with path kept '' so the friendly-name generation still runs: - scripts: $importScriptStore spread over the empty script (non-empty content also keeps ScriptBuilder's template bootstrap from overwriting it — that cascade is gated on content == ''). - flows: $importFlowStore spread over the empty flow. - apps: $importStore — wrapped exports ({summary, value, policy}) and bare App values, mirroring main's /add. - raw apps: $importStore then sessionStorage rawAppImport (the full page reload for cross-origin isolation drops in-memory stores); honored only when the payload carries files (rendering gates on them), skipping the framework picker; otherwise the template seed. Verified live: "Build app" from a script lands on /apps/edit with the canvas seeded from the script instead of an empty editor. Co-Authored-By: Claude Fable 5 * chore(drafts): remove dead delete_user_draft + its stale doc [C4] The doc claimed item delete handlers call it, but those all moved to delete_all_drafts_for_path (an item delete is for everyone); the caller-scoped discard goes through the save_draft route with value:null. That left delete_user_draft with zero callers (OSS and EE) — remove it and its orphaned sqlx cache entry, and reword the contrast note on delete_all_drafts_for_path. Co-Authored-By: Claude Fable 5 * chore(migrations): retire the sync_drafts-era index comment + right-size it [C6] The draft_user_sync_idx comment described the deleted sync_drafts polling endpoint (editors polling created_at ranges every 2-10s) — that design was replaced by recordRemoteSync + save_draft last_sync, and nothing range-scans draft.created_at anymore. Since this migration only exists on this branch, fix it before it ships: the index's real consumer is GET /drafts/list (workspace_id + email equality, ORDER BY path), so swap the vestigial trailing created_at for path (rows come back in output order) and rename to draft_user_listing_idx. Chain re-verified on a fresh DB. (Byte-for-byte migration edit — dev DBs that already applied it need a reset, as with the earlier consolidation.) Co-Authored-By: Claude Fable 5 * fix(drafts): discardDraft awaits the delete POST before refetching [I5] UserDraftDbSyncer.save resolves at enqueue time for debounced saves, so discardDraft's await finished ~1.5s before the value:null POST and the invalidateWorkspaceDrafts refetch re-listed the just-discarded draft. Use immediate: true (resolves after the POST lands), matching every sibling delete-then-refetch path. Co-Authored-By: Claude Fable 5 * fix(drafts): replace stale draft_only gates in the builders [I6] draft_only was dropped from the get-by-path wire shape (the column is gone; overlays carry no_deployed instead), so these four reads were always undefined: - ScriptBuilder "Exit & See details" gate and TriggersEditor's isDeployed treated every draft-only script as deployed → now keyed on savedScript.no_deployed like the sibling reads right next to them. - FlowBuilder's deploy path never took the direct-save branch for draft-only flows (no deployed version exists to compare against), and "Exit & see details" was offered for draft-only flows (404 details page) → both now keyed on the newFlow prop (driven by no_deployed), which the rest of the file already uses. Co-Authored-By: Claude Fable 5 * fix(drafts): Ctrl/Cmd+S flushes the draft in the low-code app editor [I7] The app editor's keydown handler swallowed the shortcut with a bare preventDefault() — every other page editor flushes the pending autosave (UserDraftDbSyncer.flush) so the AutosaveIndicator narrates Saving... → Saved and parked edits (autosave toggle off) actually persist. Wire the same flush, skipped in the AI session pane where no UserDraft handle exists. Co-Authored-By: Claude Fable 5 * fix(drafts): AI tool strings no longer describe drafts as localStorage [C2] The copilot tool results/messages still told the model drafts were "saved to local storage" / "a browser-only local draft" — drafts are per-user rows in the server-side draft table now. Misleading the model about the storage medium produces wrong explanations to users (e.g. "your draft will be lost if you clear your browser data"). Reword all occurrences to "draft" / "per-user draft (saved server-side)". Co-Authored-By: Claude Fable 5 * fix(openapi): drop stale draft_only request props, fix OtherDraftUser, regen deref [D4][C5] - The create-script (NewScript), createFlow, createApp and createAppRaw request bodies still documented draft_only — the backend request structs no longer read it, so an older CLI sending draft_only: true is silently ignored and fully deploys. Remove the property from the spec so generated clients can't offer it. (Response-side draft_only on the Listable* rows stays — the list synthesis populates it.) - UserDraftOverlay.other_drafts_users item schema declared email and a required draft_saved_at; OtherDraftUser serializes only username (nullable for the legacy row — emails never leave the server). Align the schema. [C5] - Regenerate openapi-deref.yaml/.json (served at runtime via include_str!) — they still advertised getScriptByPathWithDraft and the deleted draft surface, and now carry the drafts/save_draft routes. Frontend gen client regenerated; check:fast clean. Co-Authored-By: Claude Fable 5 * fix(sessions): stop session pane from clobbering server-side raw-app drafts [P1] loadRawApp seeded the session runtime from result.value (the deployed payload), ignoring the .draft pocket returned by the get-by-path overlay. The subsequent UserDraft.save then POSTed deployed content with no last_sync recorded, silently overwriting the user's server draft. Now the no-draft branch consumes result.draft when present (matching the flow/script branches) and records draft_saved_at via recordRemoteSync so later session saves are conflict-checked instead of treated as fresh. Also corrects the header and aiDraft-branch comments that claimed the overlay merges drafts into top-level fields — it never does. Co-Authored-By: Claude Opus 4.8 * fix(rust-client): pass new get_draft arg to variable_api::get_variable getVariable gained a GetDraft query parameter (per-user draft overlay), so the generated client fn takes a sixth argument. Verified with the same generate+check pipeline CI runs (rust-client/dev.nu --check). Co-Authored-By: Claude Opus 4.8 * nit: Workspace fork mention * fix(drafts): don't leak other_drafts_users on draft-only private kinds [P2] fetch_draft_only built the other_drafts_users list unconditionally, while the deployed-overlay path gates it on shares_drafts_across_users. For the drawer kinds (resource/variable/triggers) drafts are private to their owner, so a draft-only GET was the one route that still told a viewer who else has a draft at the path. Apply the same kind gate. Co-Authored-By: Claude Opus 4.8 * perf(drafts): probe a single row in the RLS write-probe [P2] The script table keeps one row per version at the same path, so the FOR UPDATE probe locked the entire version history and serialized against concurrent deploys. LIMIT 1 locks one row — any UPDATE-policy visible row proves writability (same pattern as scripts.rs's latest-version lock). Co-Authored-By: Claude Opus 4.8 * fix(drafts): consume the /add?param= seeding intents in new_draft branches [D2] The /add routes' redirect preserves query params, but the edit pages' new_draft branches only consumed the YAML/JSON import stores — every other intent the old /add pages handled landed in a blank editor: - scripts: ?hub= and ?template= forks (with locked language and a `_fork` path suggestion), ?wac=python|typescript (WAC editor template + language), ?lang=, ?initial_args= (URL form), and the base64-JSON #hash payload (run page "Fork", workspace_settings handler-template buttons; WAC detection restored for imports too) - flows: ?hub= (preprocessor placeholder replacement + env-variables panel), ?template=/?template_id=, ?fork=true (fork_flow localStorage / window.opener handoff), #state, ?tutorial= - apps: ?hub= (fromHub inputs panel), ?template=/?template_id=, ?tutorial= The redirect itself also dropped the URL hash — SvelteKit forbids url.hash in load, so it forwards window.location.hash (correct for all hash producers: they arrive as full page loads via window.open / target=_blank). Seeding priority and toasts mirror main's /add pages. Verified live: hub/template/wac/hash/fork intents for scripts and flows, hub for apps (dev hub returns empty payloads, code path confirmed via toast + inputs panel); no autosave POSTs fire during seeding. Co-Authored-By: Claude Opus 4.8 * fix(drafts): LS→DB migration no longer clobbers fresher server drafts [P2] The one-off localStorage migration POSTed every entry with force: true, unconditionally overwriting whatever the user had since saved server-side from another browser. It now passes the LS copy's lastWrittenAt as last_sync (epoch 0 when absent), so the server's conflict rule arbitrates: empty slot → insert; server draft fresher → conflict, LS copy dropped; LS copy fresher → upload wins. Verified all three outcomes against the live save_draft endpoint. Co-Authored-By: Claude Opus 4.8 * refactor(raw_apps): drop banned $bindable(default) on template picker open [P2] `open = $bindable(false)` on an optional prop is the AGENTS.md-banned pattern (the default masks the undefined state). The only caller always binds a boolean, so `open` is now a required prop with a plain `$bindable()`. Co-Authored-By: Claude Opus 4.8 * fix(drafts): fork others' drafts via the import handoff, not an eager save The Fork actions (OtherUsersDraftsModal + DraftBadge popover) saved the fetched draft server-side immediately and navigated to the fork path, which surfaced three problems: a server draft existed before the user edited anything, the Path widget treated the slot as an existing item ("Only the owner can change the path"), and the value's draft_path kept the source path while the URL said X_owner_fork. Forking now routes through the same one-shot import handoff as the "Import from YAML/JSON" actions (new shared forkDraftToImport helper): stash the value in the kind's import store, navigate to /add, and let the new_draft branch seed a brand-new own item — nothing saved until the first real edit, fresh renamable path, no source identity riding along. The editPathFor/currentUserUsername plumbing that only served the old flow is removed from both fork surfaces and their callers. The new_draft branches also clear the previous path's draft-presence state (otherDraftsUsers, loadedFromDraft, stale-draft timestamps) — the page component is reused across same-route navigation, so forking from an editor with collaborators used to carry the "Others are working on this" hint onto the fresh draft. Verified live: fork of a legacy draft seeds content+summary on a fresh u/{user}/draft_{uuid} slot with zero save_draft requests and no leftover collaborator hints. Co-Authored-By: Claude Opus 4.8 * refactor(drafts): replace deprecated Popover with meltComponents Popover - Migrate from old Popover.svelte to meltComponents/Popover.svelte - Convert to new trigger/content snippet pattern with openOnHover=true - Maintain hover behavior with debounceDelay=100 - Add key to visibleUsers each block for Svelte 5 compliance * feat(drafts): seed forked drafts with the source path in the forker's namespace Forking u/admin/myflow as guest now seeds the Path widget with u/guest/myflow instead of a random friendly name — everything after the source path's first two segments is kept, so f/folder/my/flow becomes u/guest/my/flow. The re-homed path travels from forkDraftToImport to the new_draft branches as a ?seed_path= param (the redirect preserves query params; plain ?path= would be eaten in transit by ScriptBuilder's legacy collab-param cleanup, which deletes path/collab from the live searchParams object). The script editor also passes initialPathChosen for any seeded path — MetadataGen fires onChange for a non-empty summary at mount, and the summary→path auto-slug would otherwise overwrite the explicit seed (hub/template forks and URL-hash payloads included). Verified live: forking a draft on u/admin/hard_working_script seeds path u/admin/hard_working_script (with the "path already used" warning), keeps the drafted summary/content, and still fires no save_draft until the first edit. Co-Authored-By: Claude Opus 4.8 * fix(drafts): DiffDrawer "Restore deployed" actually discards the draft [P1] All four restoreDeployed implementations POSTed the delete through the debounced pipeline and reloaded with getDraft defaulting to true: the reload's draft write re-entered the autosave mirror (the one-shot seed guard was consumed on first load), and debouncerByKey displaced the queued value:null with the new save — the delete never reached the server and the editor re-rendered the draft it was told to discard. They now funnel through runResetToDeployed (the stopSync-bracketed delete the AutosaveIndicator reset already uses) with each page's proven reset body (getDraft: false reload), so the suspension mutes the mirror while the delete flushes and sync re-arms on first interaction. Also fixes the raw-app drawer navigating to the visual app editor (/apps/edit) instead of /apps_raw/edit [P2]. Verified live on the script editor: Restore deployed issues exactly one save_draft ({value:null} answered status=saved), the server row is gone, and the editor re-renders the deployed content. Co-Authored-By: Claude Opus 4.8 * fix(drafts): deploying a draft-only item reliably deletes its draft Two bugs left the slot draft (u/{user}/draft_{uuid}) alive after a successful deploy: - RawAppEditorHeader.createApp removed the draft at the just-typed deploy path instead of the URL slot key (the visual header documents exactly this trap), orphaning the real row for every draft-only raw-app deploy. - Everywhere else the delete went through bare UserDraft.remove, which only QUEUES the value:null in the per-key debouncer. Editors that stay mounted through the post-deploy navigation (AppEditor, RawAppEditor — and timing-dependently the script/flow builders' post-deploy draft_triggers mirror) keep mirroring their working value, and one such write displaces the queued delete with a fresh save — observed live: deploying a new visual app re-saved the full grid value at the slot right after deploy. New discardDraftAfterDeploy helper (userDraftToast.ts) applies the same bracket runResetToDeployed uses: stopSync to mute the mirror, remove + immediate flush so the displacement window closes, re-arm on first interaction. Wired into the script/flow pages' onDeploy and both app headers' create/update paths (session-pane guards preserved). Verified live for all three kinds: draft-only deploy issues the value:null (status saved), the slot row is gone, and no post-deploy save re-creates it. Co-Authored-By: Claude Opus 4.8 * fix(drafts): forks-compare deploy clears drawer-kind drafts too The script/flow/app deploy endpoints delete the deployer's draft server-side, but the drawer kinds' (variable / resource / schedule / triggers) create/update endpoints never touch the draft table — their editors discard client-side after a save. deployDraft replayed the save but not the discard, so "Deploy n drafts" on /forks/compare deployed those kinds correctly and left the drafts listed forever. deployDraft now issues the canonical value:null delete (immediate) for the drawer kinds after a successful save. Verified live: deploying a draft-only variable from /forks/compare creates the variable and the draft row is gone. Co-Authored-By: Claude Opus 4.8 * fix(drafts): StaleDraftModal "Load latest deploy" actually discards the stale draft [P2] The modal invoked onLoadLatestDeploy directly — the draft = undefined write queued the delete and the reload's deployed-payload write displaced it, overwriting the stale draft with a deployed-identical copy (is_draft stuck on, asterisk persists, modal can't re-fire since draft_saved_at moved past the deploy). All four pages now run the callback through runResetToDeployed, same as the DiffDrawer restore. Verified live: stale-draft scenario → Load latest deploy → exactly one value:null POST, draft row gone, editor renders the newer deploy. Co-Authored-By: Claude Opus 4.8 * fix(drafts): don't acquire a sync entry for empty-path specs [P2] The read-only historical-hash view (/scripts/edit/x?hash=...) computes draftPath '' but useMany still acquired a live entry at ws/script/ — every edit mirror-POSTed to /drafts/save_draft/script/ (unroutable), populating the failures map and pinning the AutosaveIndicator on "Save failed" with a retry per debounce window. Empty-path specs now get a detached local-only handle: bind: works, nothing syncs — which is what usePageDraftSync's doc always claimed. Verified live: editing in the hash view fires zero save_draft requests. Co-Authored-By: Claude Opus 4.8 * fix(drafts): no spurious conflict after bfcache restore of a flushed page [P2] flushOnPageHide advances the server rows with unreadable keepalive POSTs and leaves lastSyncMap stale — correct when the document dies, wrong when bfcache resurrects it: the next autosave carried the pre-flush last_sync and the server rejected the user's own write as a conflict, opening DraftSyncConflictModal. The flushed keys are now remembered and dropped from lastSyncMap on pageshow with event.persisted, so the first post-restore save takes first-push semantics against this document's own flush. Co-Authored-By: Claude Opus 4.8 * ui(drafts): draft asterisk sits on the trigger row's main title The draft hint rendered at the end of the secondary path line (u/admin/item*) on the http/websocket/nats/kafka/email trigger lists — easy to miss. It now renders at the end of the row's bold title, and on the azure/gcp lists it moves from mid-title (after the path, before the topic suffix) to the end of the line. mqtt/postgres/sqs/schedules already had it on the title. Verified visually on the HTTP routes list. Co-Authored-By: Claude Opus 4.8 * fix(drafts): trigger editors save the FIRST edit, not the second Three interlocking fixes in the trigger autosave path: - The entry's one-shot first-write seed guard (skipNextWrite) was never consumed for trigger entries: the drawers don't write the cell on open (the form holds the state, unlike variables/resources which pass a defaultValue), so the guard stayed armed and silently swallowed the user's FIRST edit — banner on, no asterisk, no save until a second change. maybeRestore now seeds the cell with the post-load baseline (server draft overlay if any, deployed otherwise) via UserDraft.seed, consuming the guard without POSTing. - Guard hygiene in the cell's sync effect: a programmatic write consumes BOTH one-shot guards, and a no-op write (same serialization — e.g. the trigger pages fire openEdit twice per row click, re-seeding the same value) defuses a lingering seedNextWrite instead of leaving it armed to eat the next real edit. - The at-baseline auto-discard is now deferred + revalidated (600ms): with the cell seeded, the double-openEdit churn transiently shows form-at-deployed + cell-holds-draft and an immediate discard deleted the server draft on open; the recheck skips the transient state while a genuine user revert still discards. Verified live on the HTTP route editor: open-with-draft restores the draft with zero POSTs, the very first field edit saves, and reverting the form to the deployed value deletes the server draft. Co-Authored-By: Claude Opus 4.8 * ui(drafts): underscore-separated uuids in draft slot paths u/{user}/draft_{uuid} now uses underscores instead of dashes in the uuid — path segments elsewhere in Windmill are [a-zA-Z0-9_] words and downstream consumers treat '-' as a foreign character. Nothing parses the uuid back, so existing dashed slots stay valid. Co-Authored-By: Claude Opus 4.8 * fix(drafts): cascade draft cleanup on bulk-delete and rename Drafts have no SQL FK to their underlying items (only to password.email), so deletion and rename must cascade programmatically. Two gaps remained: - Bulk delete of variables/resources did not wipe per-user drafts at the deleted paths (single delete already did via delete_all_drafts_for_path). Cascade them — including the linked resource/variable rows the bulk delete fans into — so no orphaned draft-only rows survive. - Renaming a variable/resource/trigger left the per-user draft stranded at the old path. Add delete_own_draft_for_path and clear the deployer's own (+ legacy NULL) draft at the old path on rename, mirroring the script/flow/app rename path; teammates keep theirs (StaleDraftModal). Variable/resource renames also move the linked counterpart, so both kinds' drafts at the old path are cleared. Schedules have no rename path. Note: sqlx offline cache not yet regenerated for the new/changed queries. Co-Authored-By: Claude Opus 4.8 * fix(drafts): surface legacy NULL-email drafts and migrate pathless /add keys Legacy workspace-scoped drafts (pre-per-user rows + the remove_draft_only migration, all email IS NULL) stopped showing up because every per-user lookup matched only email = self. Match (email = self OR email IS NULL) everywhere a draft is surfaced or opened, with the owned row taking precedence (DISTINCT ON / ORDER BY email NULLS LAST): the home drafts list, the script/flow/app/drawer draft-only list syntheses, and the get-by-path overlay/fallback. The localStorage->DB migration also dropped pathless legacy keys (userdraft/w/{ws}/{kind}/ with no path) — the new-item /add autosave — because parseKey rejected an empty path, leaving them stranded in LS. Mint a fresh u/{user}/draft_{uuid} slot for those (same convention as the editors' /add redirects) so they migrate as regular draft-only items. Note: sqlx offline cache not yet regenerated for the changed macros. Co-Authored-By: Claude Opus 4.8 * chore(sqlx): regenerate offline cache for draft cascade + legacy-draft queries Adds the offline entries for the queries changed in the two preceding draft fixes (delete_own_draft_for_path, the maybe_overlay_draft/fetch_draft_only NULL-email fallback, and the script/flow/app draft-only syntheses). Also forwards the `http_trigger` feature from windmill-api-openapi to windmill-store: that crate imports `try_get_resource_from_db_as` unconditionally, but the fn is cfg-gated behind a trigger feature, so the openapi targets failed to compile in isolation (e.g. `--all-targets` under resolver 2) — which blocked `cargo sqlx prepare`. The feature was already present transitively in whole-workspace builds; this just makes it explicit where the symbol is used. Co-Authored-By: Claude Opus 4.8 * fix(drafts): resolve own draft owner in the admins workspace The draft-owner surfaces (home-page badge, "others' drafts", View JSON / Fork) resolve a draft's email to a username via the `usr` table. The `admins` workspace has no `usr` rows — there a user's "username" IS their email — so the join missed every owner and returned NULL, which the badge renders as "Legacy workspace draft". A user editing a deployed item in `admins` thus saw their OWN draft plus the genuine legacy NULL-email row both labelled "Legacy workspace draft" (the reported duplicate). Add the identity fallback `COALESCE(u.username, CASE WHEN workspace_id = 'admins' THEN email END)` to the script/flow/app draft_users aggregations and fetch_other_drafts_users, and accept username==email in get_draft_for_user. The genuine legacy row keeps username NULL (its email is NULL, so the CASE yields NULL too), so it alone reads "Legacy workspace draft" while the user's own draft now reads " (you)". Co-Authored-By: Claude Opus 4.8 * fix(drafts): keep "See others' drafts" after reset-to-deployed other_drafts_users is only computed by the backend when getDraft is true (the cross-user lookup is skipped otherwise). Reset-to-deployed reloads with getDraft:false, so the editors were overwriting the known list with the empty response — hiding the "See others' drafts" button until a full page reload recomputed it. Discarding one's own draft is independent of other users' drafts, which are untouched on the backend. Only assign otherDraftsUsers on a getDraft:true load. Applied to the script, flow, app and raw-app editors. Co-Authored-By: Claude Opus 4.8 * disable fork for operators * Path reactivity issue * docs(drafts): tighten draft-feature comments and drop dead code The draft feature accumulated many multi-paragraph comments that risked code-comment drift. Compact them to the AGENTS.md bar (constraints not narration, state-once, no drafting-history), de-duplicating the repeated draft_users / cascade / draft_only-synthesis rationale to one canonical version per theme with terse cross-references elsewhere (~1300 fewer lines). Also fixes three stale/contradictory comments surfaced while trimming: - the operator authz note claimed operators are "excluded from every draft surface", contradicting require_can_read_path (they can read some drafts, never write) — reworded to match the code; - a migration comment named a non-existent index (draft_user_sync_idx); - a syncer comment documented the wrong map-key separator. Removes notifyDraftLoaded (orphaned exported helper, no callers). Co-Authored-By: Claude Opus 4.8 * refactor(drafts): rename save_draft route to /update for CRUD consistency The draft write route was POST /drafts/save_draft/{kind}/{path}, which stutters with the /drafts prefix and uses a non-house verb. Rename it to POST /drafts/update/{kind}/{path} (operationId saveDraft -> updateDraft) to match the codebase's CRUD convention (/list, /get/{path}, /update/{path}). /list and /get/{kind}/{path} already matched and are unchanged. Updates the handler, openapi spec + dereferenced bundles, the two DraftService callers, the hand-built keepalive page-unload URL (it bypasses the generated client, so it wouldn't be caught by regeneration), and the integration tests. Response status values ("saved"/"conflict") are unchanged, so there is no behavior change. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: windmill-internal-app[bot] Co-authored-by: Claude Opus 4.7 --- ...57336378f9e85baf14d1dc34ed7e9b42e5e72.json | 16 + ...6f48e4330af7fbe61426e999c6073f630d88f.json | 41 + ...71052c112e6bbb3cf834f16176cbb7e1ac319.json | 4 +- ...d82c85129e2407819477adf7e87f097276ae2.json | 16 + ...ef6feafc448e70c30ba7e7c835f5e3167f3a5.json | 64 + ...885dac4de6171463ac0d1c45909da9f91b3a4.json | 59 + ...ff37dcc4c34f800ff4f385ec9f60933f83efe.json | 15 + ...1b027c022fc034dc378a071c046dc64571ac6.json | 15 + ...1f924bd4483c8f70095c05720bc4eb2d8df81.json | 16 + ...324d753cb2b1fac69ceb738df4c3d2a6270a5.json | 35 + ...d8e9353bc083724fa5e4530fd715fb237dc0c.json | 57 + ...156a66727e7a469abad1664ee03a3da31078a.json | 35 + ...b26e3ef9171f478e7ab3bd68eb09663b393c4.json | 35 + ...6e490d5dfcdd7a94dcbe5d05c9e5af37ac4a4.json | 49 + ...bfda0fb4434407b5e1ba4ffb8a770718d9c5d.json | 57 + ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- ...41a82b26105c1bf7833c6e5d6178e65974067.json | 125 + ...e07f0965efb65c51580c529e61d09b8742dfd.json | 99 + ...5fcfb1fdb296ea8893312d7d72ac80e4f506e.json | 16 + ...b0128c02bc44461ccc9cd9fb29de567762f63.json | 41 + ...4c4b467ff43d84618a3471eb7b1cfec13883d.json | 24 + ...88006066c83a2697913e43dc3c3db4bd4bd2e.json | 12 + ...23823945e84997b038d5b7915406c4ccb302d.json | 15 + ...cf4386eb97e4e00c7431ff2860b225d212780.json | 16 + ...da55f57b4de71db778642ed72654bf47d26a5.json | 20 + ...c749524f1db10a51f3de6cadd4826e6c1d422.json | 48 + ...4f0707c2243f085bc3fc442065fb4391330df.json | 16 + ...c2aa44d714bcc95e242ef329f6fd71c345c1.json} | 16 +- ...6e78437a5e4d3b5e2341ec5f7725a28870270.json | 2 +- ...4267d67f58f9cbff0d164b4df45de885b0e9d.json | 63 + ...df39178f5111119d50f2975dd1180502c0c52.json | 35 + ...89c59bd6c6f7bce52aa3707e923b32d9be3e2.json | 27 + ...cde48c5517ab36acd543a1f0a6119c34ce453.json | 16 + ...ea03e4a1d1edb4470812cd7dd57c657fa287a.json | 28 + ...f330cd3c8ef1ca692147210a36e56946f7ef6.json | 15 + ...5e59e5edda1faea72f39603814afb6a3cd596.json | 63 + ...86f0ecfcf63daa0b185994a410eb2fe41fad9.json | 23 + ...b7e3239773fa3e6352aef7e98467ad07f337d.json | 27 + ...5057b1c974a9a947bda133c155503dac1f545.json | 60 + ...acd868f9101250b0599fc66798a7852c7cdf5.json | 26 + ...e6697e20385b9a1f222382d7bf9e540b0b9aa.json | 63 + ...d86af3e3f0b9ab326e4ea89267514ecc66f71.json | 22 + ...f271cc64e5a3c9901c2e2f806ab3acbb4aa75.json | 57 + ...4cbece8a8206a678271218c4d486a65818745.json | 16 + ...49dd75b5f8ed859e410d2322ec3f565382fa8.json | 35 + ...c6536f9e2404002ba36046691aab955caf300.json | 22 + backend/Cargo.lock | 1 + backend/ee-repo-ref.txt | 2 +- ...0528143710_draft_user_sync_schema.down.sql | 25 + ...260528143710_draft_user_sync_schema.up.sql | 82 + .../20260609165313_remove_draft_only.down.sql | 5 + .../20260609165313_remove_draft_only.up.sql | 99 + backend/tests/ci_tests.rs | 2 +- backend/tests/dependency_map.rs | 3 +- backend/tests/relock_skip.rs | 2 +- backend/tests/script_auto_kind_failure.rs | 2 +- backend/tests/trigger_listener_queries.rs | 2 +- backend/tests/worker.rs | 2 +- backend/tests/workspace_export.rs | 2 +- backend/windmill-api-flows/src/flows.rs | 264 +- .../tests/apps.rs | 6 - .../tests/drafts.rs | 358 +- .../tests/flows.rs | 6 - .../tests/scripts.rs | 6 - .../tests/workspace_comparison.rs | 4 +- backend/windmill-api-openapi/Cargo.toml | 7 +- backend/windmill-api-schedule/src/lib.rs | 137 +- backend/windmill-api-scripts/src/scripts.rs | 375 +- .../windmill-api-workspaces/src/workspaces.rs | 71 +- .../src/workspaces_extra.rs | 4 +- backend/windmill-api/openapi-deref.json | 1059 ++- backend/windmill-api/openapi-deref.yaml | 7648 ++++++++++------- backend/windmill-api/openapi.yaml | 594 +- backend/windmill-api/src/apps.rs | 278 +- backend/windmill-api/src/drafts.rs | 563 +- backend/windmill-api/src/lib.rs | 4 +- backend/windmill-api/src/mcp/utils.rs | 4 +- backend/windmill-api/src/offboarding.rs | 4 +- backend/windmill-api/src/path_autocomplete.rs | 4 +- backend/windmill-api/src/users.rs | 4 +- backend/windmill-api/src/utils.rs | 1 - backend/windmill-api/src/workspaces_export.rs | 29 +- backend/windmill-common/Cargo.toml | 1 + backend/windmill-common/src/lib.rs | 1 + backend/windmill-common/src/scripts.rs | 6 +- backend/windmill-common/src/user_drafts.rs | 536 ++ backend/windmill-common/src/variables.rs | 13 + backend/windmill-store/src/resources.rs | 188 +- backend/windmill-store/src/variables.rs | 191 +- backend/windmill-test-utils/src/lib.rs | 4 +- .../windmill-trigger-azure/src/handler_oss.rs | 1 + .../windmill-trigger-email/src/handler_oss.rs | 1 + .../windmill-trigger-gcp/src/handler_oss.rs | 1 + backend/windmill-trigger-http/src/handler.rs | 1 + .../windmill-trigger-kafka/src/handler_oss.rs | 1 + backend/windmill-trigger-mqtt/src/handler.rs | 1 + .../windmill-trigger-nats/src/handler_oss.rs | 1 + .../windmill-trigger-postgres/src/handler.rs | 1 + .../windmill-trigger-sqs/src/handler_oss.rs | 1 + .../windmill-trigger-websocket/src/handler.rs | 1 + backend/windmill-trigger/src/handler.rs | 143 +- backend/windmill-trigger/src/types.rs | 23 + backend/windmill-types/src/flows.rs | 21 +- backend/windmill-types/src/lib.rs | 2 + backend/windmill-types/src/s3.rs | 23 +- backend/windmill-types/src/scripts.rs | 28 +- backend/windmill-types/src/user_drafts.rs | 18 + cli/src/guidance/skills.gen.ts | 216 + frontend/src/lib/coalescingRunner.svelte.ts | 118 + frontend/src/lib/coalescingRunner.test.ts | 130 + .../lib/components/AutosaveIndicator.svelte | 339 + .../src/lib/components/CompareDrafts.svelte | 112 +- .../lib/components/ContentSearchInner.svelte | 6 +- frontend/src/lib/components/DiffDrawer.svelte | 2 +- frontend/src/lib/components/DraftBadge.svelte | 291 +- frontend/src/lib/components/Editor.svelte | 63 +- .../lib/components/EncryptedDraftField.svelte | 48 + .../src/lib/components/FlowBuilder.svelte | 260 +- .../lib/components/LocalDraftBanner.svelte | 37 +- frontend/src/lib/components/Path.svelte | 19 + .../src/lib/components/ResourceEditor.svelte | 148 +- .../src/lib/components/ScriptBuilder.svelte | 312 +- .../src/lib/components/SimpleEditor.svelte | 6 + .../src/lib/components/VariableEditor.svelte | 143 +- .../src/lib/components/VariableForm.svelte | 132 +- .../components/WorkspaceDraftsBanner.svelte | 2 +- .../components/apps/editor/AppEditor.svelte | 87 +- .../apps/editor/AppEditorHeader.svelte | 360 +- .../apps/editor/AppEditorHeaderDeploy.svelte | 24 +- .../AppEditorHeaderDeployInitialDraft.svelte | 55 - .../apps/editor/AppEditorTutorial.svelte | 2 +- .../apps/editor/AppJsonEditor.svelte | 100 +- frontend/src/lib/components/apps/types.ts | 31 +- .../DraftEditorModals.svelte | 102 + .../DraftSyncConflictModal.svelte | 92 + .../LocalDraftStaleModal.svelte | 125 - .../OtherUsersDraftsModal.svelte | 169 + .../confirmationModal/StaleDraftModal.svelte | 86 + .../UnsavedConfirmationModal.svelte | 36 - .../lib/components/common/modal/Modal2.svelte | 30 +- .../lib/components/common/table/AppRow.svelte | 78 +- .../components/common/table/FlowRow.svelte | 69 +- .../components/common/table/ScriptRow.svelte | 65 +- .../copilot/chat/global/core.test.ts | 175 +- .../components/copilot/chat/global/core.ts | 174 +- .../copilot/chat/global/userDraftAdapter.ts | 16 +- frontend/src/lib/components/diff_drawer.ts | 42 +- frontend/src/lib/components/flow_builder.ts | 27 +- .../components/flows/CreateActionsApp.svelte | 8 +- .../components/flows/CreateActionsFlow.svelte | 12 +- .../flows/content/FlowEditorDrawer.svelte | 21 +- .../src/lib/components/forkDraftToImport.ts | 60 + .../src/lib/components/home/ItemsList.svelte | 7 +- .../src/lib/components/home/treeViewUtils.ts | 1 - .../components/raw_apps/RawAppEditor.svelte | 70 +- .../raw_apps/RawAppEditorHeader.svelte | 354 +- .../raw_apps/RawAppTemplatePicker.svelte | 399 + frontend/src/lib/components/script_builder.ts | 37 +- .../scripts/CreateActionsScript.svelte | 2 +- .../search/GlobalSearchModal.svelte | 1 - .../components/sessions/FlowEditorView.svelte | 5 - .../sessions/RawAppEditorView.svelte | 5 - .../sessions/ScriptEditorView.svelte | 55 +- .../sessions/sessionRuntime.svelte.ts | 111 +- .../azure/AzureTriggerEditorInner.svelte | 29 +- .../email/EmailTriggerEditorInner.svelte | 46 +- .../triggers/gcp/GcpTriggerEditorInner.svelte | 44 +- .../triggers/http/RouteEditorInner.svelte | 38 +- .../kafka/KafkaTriggerEditorInner.svelte | 35 +- .../mqtt/MqttTriggerEditorInner.svelte | 36 +- .../nats/NatsTriggerEditorInner.svelte | 49 +- .../PostgresTriggerEditorInner.svelte | 64 +- .../schedules/ScheduleEditorInner.svelte | 64 +- .../triggers/sqs/SqsTriggerEditorInner.svelte | 43 +- .../triggers/useTriggerDraftSync.svelte.ts | 98 +- frontend/src/lib/components/triggers/utils.ts | 67 +- .../WebsocketTriggerEditorInner.svelte | 35 +- .../lib/components/usePageDraftSync.svelte.ts | 105 + frontend/src/lib/debouncerByKey.svelte.ts | 88 + frontend/src/lib/debouncerByKey.test.ts | 87 + frontend/src/lib/draftAddRedirect.ts | 32 + frontend/src/lib/encryptedDraft.ts | 10 + frontend/src/lib/localDraftHints.svelte.ts | 41 + frontend/src/lib/rawAppDeploy.ts | 4 +- frontend/src/lib/svelte5Utils.svelte.ts | 34 +- frontend/src/lib/tutorials/config.ts | 8 +- frontend/src/lib/userDraft.svelte.ts | 946 +- frontend/src/lib/userDraft.test.ts | 1145 --- frontend/src/lib/userDraftDbMigration.ts | 220 + frontend/src/lib/userDraftDbSyncer.svelte.ts | 475 + frontend/src/lib/userDraftToast.ts | 108 +- frontend/src/lib/userNamespace.ts | 31 + frontend/src/lib/utils/editInFork.ts | 6 +- frontend/src/lib/utils_draft_deploy.ts | 355 +- frontend/src/lib/workspaceDrafts.svelte.ts | 82 +- .../src/routes/(root)/(logged)/+layout.svelte | 35 +- .../routes/(root)/(logged)/apps/add/+page.js | 5 - .../(root)/(logged)/apps/add/+page.svelte | 160 +- .../routes/(root)/(logged)/apps/add/+page.ts | 6 + .../(logged)/apps/edit/[...path]/+page.svelte | 508 +- .../(logged)/apps/get/[...path]/+page.svelte | 2 +- .../(root)/(logged)/apps_raw/add/+page.js | 5 - .../(root)/(logged)/apps_raw/add/+page.svelte | 693 +- .../(root)/(logged)/apps_raw/add/+page.ts | 6 + .../apps_raw/edit/[...path]/+page.svelte | 539 +- .../apps_raw/get/[...path]/+page.svelte | 2 +- .../(logged)/azure_triggers/+page.svelte | 11 +- .../(logged)/email_triggers/+page.svelte | 11 +- .../routes/(root)/(logged)/flows/add/+page.js | 5 - .../(root)/(logged)/flows/add/+page.svelte | 223 +- .../routes/(root)/(logged)/flows/add/+page.ts | 6 + .../flows/edit/[...path]/+page.svelte | 567 +- .../(logged)/flows/get/[...path]/+page.svelte | 4 +- .../(root)/(logged)/gcp_triggers/+page.svelte | 11 +- .../(logged)/kafka_triggers/+page.svelte | 11 +- .../(logged)/mqtt_triggers/+page.svelte | 11 +- .../(logged)/nats_triggers/+page.svelte | 11 +- .../(logged)/postgres_triggers/+page.svelte | 11 +- .../(root)/(logged)/resources/+page.svelte | 18 +- .../(root)/(logged)/routes/+page.svelte | 22 +- .../(root)/(logged)/run/[...run]/+page.svelte | 2 +- .../(root)/(logged)/schedules/+page.svelte | 18 +- .../(root)/(logged)/scripts/add/+page.js | 5 - .../(root)/(logged)/scripts/add/+page.svelte | 307 +- .../(root)/(logged)/scripts/add/+page.ts | 6 + .../scripts/edit/[...path]/+page.svelte | 681 +- .../scripts/get/[...hash]/+page.svelte | 2 +- .../(root)/(logged)/sqs_triggers/+page.svelte | 11 +- .../svix/create-webhook/+page@(root).svelte | 2 +- .../(root)/(logged)/variables/+page.svelte | 18 +- .../(logged)/websocket_triggers/+page.svelte | 11 +- rust-client/src/client.rs | 12 +- .../schemas/azure_trigger.schema.yaml | 20 + .../schemas/email_trigger.schema.yaml | 20 + .../schemas/gcp_trigger.schema.yaml | 20 + .../schemas/http_trigger.schema.yaml | 20 + .../schemas/kafka_trigger.schema.yaml | 20 + .../schemas/mqtt_trigger.schema.yaml | 20 + .../schemas/nats_trigger.schema.yaml | 20 + .../schemas/postgres_trigger.schema.yaml | 20 + .../schemas/schedule.schema.yaml | 16 + .../schemas/sqs_trigger.schema.yaml | 20 + .../schemas/websocket_trigger.schema.yaml | 20 + 243 files changed, 17514 insertions(+), 10993 deletions(-) create mode 100644 backend/.sqlx/query-03547bf921bbd4342dc8604277057336378f9e85baf14d1dc34ed7e9b42e5e72.json create mode 100644 backend/.sqlx/query-1860c102a5309a8e1bb7288d0616f48e4330af7fbe61426e999c6073f630d88f.json create mode 100644 backend/.sqlx/query-1f486036b2902a0ffa0ce15f665d82c85129e2407819477adf7e87f097276ae2.json create mode 100644 backend/.sqlx/query-26fb727ef7d50c93c1c8ce33356ef6feafc448e70c30ba7e7c835f5e3167f3a5.json create mode 100644 backend/.sqlx/query-2cb84c274a3e8f7c6ec91c5d86b885dac4de6171463ac0d1c45909da9f91b3a4.json create mode 100644 backend/.sqlx/query-355e3976f0a7e3ccbb24314f951ff37dcc4c34f800ff4f385ec9f60933f83efe.json create mode 100644 backend/.sqlx/query-388b6af24fc77341abf7088303f1b027c022fc034dc378a071c046dc64571ac6.json create mode 100644 backend/.sqlx/query-3d7456e11d8686210169bfe26931f924bd4483c8f70095c05720bc4eb2d8df81.json create mode 100644 backend/.sqlx/query-3e51a3f545f67fb0f62956fd809324d753cb2b1fac69ceb738df4c3d2a6270a5.json create mode 100644 backend/.sqlx/query-4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c.json create mode 100644 backend/.sqlx/query-5223412e366b22d953867425ed6156a66727e7a469abad1664ee03a3da31078a.json create mode 100644 backend/.sqlx/query-56741ef2a510917f6c460968117b26e3ef9171f478e7ab3bd68eb09663b393c4.json create mode 100644 backend/.sqlx/query-5884ce1906015f6b96231f311226e490d5dfcdd7a94dcbe5d05c9e5af37ac4a4.json create mode 100644 backend/.sqlx/query-590c4429347925395794b061943bfda0fb4434407b5e1ba4ffb8a770718d9c5d.json create mode 100644 backend/.sqlx/query-665de8d7956cf034d2d009e6e3241a82b26105c1bf7833c6e5d6178e65974067.json create mode 100644 backend/.sqlx/query-66c775b6e1120c5ed53b903d252e07f0965efb65c51580c529e61d09b8742dfd.json create mode 100644 backend/.sqlx/query-67e092189a1a7be4fc3b660dc7a5fcfb1fdb296ea8893312d7d72ac80e4f506e.json create mode 100644 backend/.sqlx/query-684e025167e097e60a3fc436ec8b0128c02bc44461ccc9cd9fb29de567762f63.json create mode 100644 backend/.sqlx/query-68b4a667ac15258b718e7c9c0224c4b467ff43d84618a3471eb7b1cfec13883d.json create mode 100644 backend/.sqlx/query-6e4afbd2931a2d96b99b88a270388006066c83a2697913e43dc3c3db4bd4bd2e.json create mode 100644 backend/.sqlx/query-7753786abd0b9c7b548d0f8157023823945e84997b038d5b7915406c4ccb302d.json create mode 100644 backend/.sqlx/query-8bba7bec4f5f09a90da3eecaab4cf4386eb97e4e00c7431ff2860b225d212780.json create mode 100644 backend/.sqlx/query-8be291d84471ff742a3c2a9d53cda55f57b4de71db778642ed72654bf47d26a5.json create mode 100644 backend/.sqlx/query-8f163ee5adf4caaaa12a5698e68c749524f1db10a51f3de6cadd4826e6c1d422.json create mode 100644 backend/.sqlx/query-90068339285f3802a975750274d4f0707c2243f085bc3fc442065fb4391330df.json rename backend/.sqlx/{query-52b07f3ef0aa3ac4f6d6be0db60c732e396a79458489c564bbf2e4a28de06f1f.json => query-9b88afcbecd66e5e29658463a2b8c2aa44d714bcc95e242ef329f6fd71c345c1.json} (76%) create mode 100644 backend/.sqlx/query-b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d.json create mode 100644 backend/.sqlx/query-bf945c34c18a9fe5f0af8f54042df39178f5111119d50f2975dd1180502c0c52.json create mode 100644 backend/.sqlx/query-c03260b15cc3efd932312aebf4989c59bd6c6f7bce52aa3707e923b32d9be3e2.json create mode 100644 backend/.sqlx/query-c241ee7efe2cbb9024792f6dc67cde48c5517ab36acd543a1f0a6119c34ce453.json create mode 100644 backend/.sqlx/query-c61977907414d2336030fbab114ea03e4a1d1edb4470812cd7dd57c657fa287a.json create mode 100644 backend/.sqlx/query-cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6.json create mode 100644 backend/.sqlx/query-d4e0da9f9653d532770066310f85e59e5edda1faea72f39603814afb6a3cd596.json create mode 100644 backend/.sqlx/query-dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9.json create mode 100644 backend/.sqlx/query-ddcc3f07ff87b9ea47a9428ba0bb7e3239773fa3e6352aef7e98467ad07f337d.json create mode 100644 backend/.sqlx/query-e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545.json create mode 100644 backend/.sqlx/query-eb6f237cdaea4581cd839c353d8acd868f9101250b0599fc66798a7852c7cdf5.json create mode 100644 backend/.sqlx/query-ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa.json create mode 100644 backend/.sqlx/query-ed8351ccac2df2a4bd327383003d86af3e3f0b9ab326e4ea89267514ecc66f71.json create mode 100644 backend/.sqlx/query-ee783aeeb2eba7446995ca8467ff271cc64e5a3c9901c2e2f806ab3acbb4aa75.json create mode 100644 backend/.sqlx/query-f57c70d1f6756d8df09d3c26f6e4cbece8a8206a678271218c4d486a65818745.json create mode 100644 backend/.sqlx/query-fac99e27c8396185dd21c33baf649dd75b5f8ed859e410d2322ec3f565382fa8.json create mode 100644 backend/.sqlx/query-fea3dd1c119e859290cc875cfabc6536f9e2404002ba36046691aab955caf300.json create mode 100644 backend/migrations/20260528143710_draft_user_sync_schema.down.sql create mode 100644 backend/migrations/20260528143710_draft_user_sync_schema.up.sql create mode 100644 backend/migrations/20260609165313_remove_draft_only.down.sql create mode 100644 backend/migrations/20260609165313_remove_draft_only.up.sql create mode 100644 backend/windmill-common/src/user_drafts.rs create mode 100644 backend/windmill-types/src/user_drafts.rs create mode 100644 frontend/src/lib/coalescingRunner.svelte.ts create mode 100644 frontend/src/lib/coalescingRunner.test.ts create mode 100644 frontend/src/lib/components/AutosaveIndicator.svelte create mode 100644 frontend/src/lib/components/EncryptedDraftField.svelte delete mode 100644 frontend/src/lib/components/apps/editor/AppEditorHeaderDeployInitialDraft.svelte create mode 100644 frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte create mode 100644 frontend/src/lib/components/common/confirmationModal/DraftSyncConflictModal.svelte delete mode 100644 frontend/src/lib/components/common/confirmationModal/LocalDraftStaleModal.svelte create mode 100644 frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte create mode 100644 frontend/src/lib/components/common/confirmationModal/StaleDraftModal.svelte create mode 100644 frontend/src/lib/components/forkDraftToImport.ts create mode 100644 frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte create mode 100644 frontend/src/lib/components/usePageDraftSync.svelte.ts create mode 100644 frontend/src/lib/debouncerByKey.svelte.ts create mode 100644 frontend/src/lib/debouncerByKey.test.ts create mode 100644 frontend/src/lib/draftAddRedirect.ts create mode 100644 frontend/src/lib/encryptedDraft.ts create mode 100644 frontend/src/lib/localDraftHints.svelte.ts delete mode 100644 frontend/src/lib/userDraft.test.ts create mode 100644 frontend/src/lib/userDraftDbMigration.ts create mode 100644 frontend/src/lib/userDraftDbSyncer.svelte.ts create mode 100644 frontend/src/lib/userNamespace.ts delete mode 100644 frontend/src/routes/(root)/(logged)/apps/add/+page.js create mode 100644 frontend/src/routes/(root)/(logged)/apps/add/+page.ts delete mode 100644 frontend/src/routes/(root)/(logged)/apps_raw/add/+page.js create mode 100644 frontend/src/routes/(root)/(logged)/apps_raw/add/+page.ts delete mode 100644 frontend/src/routes/(root)/(logged)/flows/add/+page.js create mode 100644 frontend/src/routes/(root)/(logged)/flows/add/+page.ts delete mode 100644 frontend/src/routes/(root)/(logged)/scripts/add/+page.js create mode 100644 frontend/src/routes/(root)/(logged)/scripts/add/+page.ts diff --git a/backend/.sqlx/query-03547bf921bbd4342dc8604277057336378f9e85baf14d1dc34ed7e9b42e5e72.json b/backend/.sqlx/query-03547bf921bbd4342dc8604277057336378f9e85baf14d1dc34ed7e9b42e5e72.json new file mode 100644 index 0000000000..71f0c1e2d8 --- /dev/null +++ b/backend/.sqlx/query-03547bf921bbd4342dc8604277057336378f9e85baf14d1dc34ed7e9b42e5e72.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels)\n SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels\n FROM flow\n WHERE path = $2 AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "03547bf921bbd4342dc8604277057336378f9e85baf14d1dc34ed7e9b42e5e72" +} diff --git a/backend/.sqlx/query-1860c102a5309a8e1bb7288d0616f48e4330af7fbe61426e999c6073f630d88f.json b/backend/.sqlx/query-1860c102a5309a8e1bb7288d0616f48e4330af7fbe61426e999c6073f630d88f.json new file mode 100644 index 0000000000..d67ddf711b --- /dev/null +++ b/backend/.sqlx/query-1860c102a5309a8e1bb7288d0616f48e4330af7fbe61426e999c6073f630d88f.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT ON (path)\n path,\n value as \"value!: sqlx::types::Json>\",\n created_at,\n typ::text as \"typ!\"\n FROM draft\n WHERE workspace_id = $1\n AND typ IN ('app', 'raw_app')\n AND (email = $2 OR email IS NULL)\n AND NOT EXISTS (\n SELECT 1 FROM app a\n WHERE a.workspace_id = draft.workspace_id\n AND a.path = draft.path\n )\n ORDER BY path, (email IS NULL), created_at DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "typ!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + null + ] + }, + "hash": "1860c102a5309a8e1bb7288d0616f48e4330af7fbe61426e999c6073f630d88f" +} diff --git a/backend/.sqlx/query-19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319.json b/backend/.sqlx/query-19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319.json index 8944a6001f..dfc8540468 100644 --- a/backend/.sqlx/query-19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319.json +++ b/backend/.sqlx/query-19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319.json @@ -5,7 +5,7 @@ "columns": [ { "ordinal": 0, - "name": "id", + "name": "id!", "type_info": "Uuid" } ], @@ -16,7 +16,7 @@ ] }, "nullable": [ - false + null ] }, "hash": "19513c4158267cc7fe10d999ad571052c112e6bbb3cf834f16176cbb7e1ac319" diff --git a/backend/.sqlx/query-1f486036b2902a0ffa0ce15f665d82c85129e2407819477adf7e87f097276ae2.json b/backend/.sqlx/query-1f486036b2902a0ffa0ce15f665d82c85129e2407819477adf7e87f097276ae2.json new file mode 100644 index 0000000000..900cbdae4d --- /dev/null +++ b/backend/.sqlx/query-1f486036b2902a0ffa0ce15f665d82c85129e2407819477adf7e87f097276ae2.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email)\n SELECT $2, path, typ, value, created_at, email\n FROM draft\n WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "1f486036b2902a0ffa0ce15f665d82c85129e2407819477adf7e87f097276ae2" +} diff --git a/backend/.sqlx/query-26fb727ef7d50c93c1c8ce33356ef6feafc448e70c30ba7e7c835f5e3167f3a5.json b/backend/.sqlx/query-26fb727ef7d50c93c1c8ce33356ef6feafc448e70c30ba7e7c835f5e3167f3a5.json new file mode 100644 index 0000000000..2d22e40cc7 --- /dev/null +++ b/backend/.sqlx/query-26fb727ef7d50c93c1c8ce33356ef6feafc448e70c30ba7e7c835f5e3167f3a5.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, custom_path\n FROM app\n WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "summary", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "policy", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "versions", + "type_info": "Int8Array" + }, + { + "ordinal": 6, + "name": "extra_perms", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "custom_path", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "26fb727ef7d50c93c1c8ce33356ef6feafc448e70c30ba7e7c835f5e3167f3a5" +} diff --git a/backend/.sqlx/query-2cb84c274a3e8f7c6ec91c5d86b885dac4de6171463ac0d1c45909da9f91b3a4.json b/backend/.sqlx/query-2cb84c274a3e8f7c6ec91c5d86b885dac4de6171463ac0d1c45909da9f91b3a4.json new file mode 100644 index 0000000000..19fbdd36f9 --- /dev/null +++ b/backend/.sqlx/query-2cb84c274a3e8f7c6ec91c5d86b885dac4de6171463ac0d1c45909da9f91b3a4.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND email = $2\n AND path = $3\n AND typ = $4\n AND ($6::bool = true\n OR $5::timestamptz IS NULL\n OR created_at <= $5::timestamptz)\n RETURNING now() as \"now!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "now!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "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" + ] + } + } + }, + "Timestamptz", + "Bool" + ] + }, + "nullable": [ + null + ] + }, + "hash": "2cb84c274a3e8f7c6ec91c5d86b885dac4de6171463ac0d1c45909da9f91b3a4" +} diff --git a/backend/.sqlx/query-355e3976f0a7e3ccbb24314f951ff37dcc4c34f800ff4f385ec9f60933f83efe.json b/backend/.sqlx/query-355e3976f0a7e3ccbb24314f951ff37dcc4c34f800ff4f385ec9f60933f83efe.json new file mode 100644 index 0000000000..66809be416 --- /dev/null +++ b/backend/.sqlx/query-355e3976f0a7e3ccbb24314f951ff37dcc4c34f800ff4f385ec9f60933f83efe.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs)\n SELECT $1, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs\n FROM flow WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "355e3976f0a7e3ccbb24314f951ff37dcc4c34f800ff4f385ec9f60933f83efe" +} diff --git a/backend/.sqlx/query-388b6af24fc77341abf7088303f1b027c022fc034dc378a071c046dc64571ac6.json b/backend/.sqlx/query-388b6af24fc77341abf7088303f1b027c022fc034dc378a071c046dc64571ac6.json new file mode 100644 index 0000000000..f1fab7b130 --- /dev/null +++ b/backend/.sqlx/query-388b6af24fc77341abf7088303f1b027c022fc034dc378a071c046dc64571ac6.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow (\n workspace_id, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, dependency_job, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, versions, on_behalf_of_email, lock_error_logs\n )\n SELECT $2, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, NULL, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, ARRAY[]::bigint[], on_behalf_of_email, lock_error_logs\n FROM flow\n WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "388b6af24fc77341abf7088303f1b027c022fc034dc378a071c046dc64571ac6" +} diff --git a/backend/.sqlx/query-3d7456e11d8686210169bfe26931f924bd4483c8f70095c05720bc4eb2d8df81.json b/backend/.sqlx/query-3d7456e11d8686210169bfe26931f924bd4483c8f70095c05720bc4eb2d8df81.json new file mode 100644 index 0000000000..335561cfef --- /dev/null +++ b/backend/.sqlx/query-3d7456e11d8686210169bfe26931f924bd4483c8f70095c05720bc4eb2d8df81.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' AND (email = $3 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "3d7456e11d8686210169bfe26931f924bd4483c8f70095c05720bc4eb2d8df81" +} diff --git a/backend/.sqlx/query-3e51a3f545f67fb0f62956fd809324d753cb2b1fac69ceb738df4c3d2a6270a5.json b/backend/.sqlx/query-3e51a3f545f67fb0f62956fd809324d753cb2b1fac69ceb738df4c3d2a6270a5.json new file mode 100644 index 0000000000..ebd8594a06 --- /dev/null +++ b/backend/.sqlx/query-3e51a3f545f67fb0f62956fd809324d753cb2b1fac69ceb738df4c3d2a6270a5.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT app.summary, app.policy, app_version.value\n FROM app\n JOIN app_version\n ON app_version.id = app.versions[array_upper(app.versions, 1)]\n WHERE app.workspace_id = $1 AND app.path = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "summary", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "policy", + "type_info": "Jsonb" + }, + { + "ordinal": 2, + "name": "value", + "type_info": "Json" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "3e51a3f545f67fb0f62956fd809324d753cb2b1fac69ceb738df4c3d2a6270a5" +} diff --git a/backend/.sqlx/query-4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c.json b/backend/.sqlx/query-4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c.json new file mode 100644 index 0000000000..e432c394b9 --- /dev/null +++ b/backend/.sqlx/query-4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c.json @@ -0,0 +1,57 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT u.username as \"username?\"\n FROM draft d\n LEFT JOIN usr u\n ON u.workspace_id = d.workspace_id\n AND u.email = d.email\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ = $3\n AND (d.email IS NULL OR d.email <> $4)\n ORDER BY d.email NULLS LAST", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "username?", + "type_info": "Varchar" + } + ], + "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" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c" +} diff --git a/backend/.sqlx/query-5223412e366b22d953867425ed6156a66727e7a469abad1664ee03a3da31078a.json b/backend/.sqlx/query-5223412e366b22d953867425ed6156a66727e7a469abad1664ee03a3da31078a.json new file mode 100644 index 0000000000..be5e26ef9b --- /dev/null +++ b/backend/.sqlx/query-5223412e366b22d953867425ed6156a66727e7a469abad1664ee03a3da31078a.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT ON (path)\n path,\n value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND typ = 'flow'\n AND (email = $2 OR email IS NULL)\n AND NOT EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = draft.workspace_id\n AND f.path = draft.path\n )\n ORDER BY path, (email IS NULL)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "5223412e366b22d953867425ed6156a66727e7a469abad1664ee03a3da31078a" +} diff --git a/backend/.sqlx/query-56741ef2a510917f6c460968117b26e3ef9171f478e7ab3bd68eb09663b393c4.json b/backend/.sqlx/query-56741ef2a510917f6c460968117b26e3ef9171f478e7ab3bd68eb09663b393c4.json new file mode 100644 index 0000000000..37f6872cc9 --- /dev/null +++ b/backend/.sqlx/query-56741ef2a510917f6c460968117b26e3ef9171f478e7ab3bd68eb09663b393c4.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path,\n value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND typ = 'script'\n AND email = $2\n AND NOT EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = draft.workspace_id\n AND s.path = draft.path\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "56741ef2a510917f6c460968117b26e3ef9171f478e7ab3bd68eb09663b393c4" +} diff --git a/backend/.sqlx/query-5884ce1906015f6b96231f311226e490d5dfcdd7a94dcbe5d05c9e5af37ac4a4.json b/backend/.sqlx/query-5884ce1906015f6b96231f311226e490d5dfcdd7a94dcbe5d05c9e5af37ac4a4.json new file mode 100644 index 0000000000..bb326fab87 --- /dev/null +++ b/backend/.sqlx/query-5884ce1906015f6b96231f311226e490d5dfcdd7a94dcbe5d05c9e5af37ac4a4.json @@ -0,0 +1,49 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $3\n AND (email = $4 OR 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" + ] + } + } + }, + "Text" + ] + }, + "nullable": [] + }, + "hash": "5884ce1906015f6b96231f311226e490d5dfcdd7a94dcbe5d05c9e5af37ac4a4" +} diff --git a/backend/.sqlx/query-590c4429347925395794b061943bfda0fb4434407b5e1ba4ffb8a770718d9c5d.json b/backend/.sqlx/query-590c4429347925395794b061943bfda0fb4434407b5e1ba4ffb8a770718d9c5d.json new file mode 100644 index 0000000000..ca75762cd6 --- /dev/null +++ b/backend/.sqlx/query-590c4429347925395794b061943bfda0fb4434407b5e1ba4ffb8a770718d9c5d.json @@ -0,0 +1,57 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) as \"username?\"\n FROM draft d\n LEFT JOIN usr u\n ON u.workspace_id = d.workspace_id\n AND u.email = d.email\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ = $3\n AND (d.email IS NULL OR d.email <> $4)\n ORDER BY d.email NULLS LAST", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "username?", + "type_info": "Varchar" + } + ], + "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" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "590c4429347925395794b061943bfda0fb4434407b5e1ba4ffb8a770718d9c5d" +} diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 36ddb8ab9f..713ccb9dd3 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - true + null ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-665de8d7956cf034d2d009e6e3241a82b26105c1bf7833c6e5d6178e65974067.json b/backend/.sqlx/query-665de8d7956cf034d2d009e6e3241a82b26105c1bf7833c6e5d6178e65974067.json new file mode 100644 index 0000000000..7ecba622e6 --- /dev/null +++ b/backend/.sqlx/query-665de8d7956cf034d2d009e6e3241a82b26105c1bf7833c6e5d6178e65974067.json @@ -0,0 +1,125 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT resource.workspace_id, resource.path, resource.value, resource.description,\n resource.resource_type, resource.extra_perms, resource.created_by, resource.edited_at,\n resource.labels,\n (now() > account.expires_at) as is_expired, account.refresh_token != '' as is_refreshed,\n account.refresh_error,\n variable.path IS NOT NULL as is_linked,\n variable.is_oauth as \"is_oauth?\",\n variable.account,\n ws_specific.path IS NOT NULL as ws_specific,\n null::bool as draft_only,\n null::bool as is_draft\n FROM resource\n LEFT JOIN variable ON variable.path = resource.path AND variable.workspace_id = $2\n LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2\n LEFT JOIN ws_specific ON ws_specific.path = resource.path AND ws_specific.workspace_id = $2 AND ws_specific.item_kind = 'resource'\n WHERE resource.path = $1 AND resource.workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "value", + "type_info": "Jsonb" + }, + { + "ordinal": 3, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "resource_type", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "extra_perms", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "edited_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "labels", + "type_info": "TextArray" + }, + { + "ordinal": 9, + "name": "is_expired", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "is_refreshed", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "refresh_error", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "is_linked", + "type_info": "Bool" + }, + { + "ordinal": 13, + "name": "is_oauth?", + "type_info": "Bool" + }, + { + "ordinal": 14, + "name": "account", + "type_info": "Int4" + }, + { + "ordinal": 15, + "name": "ws_specific", + "type_info": "Bool" + }, + { + "ordinal": 16, + "name": "draft_only", + "type_info": "Bool" + }, + { + "ordinal": 17, + "name": "is_draft", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + true, + true, + false, + false, + true, + true, + true, + null, + null, + true, + null, + false, + true, + null, + null, + null + ] + }, + "hash": "665de8d7956cf034d2d009e6e3241a82b26105c1bf7833c6e5d6178e65974067" +} diff --git a/backend/.sqlx/query-66c775b6e1120c5ed53b903d252e07f0965efb65c51580c529e61d09b8742dfd.json b/backend/.sqlx/query-66c775b6e1120c5ed53b903d252e07f0965efb65c51580c529e61d09b8742dfd.json new file mode 100644 index 0000000000..80f172aa40 --- /dev/null +++ b/backend/.sqlx/query-66c775b6e1120c5ed53b903d252e07f0965efb65c51580c529e61d09b8742dfd.json @@ -0,0 +1,99 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8", + "Varchar", + "Int8Array", + "Text", + "Text", + "Text", + "Varchar", + "Text", + "Bool", + "Jsonb", + "Text", + { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang" + ] + } + } + }, + { + "Custom": { + "name": "script_kind", + "kind": { + "Enum": [ + "script", + "trigger", + "failure", + "command", + "approval", + "preprocessor" + ] + } + } + }, + "Varchar", + "VarcharArray", + "Int4", + "Int4", + "Int4", + "Bool", + "Bool", + "Int2", + "Bool", + "Bool", + "Int4", + "Int4", + "Varchar", + "Bool", + "Varchar", + "Varchar", + "Bool", + "Text", + "Bool", + "Jsonb", + "Varchar", + "Int4", + "Bool", + "Int8", + "Jsonb", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "66c775b6e1120c5ed53b903d252e07f0965efb65c51580c529e61d09b8742dfd" +} diff --git a/backend/.sqlx/query-67e092189a1a7be4fc3b660dc7a5fcfb1fdb296ea8893312d7d72ac80e4f506e.json b/backend/.sqlx/query-67e092189a1a7be4fc3b660dc7a5fcfb1fdb296ea8893312d7d72ac80e4f506e.json new file mode 100644 index 0000000000..dcff085b5e --- /dev/null +++ b/backend/.sqlx/query-67e092189a1a7be4fc3b660dc7a5fcfb1fdb296ea8893312d7d72ac80e4f506e.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Text" + ] + }, + "nullable": [] + }, + "hash": "67e092189a1a7be4fc3b660dc7a5fcfb1fdb296ea8893312d7d72ac80e4f506e" +} diff --git a/backend/.sqlx/query-684e025167e097e60a3fc436ec8b0128c02bc44461ccc9cd9fb29de567762f63.json b/backend/.sqlx/query-684e025167e097e60a3fc436ec8b0128c02bc44461ccc9cd9fb29de567762f63.json new file mode 100644 index 0000000000..2b92062ad0 --- /dev/null +++ b/backend/.sqlx/query-684e025167e097e60a3fc436ec8b0128c02bc44461ccc9cd9fb29de567762f63.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT ON (path)\n path,\n value as \"value!: sqlx::types::Json>\",\n created_at,\n typ::text as \"typ!\"\n FROM draft\n WHERE workspace_id = $1\n AND typ IN ('app', 'raw_app')\n AND email = $2\n AND NOT EXISTS (\n SELECT 1 FROM app a\n WHERE a.workspace_id = draft.workspace_id\n AND a.path = draft.path\n )\n ORDER BY path, created_at DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "typ!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + null + ] + }, + "hash": "684e025167e097e60a3fc436ec8b0128c02bc44461ccc9cd9fb29de567762f63" +} diff --git a/backend/.sqlx/query-68b4a667ac15258b718e7c9c0224c4b467ff43d84618a3471eb7b1cfec13883d.json b/backend/.sqlx/query-68b4a667ac15258b718e7c9c0224c4b467ff43d84618a3471eb7b1cfec13883d.json new file mode 100644 index 0000000000..02e17b82cb --- /dev/null +++ b/backend/.sqlx/query-68b4a667ac15258b718e7c9c0224c4b467ff43d84618a3471eb7b1cfec13883d.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH inserted AS (\n INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs)\n SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3\n RETURNING 1\n ) SELECT COUNT(*) FROM inserted", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "68b4a667ac15258b718e7c9c0224c4b467ff43d84618a3471eb7b1cfec13883d" +} diff --git a/backend/.sqlx/query-6e4afbd2931a2d96b99b88a270388006066c83a2697913e43dc3c3db4bd4bd2e.json b/backend/.sqlx/query-6e4afbd2931a2d96b99b88a270388006066c83a2697913e43dc3c3db4bd4bd2e.json new file mode 100644 index 0000000000..f4839e8e38 --- /dev/null +++ b/backend/.sqlx/query-6e4afbd2931a2d96b99b88a270388006066c83a2697913e43dc3c3db4bd4bd2e.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms)\n VALUES ('test-workspace', 'f/shared/dashboard', 'Dashboard app', '{}', ARRAY[1::bigint], '{}')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "6e4afbd2931a2d96b99b88a270388006066c83a2697913e43dc3c3db4bd4bd2e" +} diff --git a/backend/.sqlx/query-7753786abd0b9c7b548d0f8157023823945e84997b038d5b7915406c4ccb302d.json b/backend/.sqlx/query-7753786abd0b9c7b548d0f8157023823945e84997b038d5b7915406c4ccb302d.json new file mode 100644 index 0000000000..9dc1d0f296 --- /dev/null +++ b/backend/.sqlx/query-7753786abd0b9c7b548d0f8157023823945e84997b038d5b7915406c4ccb302d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO script (\n workspace_id, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of_email, assets, modules\n )\n SELECT\n $1, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of_email, assets, modules\n FROM script\n WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "7753786abd0b9c7b548d0f8157023823945e84997b038d5b7915406c4ccb302d" +} diff --git a/backend/.sqlx/query-8bba7bec4f5f09a90da3eecaab4cf4386eb97e4e00c7431ff2860b225d212780.json b/backend/.sqlx/query-8bba7bec4f5f09a90da3eecaab4cf4386eb97e4e00c7431ff2860b225d212780.json new file mode 100644 index 0000000000..00b6e712d5 --- /dev/null +++ b/backend/.sqlx/query-8bba7bec4f5f09a90da3eecaab4cf4386eb97e4e00c7431ff2860b225d212780.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app' AND (email = $3 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "8bba7bec4f5f09a90da3eecaab4cf4386eb97e4e00c7431ff2860b225d212780" +} diff --git a/backend/.sqlx/query-8be291d84471ff742a3c2a9d53cda55f57b4de71db778642ed72654bf47d26a5.json b/backend/.sqlx/query-8be291d84471ff742a3c2a9d53cda55f57b4de71db778642ed72654bf47d26a5.json new file mode 100644 index 0000000000..33e7bfe8ec --- /dev/null +++ b/backend/.sqlx/query-8be291d84471ff742a3c2a9d53cda55f57b4de71db778642ed72654bf47d26a5.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT now() as \"now!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "now!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "8be291d84471ff742a3c2a9d53cda55f57b4de71db778642ed72654bf47d26a5" +} diff --git a/backend/.sqlx/query-8f163ee5adf4caaaa12a5698e68c749524f1db10a51f3de6cadd4826e6c1d422.json b/backend/.sqlx/query-8f163ee5adf4caaaa12a5698e68c749524f1db10a51f3de6cadd4826e6c1d422.json new file mode 100644 index 0000000000..13402c8387 --- /dev/null +++ b/backend/.sqlx/query-8f163ee5adf4caaaa12a5698e68c749524f1db10a51f3de6cadd4826e6c1d422.json @@ -0,0 +1,48 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $3", + "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": "8f163ee5adf4caaaa12a5698e68c749524f1db10a51f3de6cadd4826e6c1d422" +} diff --git a/backend/.sqlx/query-90068339285f3802a975750274d4f0707c2243f085bc3fc442065fb4391330df.json b/backend/.sqlx/query-90068339285f3802a975750274d4f0707c2243f085bc3fc442065fb4391330df.json new file mode 100644 index 0000000000..681b9d7d7d --- /dev/null +++ b/backend/.sqlx/query-90068339285f3802a975750274d4f0707c2243f085bc3fc442065fb4391330df.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at)\n SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "90068339285f3802a975750274d4f0707c2243f085bc3fc442065fb4391330df" +} diff --git a/backend/.sqlx/query-52b07f3ef0aa3ac4f6d6be0db60c732e396a79458489c564bbf2e4a28de06f1f.json b/backend/.sqlx/query-9b88afcbecd66e5e29658463a2b8c2aa44d714bcc95e242ef329f6fd71c345c1.json similarity index 76% rename from backend/.sqlx/query-52b07f3ef0aa3ac4f6d6be0db60c732e396a79458489c564bbf2e4a28de06f1f.json rename to backend/.sqlx/query-9b88afcbecd66e5e29658463a2b8c2aa44d714bcc95e242ef329f6fd71c345c1.json index 5de7aed2e0..08dfcfd6bd 100644 --- a/backend/.sqlx/query-52b07f3ef0aa3ac4f6d6be0db60c732e396a79458489c564bbf2e4a28de06f1f.json +++ b/backend/.sqlx/query-9b88afcbecd66e5e29658463a2b8c2aa44d714bcc95e242ef329f6fd71c345c1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT resource.workspace_id, resource.path, resource.value, resource.description,\n resource.resource_type, resource.extra_perms, resource.created_by, resource.edited_at,\n resource.labels,\n folder_labels(resource.workspace_id, resource.path) as \"inherited_labels?\",\n (now() > account.expires_at) as is_expired, account.refresh_token != '' as is_refreshed,\n account.refresh_error,\n variable.path IS NOT NULL as is_linked,\n variable.is_oauth as \"is_oauth?\",\n variable.account,\n ws_specific.path IS NOT NULL as ws_specific\n FROM resource\n LEFT JOIN variable ON variable.path = resource.path AND variable.workspace_id = $2\n LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2\n LEFT JOIN ws_specific ON ws_specific.path = resource.path AND ws_specific.workspace_id = $2 AND ws_specific.item_kind = 'resource'\n WHERE resource.path = $1 AND resource.workspace_id = $2", + "query": "SELECT resource.workspace_id, resource.path, resource.value, resource.description,\n resource.resource_type, resource.extra_perms, resource.created_by, resource.edited_at,\n resource.labels,\n folder_labels(resource.workspace_id, resource.path) as \"inherited_labels?\",\n (now() > account.expires_at) as is_expired, account.refresh_token != '' as is_refreshed,\n account.refresh_error,\n variable.path IS NOT NULL as is_linked,\n variable.is_oauth as \"is_oauth?\",\n variable.account,\n ws_specific.path IS NOT NULL as ws_specific,\n null::bool as draft_only,\n null::bool as is_draft\n FROM resource\n LEFT JOIN variable ON variable.path = resource.path AND variable.workspace_id = $2\n LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2\n LEFT JOIN ws_specific ON ws_specific.path = resource.path AND ws_specific.workspace_id = $2 AND ws_specific.item_kind = 'resource'\n WHERE resource.path = $1 AND resource.workspace_id = $2", "describe": { "columns": [ { @@ -87,6 +87,16 @@ "ordinal": 16, "name": "ws_specific", "type_info": "Bool" + }, + { + "ordinal": 17, + "name": "draft_only", + "type_info": "Bool" + }, + { + "ordinal": 18, + "name": "is_draft", + "type_info": "Bool" } ], "parameters": { @@ -112,8 +122,10 @@ null, false, true, + null, + null, null ] }, - "hash": "52b07f3ef0aa3ac4f6d6be0db60c732e396a79458489c564bbf2e4a28de06f1f" + "hash": "9b88afcbecd66e5e29658463a2b8c2aa44d714bcc95e242ef329f6fd71c345c1" } diff --git a/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json b/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json index f0c91daf16..b492b38f49 100644 --- a/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json +++ b/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json @@ -13,4 +13,4 @@ "nullable": [] }, "hash": "afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270" -} \ No newline at end of file +} diff --git a/backend/.sqlx/query-b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d.json b/backend/.sqlx/query-b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d.json new file mode 100644 index 0000000000..52e1ced19a --- /dev/null +++ b/backend/.sqlx/query-b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d.json @@ -0,0 +1,63 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value as \"value!: sqlx::types::Json>\", created_at\n FROM draft\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $3\n AND email IS NOT DISTINCT FROM $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "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" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "b6cfb752675a3f36975e6cc6c454267d67f58f9cbff0d164b4df45de885b0e9d" +} diff --git a/backend/.sqlx/query-bf945c34c18a9fe5f0af8f54042df39178f5111119d50f2975dd1180502c0c52.json b/backend/.sqlx/query-bf945c34c18a9fe5f0af8f54042df39178f5111119d50f2975dd1180502c0c52.json new file mode 100644 index 0000000000..a4ad3fd76b --- /dev/null +++ b/backend/.sqlx/query-bf945c34c18a9fe5f0af8f54042df39178f5111119d50f2975dd1180502c0c52.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path,\n value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND typ = 'flow'\n AND email = $2\n AND NOT EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = draft.workspace_id\n AND f.path = draft.path\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "bf945c34c18a9fe5f0af8f54042df39178f5111119d50f2975dd1180502c0c52" +} diff --git a/backend/.sqlx/query-c03260b15cc3efd932312aebf4989c59bd6c6f7bce52aa3707e923b32d9be3e2.json b/backend/.sqlx/query-c03260b15cc3efd932312aebf4989c59bd6c6f7bce52aa3707e923b32d9be3e2.json new file mode 100644 index 0000000000..78014926fa --- /dev/null +++ b/backend/.sqlx/query-c03260b15cc3efd932312aebf4989c59bd6c6f7bce52aa3707e923b32d9be3e2.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE\n flow\n SET\n path = $1,\n summary = $2,\n description = $3,\n dependency_job = NULL,\n lock_error_logs = '',\n tag = $4,\n dedicated_worker = $5,\n visible_to_runner_only = $6,\n on_behalf_of_email = $7,\n ws_error_handler_muted = $8,\n value = $9,\n schema = $10::text::json,\n edited_by = $11,\n edited_at = now(),\n labels = COALESCE($14, labels)\n WHERE\n path = $12 AND workspace_id = $13", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text", + "Varchar", + "Bool", + "Bool", + "Text", + "Bool", + "Jsonb", + "Text", + "Varchar", + "Text", + "Text", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "c03260b15cc3efd932312aebf4989c59bd6c6f7bce52aa3707e923b32d9be3e2" +} diff --git a/backend/.sqlx/query-c241ee7efe2cbb9024792f6dc67cde48c5517ab36acd543a1f0a6119c34ce453.json b/backend/.sqlx/query-c241ee7efe2cbb9024792f6dc67cde48c5517ab36acd543a1f0a6119c34ce453.json new file mode 100644 index 0000000000..65003984ac --- /dev/null +++ b/backend/.sqlx/query-c241ee7efe2cbb9024792f6dc67cde48c5517ab36acd543a1f0a6119c34ce453.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' AND (email = $3 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "c241ee7efe2cbb9024792f6dc67cde48c5517ab36acd543a1f0a6119c34ce453" +} diff --git a/backend/.sqlx/query-c61977907414d2336030fbab114ea03e4a1d1edb4470812cd7dd57c657fa287a.json b/backend/.sqlx/query-c61977907414d2336030fbab114ea03e4a1d1edb4470812cd7dd57c657fa287a.json new file mode 100644 index 0000000000..61c60afdc4 --- /dev/null +++ b/backend/.sqlx/query-c61977907414d2336030fbab114ea03e4a1d1edb4470812cd7dd57c657fa287a.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, custom_path)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Jsonb", + "Int8Array", + "Jsonb", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c61977907414d2336030fbab114ea03e4a1d1edb4470812cd7dd57c657fa287a" +} diff --git a/backend/.sqlx/query-cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6.json b/backend/.sqlx/query-cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6.json new file mode 100644 index 0000000000..6d682c7bb7 --- /dev/null +++ b/backend/.sqlx/query-cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "cbe8fb4935908a7eb9a0b56b1d6f330cd3c8ef1ca692147210a36e56946f7ef6" +} diff --git a/backend/.sqlx/query-d4e0da9f9653d532770066310f85e59e5edda1faea72f39603814afb6a3cd596.json b/backend/.sqlx/query-d4e0da9f9653d532770066310f85e59e5edda1faea72f39603814afb6a3cd596.json new file mode 100644 index 0000000000..0eed069163 --- /dev/null +++ b/backend/.sqlx/query-d4e0da9f9653d532770066310f85e59e5edda1faea72f39603814afb6a3cd596.json @@ -0,0 +1,63 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND (email = $2 OR email IS NULL)\n AND path = $3\n AND typ = $4\n ORDER BY email NULLS LAST\n LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "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": [ + false, + false + ] + }, + "hash": "d4e0da9f9653d532770066310f85e59e5edda1faea72f39603814afb6a3cd596" +} diff --git a/backend/.sqlx/query-dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9.json b/backend/.sqlx/query-dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9.json new file mode 100644 index 0000000000..6ae3f60e49 --- /dev/null +++ b/backend/.sqlx/query-dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email FROM usr WHERE workspace_id = $1 AND username = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "dadf78bae0299b24f6798e7a2cc86f0ecfcf63daa0b185994a410eb2fe41fad9" +} diff --git a/backend/.sqlx/query-ddcc3f07ff87b9ea47a9428ba0bb7e3239773fa3e6352aef7e98467ad07f337d.json b/backend/.sqlx/query-ddcc3f07ff87b9ea47a9428ba0bb7e3239773fa3e6352aef7e98467ad07f337d.json new file mode 100644 index 0000000000..35e384b496 --- /dev/null +++ b/backend/.sqlx/query-ddcc3f07ff87b9ea47a9428ba0bb7e3239773fa3e6352aef7e98467ad07f337d.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app\n (workspace_id, path, summary, policy, versions, custom_path, labels)\n VALUES ($1, $2, $3, $4, '{}', $5, $6) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Jsonb", + "Text", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ddcc3f07ff87b9ea47a9428ba0bb7e3239773fa3e6352aef7e98467ad07f337d" +} diff --git a/backend/.sqlx/query-e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545.json b/backend/.sqlx/query-e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545.json new file mode 100644 index 0000000000..c077375ef4 --- /dev/null +++ b/backend/.sqlx/query-e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545.json @@ -0,0 +1,60 @@ +{ + "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", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + { + "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" + ] + } + } + }, + "Text", + "Timestamptz", + "Bool" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e0cc7528f34cca1a65bcff355805057b1c974a9a947bda133c155503dac1f545" +} diff --git a/backend/.sqlx/query-eb6f237cdaea4581cd839c353d8acd868f9101250b0599fc66798a7852c7cdf5.json b/backend/.sqlx/query-eb6f237cdaea4581cd839c353d8acd868f9101250b0599fc66798a7852c7cdf5.json new file mode 100644 index 0000000000..879288cc04 --- /dev/null +++ b/backend/.sqlx/query-eb6f237cdaea4581cd839c353d8acd868f9101250b0599fc66798a7852c7cdf5.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO flow (\n workspace_id, path, summary, description,\n dependency_job, lock_error_logs, tag,\n dedicated_worker, visible_to_runner_only, on_behalf_of_email,\n ws_error_handler_muted,\n value, schema, edited_by, edited_at, labels\n ) VALUES (\n $1, $2, $3, $4,\n NULL, '', $5,\n $6, $7, $8,\n $9,\n $10, $11::text::json, $12, now(), $13\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Text", + "Text", + "Varchar", + "Bool", + "Bool", + "Text", + "Bool", + "Jsonb", + "Text", + "Varchar", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "eb6f237cdaea4581cd839c353d8acd868f9101250b0599fc66798a7852c7cdf5" +} diff --git a/backend/.sqlx/query-ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa.json b/backend/.sqlx/query-ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa.json new file mode 100644 index 0000000000..319c192fae --- /dev/null +++ b/backend/.sqlx/query-ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa.json @@ -0,0 +1,63 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND email = $2\n AND path = $3\n AND typ = $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "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": [ + false, + false + ] + }, + "hash": "ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa" +} diff --git a/backend/.sqlx/query-ed8351ccac2df2a4bd327383003d86af3e3f0b9ab326e4ea89267514ecc66f71.json b/backend/.sqlx/query-ed8351ccac2df2a4bd327383003d86af3e3f0b9ab326e4ea89267514ecc66f71.json new file mode 100644 index 0000000000..7ac1fb17da --- /dev/null +++ b/backend/.sqlx/query-ed8351ccac2df2a4bd327383003d86af3e3f0b9ab326e4ea89267514ecc66f71.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM script s WHERE s.workspace_id = $1 AND s.hash NOT IN (\n SELECT DISTINCT ON (path) hash FROM script\n WHERE workspace_id = $1 AND deleted = false\n ORDER BY path, created_at DESC\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "ed8351ccac2df2a4bd327383003d86af3e3f0b9ab326e4ea89267514ecc66f71" +} diff --git a/backend/.sqlx/query-ee783aeeb2eba7446995ca8467ff271cc64e5a3c9901c2e2f806ab3acbb4aa75.json b/backend/.sqlx/query-ee783aeeb2eba7446995ca8467ff271cc64e5a3c9901c2e2f806ab3acbb4aa75.json new file mode 100644 index 0000000000..b30207b10e --- /dev/null +++ b/backend/.sqlx/query-ee783aeeb2eba7446995ca8467ff271cc64e5a3c9901c2e2f806ab3acbb4aa75.json @@ -0,0 +1,57 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT created_at FROM draft\n WHERE workspace_id = $1 AND email = $2 AND path = $3 AND typ = $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "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": [ + false + ] + }, + "hash": "ee783aeeb2eba7446995ca8467ff271cc64e5a3c9901c2e2f806ab3acbb4aa75" +} diff --git a/backend/.sqlx/query-f57c70d1f6756d8df09d3c26f6e4cbece8a8206a678271218c4d486a65818745.json b/backend/.sqlx/query-f57c70d1f6756d8df09d3c26f6e4cbece8a8206a678271218c4d486a65818745.json new file mode 100644 index 0000000000..394d36b747 --- /dev/null +++ b/backend/.sqlx/query-f57c70d1f6756d8df09d3c26f6e4cbece8a8206a678271218c4d486a65818745.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app') AND (email = $3 OR email IS NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "f57c70d1f6756d8df09d3c26f6e4cbece8a8206a678271218c4d486a65818745" +} diff --git a/backend/.sqlx/query-fac99e27c8396185dd21c33baf649dd75b5f8ed859e410d2322ec3f565382fa8.json b/backend/.sqlx/query-fac99e27c8396185dd21c33baf649dd75b5f8ed859e410d2322ec3f565382fa8.json new file mode 100644 index 0000000000..c624143ea4 --- /dev/null +++ b/backend/.sqlx/query-fac99e27c8396185dd21c33baf649dd75b5f8ed859e410d2322ec3f565382fa8.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT DISTINCT ON (path)\n path,\n value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND typ = 'script'\n AND (email = $2 OR email IS NULL)\n AND NOT EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = draft.workspace_id\n AND s.path = draft.path\n )\n ORDER BY path, (email IS NULL)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "fac99e27c8396185dd21c33baf649dd75b5f8ed859e410d2322ec3f565382fa8" +} diff --git a/backend/.sqlx/query-fea3dd1c119e859290cc875cfabc6536f9e2404002ba36046691aab955caf300.json b/backend/.sqlx/query-fea3dd1c119e859290cc875cfabc6536f9e2404002ba36046691aab955caf300.json new file mode 100644 index 0000000000..e8dedf7ab9 --- /dev/null +++ b/backend/.sqlx/query-fea3dd1c119e859290cc875cfabc6536f9e2404002ba36046691aab955caf300.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT path AS \"path!\" FROM (\n (SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND archived = false AND deleted = false LIMIT 5000)\n UNION\n (SELECT path FROM flow WHERE workspace_id = $1 AND archived = false LIMIT 5000)\n UNION\n (SELECT path FROM app WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM raw_app WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM variable WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM resource WHERE workspace_id = $1 LIMIT 5000)\n ) t\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path!", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "fea3dd1c119e859290cc875cfabc6536f9e2404002ba36046691aab955caf300" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 11f00f7ad1..8ca8a7e265 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -14589,6 +14589,7 @@ dependencies = [ "dashmap", "datafusion", "equivalent", + "erased-serde", "futures", "futures-core", "gethostname", diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 70d6eba003..fa76836c89 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -a82882f1cb9b1c4cef532f6ad046242903418d29 +97b5cb2096d3a9b4818943c5abf181d914cb4e99 diff --git a/backend/migrations/20260528143710_draft_user_sync_schema.down.sql b/backend/migrations/20260528143710_draft_user_sync_schema.down.sql new file mode 100644 index 0000000000..bbd036ad37 --- /dev/null +++ b/backend/migrations/20260528143710_draft_user_sync_schema.down.sql @@ -0,0 +1,25 @@ +ALTER TABLE draft DROP COLUMN id; + +-- The .up.sql secret-draft scrub is irreversible — the blanked plaintext +-- values were deliberately destroyed and cannot be recovered here. + +DROP INDEX IF EXISTS draft_workspace_path_typ_idx; +DROP INDEX IF EXISTS draft_user_listing_idx; +DROP INDEX IF EXISTS draft_pkey_legacy; +DROP INDEX IF EXISTS draft_pkey_with_user; + +-- Per-user rows can't exist under the composite PK (one row per +-- (workspace_id, path, typ)); drop them before restoring it. +DELETE FROM draft WHERE email IS NOT NULL; + +ALTER TABLE draft ADD CONSTRAINT draft_pkey PRIMARY KEY (workspace_id, path, typ); + +ALTER TABLE draft DROP CONSTRAINT IF EXISTS draft_password_fkey; +ALTER TABLE draft DROP COLUMN email; + +-- Restore the narrower DRAFT_TYPE enum; drop rows outside that set so the +-- cast doesn't fail. +CREATE TYPE DRAFT_TYPE AS ENUM ('script', 'flow', 'app'); +DELETE FROM draft WHERE typ::text NOT IN ('script', 'flow', 'app'); +ALTER TABLE draft ALTER COLUMN typ TYPE DRAFT_TYPE USING typ::text::DRAFT_TYPE; +DROP TYPE DRAFT_KIND; diff --git a/backend/migrations/20260528143710_draft_user_sync_schema.up.sql b/backend/migrations/20260528143710_draft_user_sync_schema.up.sql new file mode 100644 index 0000000000..07343c6b2c --- /dev/null +++ b/backend/migrations/20260528143710_draft_user_sync_schema.up.sql @@ -0,0 +1,82 @@ +-- Reshape `draft` for per-user bidirectional sync: add the owner `email` +-- (FK to password.email, NULL on legacy rows); replace the composite PK with +-- two partial unique indexes so per-user rows and the single legacy +-- workspace-level row coexist at the same (workspace_id, path, typ); widen +-- the DRAFT_TYPE enum to DRAFT_KIND (every UserDraftItemKind); and add a +-- synthetic BIGSERIAL `id` PK (tools like pg_dump/replication break on the +-- partial-index-only layout). + +CREATE TYPE DRAFT_KIND AS 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' +); + +ALTER TABLE draft ALTER COLUMN typ TYPE DRAFT_KIND USING typ::text::DRAFT_KIND; +DROP TYPE DRAFT_TYPE; + +ALTER TABLE draft ADD COLUMN email VARCHAR(255); + +ALTER TABLE draft + ADD CONSTRAINT draft_password_fkey + FOREIGN KEY (email) + REFERENCES password(email) + ON DELETE CASCADE + ON UPDATE CASCADE; + +ALTER TABLE draft DROP CONSTRAINT draft_pkey; + +CREATE UNIQUE INDEX draft_pkey_with_user + ON draft (workspace_id, path, typ, email) + WHERE email IS NOT NULL; + +CREATE UNIQUE INDEX draft_pkey_legacy + ON draft (workspace_id, path, typ) + WHERE email IS NULL; + +-- Serves the per-user draft listing (`WHERE workspace_id = ? AND email = ? +-- ORDER BY path`); neither partial unique index helps (both lead with +-- `path, typ`), and the trailing `path` keeps rows in output order. +CREATE INDEX draft_user_listing_idx + ON draft (workspace_id, email, path) + WHERE email IS NOT NULL; + +ALTER TABLE draft ADD COLUMN id BIGSERIAL PRIMARY KEY; + +-- Hot path: `fetch_other_drafts_users` runs on every get-by-path request +-- with `WHERE workspace_id = ? AND path = ? AND typ = ?` and no email +-- predicate. The partial unique/listing indexes can't serve it (their +-- `email IS [NOT] NULL` predicates aren't implied by the query), so a plain +-- btree is needed. Also covers `get_draft_for_user` (same three columns + +-- `email IS NOT DISTINCT FROM ?` as a filter). +CREATE INDEX draft_workspace_path_typ_idx ON draft (workspace_id, path, typ); + +-- Secret variable values must never sit in `draft.value` in plaintext. +-- `save_draft` now encrypts them at write time; this scrubs any rows +-- persisted before that guard (irreversible — see the .down.sql note). +UPDATE draft +SET value = jsonb_set(value::jsonb, '{variable,value}', '""'::jsonb)::json +WHERE typ = 'variable' + AND (value::jsonb -> 'variable' ->> 'is_secret')::boolean IS TRUE + AND value::jsonb -> 'variable' ? 'value'; diff --git a/backend/migrations/20260609165313_remove_draft_only.down.sql b/backend/migrations/20260609165313_remove_draft_only.down.sql new file mode 100644 index 0000000000..9594c93957 --- /dev/null +++ b/backend/migrations/20260609165313_remove_draft_only.down.sql @@ -0,0 +1,5 @@ +-- Restore the `draft_only` column. Irreversible data-wise: stubs deleted in +-- the up migration are gone and the column comes back NULL everywhere. +ALTER TABLE script ADD COLUMN draft_only BOOLEAN; +ALTER TABLE flow ADD COLUMN draft_only BOOLEAN; +ALTER TABLE app ADD COLUMN draft_only BOOLEAN; diff --git a/backend/migrations/20260609165313_remove_draft_only.up.sql b/backend/migrations/20260609165313_remove_draft_only.up.sql new file mode 100644 index 0000000000..9949de5540 --- /dev/null +++ b/backend/migrations/20260609165313_remove_draft_only.up.sql @@ -0,0 +1,99 @@ +-- `draft_only` items (scripts/flows/apps saved as a draft but never +-- deployed) lived as a stub row in their own table plus a `draft` row; the +-- stub is now redundant. For each stub: ensure a draft row exists at its +-- path (ON CONFLICT DO NOTHING preserves the real per-user draft most stubs +-- already have, synthesising an `email = NULL` legacy stand-in only for the +-- rare stub that lost its draft), drop the stub (version FKs cascade), then +-- drop the `draft_only` column. +-- ON CONFLICT targets `draft_pkey_legacy` — (workspace_id, path, typ) WHERE +-- email IS NULL. + +INSERT INTO draft (workspace_id, path, typ, email, value) +SELECT + s.workspace_id, + s.path, + 'script'::DRAFT_KIND, + NULL, + jsonb_strip_nulls(jsonb_build_object( + 'path', s.path, + 'summary', s.summary, + 'description', s.description, + 'content', s.content, + 'language', s.language, + 'kind', s.kind, + 'tag', s.tag, + 'schema', s.schema, + 'envs', to_jsonb(s.envs), + 'concurrent_limit', s.concurrent_limit, + 'concurrency_time_window_s', s.concurrency_time_window_s, + 'concurrency_key', s.concurrency_key, + 'cache_ttl', s.cache_ttl, + 'cache_ignore_s3_path', s.cache_ignore_s3_path, + 'dedicated_worker', s.dedicated_worker, + 'ws_error_handler_muted', s.ws_error_handler_muted, + 'priority', s.priority, + 'timeout', s.timeout, + 'delete_after_use', s.delete_after_use, + 'restart_unless_cancelled', s.restart_unless_cancelled, + 'visible_to_runner_only', s.visible_to_runner_only, + 'auto_kind', s.auto_kind, + 'has_preprocessor', s.has_preprocessor, + 'on_behalf_of_email', s.on_behalf_of_email, + 'assets', s.assets, + 'debounce_key', s.debounce_key, + 'debounce_delay_s', s.debounce_delay_s, + 'labels', to_jsonb(s.labels), + 'draft_triggers', '[]'::jsonb + ))::json +FROM script s +WHERE s.draft_only IS TRUE AND s.deleted IS FALSE AND s.archived IS FALSE +ON CONFLICT (workspace_id, path, typ) WHERE email IS NULL DO NOTHING; + +INSERT INTO draft (workspace_id, path, typ, email, value) +SELECT + f.workspace_id, + f.path, + 'flow'::DRAFT_KIND, + NULL, + jsonb_strip_nulls(jsonb_build_object( + 'path', f.path, + 'summary', f.summary, + 'description', f.description, + 'value', f.value, + 'schema', f.schema, + 'tag', f.tag, + 'dedicated_worker', f.dedicated_worker, + 'timeout', f.timeout, + 'visible_to_runner_only', f.visible_to_runner_only, + 'on_behalf_of_email', f.on_behalf_of_email, + 'ws_error_handler_muted', f.ws_error_handler_muted, + 'labels', to_jsonb(f.labels), + 'draft_triggers', '[]'::jsonb + ))::json +FROM flow f +WHERE f.draft_only IS TRUE AND f.archived IS FALSE +ON CONFLICT (workspace_id, path, typ) WHERE email IS NULL DO NOTHING; + +-- App drafts store the editor's working value directly in `draft.value` (an +-- `App` object for `app`, a `{files, runnables, data}` object for `raw_app`). +-- The deployed wrapper's summary/policy/custom_path aren't part of the App +-- type and aren't restored from a draft on reload, so they're dropped here. +INSERT INTO draft (workspace_id, path, typ, email, value) +SELECT + a.workspace_id, + a.path, + CASE WHEN av.raw_app THEN 'raw_app'::DRAFT_KIND ELSE 'app'::DRAFT_KIND END, + NULL, + av.value +FROM app a +JOIN app_version av ON av.id = a.versions[array_upper(a.versions, 1)] +WHERE a.draft_only IS TRUE +ON CONFLICT (workspace_id, path, typ) WHERE email IS NULL DO NOTHING; + +DELETE FROM script WHERE draft_only IS TRUE; +DELETE FROM flow WHERE draft_only IS TRUE; +DELETE FROM app WHERE draft_only IS TRUE; + +ALTER TABLE script DROP COLUMN draft_only; +ALTER TABLE flow DROP COLUMN draft_only; +ALTER TABLE app DROP COLUMN draft_only; diff --git a/backend/tests/ci_tests.rs b/backend/tests/ci_tests.rs index c8551a30a5..46bed97094 100644 --- a/backend/tests/ci_tests.rs +++ b/backend/tests/ci_tests.rs @@ -10,6 +10,7 @@ mod ci_tests { fn quick_ns(content: &str, path: &str, parent_hash: Option) -> NewScript { NewScript { + draft_only: None, content: content.into(), language: ScriptLang::Python3, lock: None, @@ -20,7 +21,6 @@ mod ci_tests { cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: None, diff --git a/backend/tests/dependency_map.rs b/backend/tests/dependency_map.rs index 48a50ef9b9..0d58615be0 100644 --- a/backend/tests/dependency_map.rs +++ b/backend/tests/dependency_map.rs @@ -16,6 +16,7 @@ mod dependency_map { parent_hash: Option, ) -> NewScript { NewScript { + draft_only: None, content: content.into(), language, lock, @@ -26,7 +27,6 @@ mod dependency_map { cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: None, @@ -441,7 +441,6 @@ def main(): .unwrap(), ), schema: None, - draft_only: None, tag: None, dedicated_worker: None, timeout: None, diff --git a/backend/tests/relock_skip.rs b/backend/tests/relock_skip.rs index eb8dfb261d..8262a38cf4 100644 --- a/backend/tests/relock_skip.rs +++ b/backend/tests/relock_skip.rs @@ -14,6 +14,7 @@ mod relock_skip { parent_hash: Option, ) -> NewScript { NewScript { + draft_only: None, content: content.into(), language, lock, @@ -24,7 +25,6 @@ mod relock_skip { cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: None, diff --git a/backend/tests/script_auto_kind_failure.rs b/backend/tests/script_auto_kind_failure.rs index 4110bd4ba5..05d22e75ec 100644 --- a/backend/tests/script_auto_kind_failure.rs +++ b/backend/tests/script_auto_kind_failure.rs @@ -6,6 +6,7 @@ use windmill_test_utils::init_client; fn quick_ns(content: &str, path: &str, kind: Option<&str>) -> NewScript { NewScript { + draft_only: None, content: content.into(), language: ScriptLang::Bun, lock: None, @@ -16,7 +17,6 @@ fn quick_ns(content: &str, path: &str, kind: Option<&str>) -> NewScript { cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: kind.map(|s| s.to_string()), diff --git a/backend/tests/trigger_listener_queries.rs b/backend/tests/trigger_listener_queries.rs index d1c20da407..c3f6357d4c 100644 --- a/backend/tests/trigger_listener_queries.rs +++ b/backend/tests/trigger_listener_queries.rs @@ -200,7 +200,7 @@ async fn test_handler_queries_websocket(db: Pool) -> anyhow::Result<() assert_eq!(trigger.base.permissioned_as, "u/test-user"); let triggers = handler - .list_triggers(&mut *conn, "test-workspace", None) + .list_triggers(&mut *conn, "test-workspace", None, None) .await?; assert!(triggers.iter().any(|t| t.base.path == "f/test/handler_ws")); diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index f21862fd38..b220416b55 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -4178,8 +4178,8 @@ async fn test_flow_lock_all(db: Pool) -> anyhow::Result<()> { visible_to_runner_only: None, on_behalf_of_email: None, }, - draft_only: None, deployment_message: None, + draft_only: None, }, ) .await diff --git a/backend/tests/workspace_export.rs b/backend/tests/workspace_export.rs index 823ff22a43..ca988562fd 100644 --- a/backend/tests/workspace_export.rs +++ b/backend/tests/workspace_export.rs @@ -40,6 +40,7 @@ async fn test_tarball_export_all_tables(db: Pool) -> anyhow::Result<() .create_script( "test-workspace", &windmill_api_client::types::NewScript { + draft_only: None, content: "export function main() { return 42; }".to_string(), language: windmill_api_client::types::ScriptLang::Bun, path: "f/test_folder/test_script".to_string(), @@ -51,7 +52,6 @@ async fn test_tarball_export_all_tables(db: Pool) -> anyhow::Result<() parent_hash: None, schema: Default::default(), is_template: None, - draft_only: None, dedicated_worker: None, ws_error_handler_muted: None, priority: None, diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 4a31589baa..5a9087318b 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -21,7 +21,8 @@ use windmill_api_auth::{ }; use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; use windmill_common::{ - utils::{WithStarredInfoQuery, HTTP_CLIENT}, + user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay}, + utils::HTTP_CLIENT, webhook::{WebhookMessage, WebhookShared}, DB, }; @@ -49,7 +50,6 @@ use windmill_common::{ flows::{Flow, FlowWithStarred, ListFlowQuery, ListableFlow, NewFlow}, jobs::JobPayload, schedule::Schedule, - scripts::Schema, utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, RunnableKind, StripPath}, }; use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap; @@ -68,7 +68,6 @@ pub fn workspaced_service() -> Router { .route("/list_tokens/{*path}", get(list_tokens)) .route("/get/{*path}", get(get_flow_by_path)) .route("/deployment_status/p/{*path}", get(get_deployment_status)) - .route("/get/draft/{*path}", get(get_flow_by_path_w_draft)) .route("/exists/{*path}", get(exists_flow_by_path)) .route("/list_paths", get(list_paths)) .route("/history/p/{*path}", get(get_flow_history)) @@ -130,6 +129,7 @@ async fn list_search_flows( async fn list_flows( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, Query(pagination): Query, Query(lq): Query, @@ -151,10 +151,15 @@ async fn list_flows( "archived", "extra_perms", "favorite.path IS NOT NULL as starred", - "draft.path IS NOT NULL as has_draft", - "draft_only", "ws_error_handler_muted", "o.labels", + "draft.email IS NOT NULL as is_draft", + // Per-path draft owners as a JSON array; see scripts.rs for the rationale + // (admins-workspace identity fallback, legacy NULL-email row). + "(SELECT json_agg(json_build_object('username', COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END)) ORDER BY COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) NULLS LAST) \ + FROM draft d \ + LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \ + WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'flow') as draft_users", "folder_labels(o.workspace_id, o.path) as inherited_labels" ]) .left() @@ -166,7 +171,8 @@ async fn list_flows( .left() .join("draft") .on( - "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'flow'" + "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'flow' AND draft.email = ?" + .bind(&authed.email), ) .left() .join("flow_version fv") @@ -195,9 +201,6 @@ async fn list_flows( sqlb.and_where_is_not_null("favorite.path"); } - if !lq.include_draft_only.unwrap_or(false) || authed.is_operator { - sqlb.and_where("o.draft_only IS NOT TRUE"); - } if let Some(dw) = &lq.dedicated_worker { sqlb.and_where_eq("dedicated_worker", dw); } @@ -221,13 +224,92 @@ async fn list_flows( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let allowed = build_scope_path_predicate(&authed, "flows", "read"); - let rows = sqlx::query_as::<_, ListableFlow>(&sql) + let mut rows = sqlx::query_as::<_, ListableFlow>(&sql) .fetch_all(&mut *tx) .await? .into_iter() .filter(|r| allowed(&r.path)) .collect::>(); tx.commit().await?; + + // Append the authed user's drafts at paths with no deployed flow; see scripts.rs. + if lq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lq.path_start.is_none() + && lq.path_exact.is_none() + && lq.edited_by.is_none() + && lq.dedicated_worker.is_none() + && lq.label.is_none() + && !lq.starred_only.unwrap_or(false) + && !lq.show_archived.unwrap_or(false) + { + // `(email = $2 OR email IS NULL)` + `DISTINCT ON (path)` ordered NULL-last; see scripts.rs. + let draft_only_rows = sqlx::query!( + r#"SELECT DISTINCT ON (path) + path, + value as "value!: sqlx::types::Json>", + created_at + FROM draft + WHERE workspace_id = $1 + AND typ = 'flow' + AND (email = $2 OR email IS NULL) + AND NOT EXISTS ( + SELECT 1 FROM flow f + WHERE f.workspace_id = draft.workspace_id + AND f.path = draft.path + ) + ORDER BY path, (email IS NULL)"#, + &w_id, + &authed.email, + ) + .fetch_all(&db) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + // The Path widget binds `$pathStore` one-way (`flow.path → $pathStore`), + // so the editor writes a separate `draft_path` field only when the typed + // path differs from the deployed one. `None` = unchanged. + let draft_path = v + .get("draft_path") + .and_then(|s| s.as_str()) + .filter(|s| !s.is_empty() && *s != row.path.as_str()) + .map(|s| s.to_string()); + rows.push(ListableFlow { + workspace_id: w_id.clone(), + path: row.path, + summary: v + .get("summary") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(), + description: v + .get("description") + .and_then(|s| s.as_str()) + .map(|s| s.to_string()), + edited_by: Some(authed.email.clone()), + edited_at: Some(row.created_at), + archived: false, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + starred: false, + draft_only: Some(true), + ws_error_handler_muted: None, + deployment_msg: None, + labels: None, + // No deployed row to inherit folder labels from. + inherited_labels: None, + is_draft: true, + draft_path, + // Synthesized rows are the authed user's own draft. + draft_users: Some(sqlx::types::Json(vec![DraftUserRef { + username: Some(authed.username.clone()), + }])), + }); + } + } + Ok(Json(rows)) } @@ -517,22 +599,21 @@ async fn create_flow( sqlx::query!( r#"INSERT INTO flow ( workspace_id, path, summary, description, - dependency_job, lock_error_logs, draft_only, tag, + dependency_job, lock_error_logs, tag, dedicated_worker, visible_to_runner_only, on_behalf_of_email, ws_error_handler_muted, value, schema, edited_by, edited_at, labels ) VALUES ( $1, $2, $3, $4, - NULL, '', $5, $6, - $7, $8, $9, - $10, - $11, $12::text::json, $13, now(), $14 + NULL, '', $5, + $6, $7, $8, + $9, + $10, $11::text::json, $12, now(), $13 )"#, w_id, nf.path, nf.summary, nf.description.as_deref().unwrap_or(""), - nf.draft_only, nf.tag, nf.dedicated_worker, nf.visible_to_runner_only.unwrap_or(false), @@ -571,12 +652,15 @@ async fn create_flow( ).execute(&mut *tx).await?; // CLI / git-sync deploys ask us to preserve any existing user draft at this - // path instead of wiping it as part of the deploy. + // path instead of wiping it as part of the deploy. Only wipe the deployer's + // own draft (plus the legacy NULL-email row); see scripts.rs. if !nf.skip_draft_deletion.unwrap_or(false) { sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' \ + AND (email = $3 OR email IS NULL)", nf.path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -719,18 +803,6 @@ async fn check_schedule_conflict<'c>( Ok(()) } -pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> { - return windmill_api_auth::require_is_writer( - authed, - path, - w_id, - db, - "SELECT extra_perms FROM flow WHERE path = $1 AND workspace_id = $2", - "flow", - ) - .await; -} - #[derive(Serialize)] pub struct FlowVersion { pub id: i64, @@ -799,7 +871,7 @@ async fn get_flow_version( let mut tx = user_db.begin(&authed).await?; let flow = sqlx::query_as::<_, Flow>( - "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by + "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by FROM flow LEFT JOIN flow_version ON flow_version.path = flow.path AND flow_version.workspace_id = flow.workspace_id WHERE flow.path = $1 AND flow.workspace_id = $2 AND flow_version.id = $3", @@ -850,7 +922,6 @@ async fn get_flow_version_by_id( flow.description, flow.archived, flow.extra_perms, - flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, @@ -987,7 +1058,6 @@ async fn update_flow( description = $3, dependency_job = NULL, lock_error_logs = '', - draft_only = NULL, tag = $4, dedicated_worker = $5, visible_to_runner_only = $6, @@ -1029,8 +1099,8 @@ async fn update_flow( // if new path, must clone flow to new path and delete old flow for flow_version foreign key constraint sqlx::query!( "INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels) - SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels) + SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels FROM flow WHERE path = $2 AND workspace_id = $3", nf.path, @@ -1174,12 +1244,15 @@ async fn update_flow( } // CLI / git-sync deploys ask us to preserve any existing user draft at this - // path instead of wiping it as part of the deploy. + // path instead of wiping it as part of the deploy. Only wipe the deployer's + // own draft (plus the legacy NULL-email row); see scripts.rs. if !nf.skip_draft_deletion.unwrap_or(false) { sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' \ + AND (email = $3 OR email IS NULL)", flow_path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -1397,12 +1470,21 @@ async fn get_deployment_status( Ok(Json(deployment_status)) } +// Fields inlined rather than flattened (axum query bool quirk); see GetScriptByPathQuery in scripts.rs. +#[derive(Deserialize)] +struct GetFlowByPathQuery { + with_starred_info: Option, + #[serde(default)] + get_draft: bool, +} + async fn get_flow_by_path( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, - Query(query): Query, -) -> JsonResult { + Query(query): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("flows:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; @@ -1416,9 +1498,8 @@ async fn get_flow_by_path( flow.summary, flow.description, flow.archived, - flow.extra_perms, - flow.draft_only, - flow.dedicated_worker, + flow.extra_perms, + flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, @@ -1444,7 +1525,7 @@ async fn get_flow_by_path( "#, ) .bind(path) - .bind(w_id) + .bind(&w_id) .bind(&authed.username) .fetch_optional(&mut *tx) .await? @@ -1458,9 +1539,8 @@ async fn get_flow_by_path( flow.summary, flow.description, flow.archived, - flow.extra_perms, - flow.draft_only, - flow.dedicated_worker, + flow.extra_perms, + flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, @@ -1481,90 +1561,26 @@ async fn get_flow_by_path( "#, ) .bind(path) - .bind(w_id) + .bind(&w_id) .fetch_optional(&mut *tx) .await? }; tx.commit().await?; - let flow = not_found_if_none(flow_o, "Flow", path)?; - Ok(Json(flow)) -} - -#[derive(Serialize, sqlx::FromRow)] -pub struct FlowWDraft { - pub path: String, - pub summary: String, - pub description: String, - pub schema: Option, - pub value: sqlx::types::Json>, - pub extra_perms: serde_json::Value, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft: Option>>, - /// Timestamp at which the most recent DB draft was created. - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_created_at: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tag: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ws_error_handler_muted: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub dedicated_worker: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub visible_to_runner_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub on_behalf_of_email: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub labels: Option>, -} - -async fn get_flow_by_path_w_draft( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { - let path = path.to_path(); - check_scopes(&authed, || format!("flows:read:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - let flow_o = sqlx::query_as::<_, FlowWDraft>( - "SELECT - flow.path, - flow.summary, - flow.description, - flow_version.schema, - flow_version.value, - flow.extra_perms, - flow.draft_only, - flow.ws_error_handler_muted, - flow.dedicated_worker, - draft.value AS draft, - draft.created_at AS draft_created_at, - flow.tag, - flow.visible_to_runner_only, - flow.on_behalf_of_email, - flow.labels - FROM flow - LEFT JOIN draft - ON flow.path = draft.path - AND draft.workspace_id = $2 - AND draft.typ = 'flow' - LEFT JOIN flow_version - ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] - WHERE flow.path = $1 - AND flow.workspace_id = $2", + // No deployed row + `get_draft`: fall back to the draft table; see scripts.rs. + let overlay = overlay_or_draft_only( + &db, + &w_id, + &authed.email, + UserDraftItemKind::Flow, + path, + query.get_draft, + flow_o, + || windmill_common::error::Error::NotFound(format!("Flow not found at path {path}")), ) - .bind(path) - .bind(w_id) - .fetch_optional(&mut *tx) .await?; - - tx.commit().await?; - - let flow = not_found_if_none(flow_o, "Flow", path)?; - Ok(Json(flow)) + Ok(Json(overlay)) } async fn exists_flow_by_path( diff --git a/backend/windmill-api-integration-tests/tests/apps.rs b/backend/windmill-api-integration-tests/tests/apps.rs index 63bd96345b..3d70c09ce1 100644 --- a/backend/windmill-api-integration-tests/tests/apps.rs +++ b/backend/windmill-api-integration-tests/tests/apps.rs @@ -82,12 +82,6 @@ async fn test_app_endpoints(db: Pool) -> anyhow::Result<()> { let resp = authed_get(port, "get/p", "u/test-user/nonexistent").await; assert_eq!(resp.status(), 404); - // --- get draft --- - let resp = authed_get(port, "get/draft", "u/test-user/test_app").await; - assert_eq!(resp.status(), 200); - let body = resp.json::().await?; - assert_eq!(body["path"], "u/test-user/test_app"); - // --- get lite --- let resp = authed_get(port, "get/lite", "u/test-user/test_app").await; assert_eq!(resp.status(), 200); diff --git a/backend/windmill-api-integration-tests/tests/drafts.rs b/backend/windmill-api-integration-tests/tests/drafts.rs index d86c91b6a1..b3bac8d010 100644 --- a/backend/windmill-api-integration-tests/tests/drafts.rs +++ b/backend/windmill-api-integration-tests/tests/drafts.rs @@ -1,105 +1,297 @@ use serde_json::json; use sqlx::{Pool, Postgres}; - use windmill_test_utils::*; -fn client() -> reqwest::Client { - reqwest::Client::new() +const WS: &str = "test-workspace"; + +/// A reqwest client that sends `Authorization: Bearer `. The base +/// fixture seeds: SECRET_TOKEN (test-user, admin), SECRET_TOKEN_2 +/// (test-user-2, non-admin), SECRET_TOKEN_3 (test-user-3, non-admin). +fn client_for(token: &str) -> reqwest::Client { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), + ); + reqwest::ClientBuilder::new() + .default_headers(headers) + .build() + .unwrap() } -fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { - builder.header("Authorization", "Bearer SECRET_TOKEN") +fn save_url(port: u16, kind: &str, path: &str) -> String { + format!("http://localhost:{port}/api/w/{WS}/drafts/update/{kind}/{path}") } +async fn draft_count(db: &Pool, path: &str, kind: &str, email: &str) -> i64 { + sqlx::query_scalar::<_, i64>( + "SELECT count(*) FROM draft WHERE workspace_id = $1 AND path = $2 \ + AND typ = $3::text::DRAFT_KIND AND email = $4", + ) + .bind(WS) + .bind(path) + .bind(kind) + .bind(email) + .fetch_one(db) + .await + .unwrap() +} + +/// Upsert → conflict (stale last_sync) → force-overwrite → delete, the +/// optimistic-concurrency contract `update_draft` exists to enforce. #[sqlx::test(migrations = "../migrations", fixtures("base"))] -async fn test_draft_endpoints(db: Pool) -> anyhow::Result<()> { +async fn test_update_draft_conflict_lifecycle(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; let server = ApiServer::start(db.clone()).await?; let port = server.addr.port(); - let base = format!("http://localhost:{port}/api/w/test-workspace/drafts"); + let c = client_for("SECRET_TOKEN"); + let path = "u/test-user/draft_x"; + let url = save_url(port, "script", path); - // create a script first so the draft has a valid path - let resp = authed(client().post(format!( - "http://localhost:{port}/api/w/test-workspace/scripts/create" - ))) - .json(&json!({ - "path": "u/test-user/draft_script", - "summary": "Script for draft test", - "description": "", - "content": "export async function main() { return 1; }", - "language": "deno", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": {}, - "required": [] - } - })) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 201, "create script: {}", resp.text().await?); - - // --- create draft --- - let resp = authed(client().post(format!("{base}/create"))) - .json(&json!({ - "path": "u/test-user/draft_script", - "typ": "script", - "value": { - "content": "export async function main() { return 2; }", - "language": "deno" - } - })) + // First save: no last_sync ("treat as fresh") → saved. + let r = c + .post(&url) + .json(&json!({ "value": { "n": 1 } })) .send() - .await - .unwrap(); - assert_eq!(resp.status(), 201, "create draft: {}", resp.text().await?); + .await?; + assert_eq!(r.status(), 200, "first save"); + let body: serde_json::Value = r.json().await?; + assert_eq!(body["status"], "saved"); + let ts1 = body["current_timestamp"].as_str().unwrap().to_string(); + assert_eq!( + draft_count(&db, path, "script", "test@windmill.dev").await, + 1 + ); - // verify draft exists via script get/draft endpoint - let resp = authed(client().get(format!( - "http://localhost:{port}/api/w/test-workspace/scripts/get/draft/u/test-user/draft_script" - ))) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200); - let body = resp.json::().await?; - assert!(body["draft"].is_object(), "expected draft to be present"); + // A tiny gap so the next now() is strictly greater than ts1. + tokio::time::sleep(std::time::Duration::from_millis(15)).await; - // --- update draft (create with same path overwrites) --- - let resp = authed(client().post(format!("{base}/create"))) - .json(&json!({ - "path": "u/test-user/draft_script", - "typ": "script", - "value": { - "content": "export async function main() { return 3; }", - "language": "deno" - } - })) + // Save with the matching last_sync → not stale → saved, newer ts. + let r = c + .post(&url) + .json(&json!({ "value": { "n": 2 }, "last_sync": ts1 })) .send() - .await - .unwrap(); - assert_eq!(resp.status(), 201); + .await?; + let body: serde_json::Value = r.json().await?; + assert_eq!(body["status"], "saved", "in-order save"); + let ts2 = body["current_timestamp"].as_str().unwrap().to_string(); + assert_ne!(ts1, ts2, "timestamp should advance"); - // --- delete draft --- - let resp = authed(client().delete(format!( - "{base}/delete/script/u/test-user/draft_script" - ))) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200); + // Save with the now-stale ts1 → conflict, server reports its current ts. + let r = c + .post(&url) + .json(&json!({ "value": { "n": 3 }, "last_sync": ts1 })) + .send() + .await?; + let body: serde_json::Value = r.json().await?; + assert_eq!(body["status"], "conflict", "stale save must conflict"); + assert_eq!(body["current_timestamp"].as_str().unwrap(), ts2); - // verify draft is gone - let resp = authed(client().get(format!( - "http://localhost:{port}/api/w/test-workspace/scripts/get/draft/u/test-user/draft_script" - ))) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200); - let body = resp.json::().await?; - assert!(body["draft"].is_null(), "expected draft to be deleted"); + // The conflicting write must NOT have landed — value is still {n:2}. + let stored: serde_json::Value = sqlx::query_scalar::<_, sqlx::types::Json>( + "SELECT value FROM draft WHERE workspace_id = $1 AND path = $2 \ + AND typ = 'script' AND email = 'test@windmill.dev'", + ) + .bind(WS) + .bind(path) + .fetch_one(&db) + .await? + .0; + assert_eq!(stored["n"], 2, "conflicting write must be rejected"); + + // force = true overrides the conflict check. + let r = c + .post(&url) + .json(&json!({ "value": { "n": 3 }, "last_sync": ts1, "force": true })) + .send() + .await?; + assert_eq!( + r.json::().await?["status"], + "saved", + "force" + ); + + // Delete (value: null) → saved, row gone. + let r = c.post(&url).json(&json!({ "value": null })).send().await?; + assert_eq!( + r.json::().await?["status"], + "saved", + "delete" + ); + assert_eq!( + draft_count(&db, path, "script", "test@windmill.dev").await, + 0, + "row removed after delete" + ); + + Ok(()) +} + +/// require_can_write_path: own namespace allowed, another user's namespace +/// rejected, operators rejected outright. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_update_draft_write_authorization(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let user2 = client_for("SECRET_TOKEN_2"); // test-user-2, non-admin + + // Own namespace → allowed. + let r = user2 + .post(save_url(port, "script", "u/test-user-2/own")) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!(r.status(), 200, "own namespace allowed"); + + // Another user's namespace, no grant → rejected. + let r = user2 + .post(save_url(port, "script", "u/test-user/theirs")) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!(r.status(), 401, "other user's namespace rejected"); + + // Operators can't save drafts at all. + sqlx::query( + "UPDATE usr SET operator = true WHERE workspace_id = $1 AND username = 'test-user-3'", + ) + .bind(WS) + .execute(&db) + .await?; + let op = client_for("SECRET_TOKEN_3"); + let r = op + .post(save_url(port, "script", "u/test-user-3/own")) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!(r.status(), 401, "operator rejected"); + + Ok(()) +} + +/// The item-level extra_perms fallback: a user granted write on a deployed +/// item (via the Share dialog) can save a draft on it even though it's +/// outside their namespace. Regression test for the authz drop. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_update_draft_extra_perms_writer(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let path = "u/test-user/shared"; + + // A deployed script owned by test-user, shared with test-user-2 (write). + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, summary, description, content, \ + language, schema, extra_perms, created_by) \ + VALUES ($1, 1, $2, '', '', 'x', 'deno', '{}'::jsonb, \ + '{\"u/test-user-2\": true}'::jsonb, 'test-user')", + ) + .bind(WS) + .bind(path) + .execute(&db) + .await?; + + let user2 = client_for("SECRET_TOKEN_2"); + let r = user2 + .post(save_url(port, "script", path)) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!( + r.status(), + 200, + "extra_perms writer can save a draft: {}", + r.text().await? + ); + + // Without a grant on a different shared item → still rejected. + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, summary, description, content, \ + language, schema, extra_perms, created_by) \ + VALUES ($1, 2, 'u/test-user/private', '', '', 'x', 'deno', '{}'::jsonb, \ + '{}'::jsonb, 'test-user')", + ) + .bind(WS) + .execute(&db) + .await?; + let r = user2 + .post(save_url(port, "script", "u/test-user/private")) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!(r.status(), 401, "no grant → rejected"); + + // A READ-ONLY grant (`extra_perms` value false) must not allow draft + // saves: the write check defers to RLS via `SELECT ... FOR UPDATE`, + // and locking applies the UPDATE policies — visibility under the + // SELECT policy alone isn't enough. Pins the FOR UPDATE semantics the + // probe relies on. + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, summary, description, content, \ + language, schema, extra_perms, created_by) \ + VALUES ($1, 3, 'u/test-user/readonly', '', '', 'x', 'deno', '{}'::jsonb, \ + '{\"u/test-user-2\": false}'::jsonb, 'test-user')", + ) + .bind(WS) + .execute(&db) + .await?; + let r = user2 + .post(save_url(port, "script", "u/test-user/readonly")) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!(r.status(), 401, "read-only grant → rejected"); + + Ok(()) +} + +/// Cross-user draft viewing (`GET /drafts/get/{kind}/{path}`) is disabled +/// for the drawer kinds (resource/variable/triggers) so a viewer can't +/// read another user's draft; it stays available for script/flow/app. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_cross_user_draft_privacy(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // test-user saves a variable draft and a script draft in a shared folder. + let admin = client_for("SECRET_TOKEN"); + admin + .post(save_url(port, "variable", "f/shared/v")) + .json(&json!({ "value": { "variable": { "value": "x", "is_secret": false } } })) + .send() + .await?; + + let user2 = client_for("SECRET_TOKEN_2"); + // Variable is a drawer kind → cross-user view is forbidden regardless of + // path access (the kind gate fires first). + let r = user2 + .get(format!( + "http://localhost:{port}/api/w/{WS}/drafts/get/variable/f/shared/v?username=test-user" + )) + .send() + .await?; + assert_eq!( + r.status(), + 404, + "variable drafts are private to their owner" + ); + + // Sharing kinds (script) are NOT gated by the kind check — a missing + // draft / no access yields 404 too, but the "private to their owner" + // wording is specific to the drawer kinds, so assert it's absent here. + let r = user2 + .get(format!( + "http://localhost:{port}/api/w/{WS}/drafts/get/script/f/shared/s?username=test-user" + )) + .send() + .await?; + let body = r.text().await?; + assert!( + !body.contains("private to their owner"), + "script kind must not be blocked by the cross-user privacy gate: {body}" + ); Ok(()) } diff --git a/backend/windmill-api-integration-tests/tests/flows.rs b/backend/windmill-api-integration-tests/tests/flows.rs index b6075c8e69..774c118e7b 100644 --- a/backend/windmill-api-integration-tests/tests/flows.rs +++ b/backend/windmill-api-integration-tests/tests/flows.rs @@ -82,12 +82,6 @@ async fn test_flow_endpoints(db: Pool) -> anyhow::Result<()> { let resp = authed_get(port, "get", "u/test-user/nonexistent").await; assert_eq!(resp.status(), 404); - // --- get draft --- - let resp = authed_get(port, "get/draft", "u/test-user/test_flow").await; - assert_eq!(resp.status(), 200); - let body = resp.json::().await?; - assert_eq!(body["path"], "u/test-user/test_flow"); - // --- list --- let resp = authed(client().get(format!("{base}/list"))) .send() diff --git a/backend/windmill-api-integration-tests/tests/scripts.rs b/backend/windmill-api-integration-tests/tests/scripts.rs index c374b757a4..e5f6cb8aa8 100644 --- a/backend/windmill-api-integration-tests/tests/scripts.rs +++ b/backend/windmill-api-integration-tests/tests/scripts.rs @@ -98,12 +98,6 @@ async fn test_script_endpoints(db: Pool) -> anyhow::Result<()> { let body = resp.json::().await?; assert_eq!(body["path"], "u/test-user/test_script"); - // --- get draft --- - let resp = authed_get(port, "get/draft", "u/test-user/test_script").await; - assert_eq!(resp.status(), 200); - let body = resp.json::().await?; - assert_eq!(body["path"], "u/test-user/test_script"); - // --- raw by path (requires language extension) --- let resp = authed_get(port, "raw/p", "u/test-user/test_script.ts").await; assert_eq!(resp.status(), 200); diff --git a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs index 82447daee2..a0f7f7d460 100644 --- a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs +++ b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs @@ -92,8 +92,8 @@ async fn test_compare_workspaces_comprehensive(db: Pool) -> anyhow::Re // Create app sqlx::query!( - "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, draft_only) - VALUES ('test-workspace', 'f/shared/dashboard', 'Dashboard app', '{}', ARRAY[1::bigint], '{}', false)" + "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms) + VALUES ('test-workspace', 'f/shared/dashboard', 'Dashboard app', '{}', ARRAY[1::bigint], '{}')" ) .execute(&db) .await?; diff --git a/backend/windmill-api-openapi/Cargo.toml b/backend/windmill-api-openapi/Cargo.toml index ee1727c059..24a6f2aed8 100644 --- a/backend/windmill-api-openapi/Cargo.toml +++ b/backend/windmill-api-openapi/Cargo.toml @@ -11,7 +11,12 @@ path = "src/lib.rs" [dependencies] windmill-api-auth.workspace = true windmill-common = { workspace = true, default-features = false } -windmill-store.workspace = true +# `try_get_resource_from_db_as` is used unconditionally below but is +# cfg-gated behind a trigger feature in windmill-store; forward +# `http_trigger` so the import resolves even when this crate's targets are +# built in isolation (e.g. `--all-targets` under resolver 2), not only via +# whole-workspace feature unification. +windmill-store = { workspace = true, features = ["http_trigger"] } windmill-trigger-http.workspace = true anyhow.workspace = true axum.workspace = true diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index 35f71e561e..7cc0111c28 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -25,6 +25,10 @@ use windmill_common::{ db::UserDB, error::{Error, JsonResult, Result}, schedule::Schedule, + user_drafts::{ + delete_all_drafts_for_path, fetch_draft_only_list_rows, overlay_or_draft_only, + UserDraftItemKind, WithDraftOverlay, WithDraftQuery, + }, utils::{ escape_ilike_pattern, not_found_if_none, paginate, Pagination, ScheduleType, StripPath, }, @@ -678,6 +682,9 @@ pub struct ListScheduleQuery { pub summary: Option, pub broad_filter: Option, pub label: Option, + /// When true, append per-user draft-only rows; picker callers leave it off + /// to stay deployed-only. See list synthesis in scripts.rs. + pub include_draft_only: Option, } #[derive(sqlx::FromRow, Serialize, Deserialize, Debug, Clone)] @@ -695,6 +702,15 @@ pub struct ScheduleLight { pub extra_perms: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// `Some(true)` only on synthesized draft-only rows; `None` on deployed rows. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub draft_only: Option, + /// True when the authed user has a per-user draft at this path (drives the + /// `*` suffix on the schedules page). + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub is_draft: Option, /// Labels inherited from the parent folder, computed at read time. #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] @@ -703,6 +719,7 @@ pub struct ScheduleLight { async fn list_schedule( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, Query(lsq): Query, ) -> JsonResult> { @@ -724,13 +741,20 @@ async fn list_schedule( "labels", "folder_labels(workspace_id, path) as inherited_labels", ]) + // Scalar EXISTS flags the authed user's per-user draft; see resources.rs. + .field( + &"EXISTS(SELECT 1 FROM draft WHERE draft.workspace_id = schedule.workspace_id \ + AND draft.path = schedule.path AND draft.typ = 'trigger_schedule' \ + AND draft.email = ?) as is_draft" + .bind(&authed.email), + ) .order_by("edited_at", true) .and_where("workspace_id = ?".bind(&w_id)) .offset(offset) .limit(per_page) .clone(); - if let Some(path) = lsq.path { - sqlb.and_where_eq("script_path", "?".bind(&path)); + if let Some(path) = lsq.path.as_ref() { + sqlb.and_where_eq("script_path", "?".bind(path)); } if let Some(is_flow) = lsq.is_flow { sqlb.and_where_eq("is_flow", "?".bind(&is_flow)); @@ -773,10 +797,96 @@ async fn list_schedule( } } let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; - let rows = sqlx::query_as::<_, ScheduleLight>(&sql) + let mut rows = sqlx::query_as::<_, ScheduleLight>(&sql) .fetch_all(&mut *tx) .await?; tx.commit().await?; + + // Append the authed user's draft-only schedules; see scripts.rs. + if lsq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lsq.path.is_none() + && lsq.is_flow.is_none() + && lsq.args.is_none() + && lsq.path_start.is_none() + && lsq.schedule_path.is_none() + && lsq.description.is_none() + && lsq.summary.is_none() + && lsq.broad_filter.is_none() + && lsq.label.is_none() + { + let draft_only_rows = fetch_draft_only_list_rows( + &db, + &w_id, + &authed.email, + UserDraftItemKind::TriggerSchedule, + ) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + // Schedule editor's draft mirrors NewSchedule: { path, schedule, timezone, script_path, is_flow, enabled?, summary?, labels? } + let path = v + .get("path") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + if path.is_empty() { + continue; + } + let schedule = v + .get("schedule") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + let timezone = v + .get("timezone") + .and_then(|x| x.as_str()) + .unwrap_or("UTC") + .to_string(); + let script_path = v + .get("script_path") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + let is_flow = v.get("is_flow").and_then(|x| x.as_bool()).unwrap_or(false); + let enabled = v.get("enabled").and_then(|x| x.as_bool()).unwrap_or(true); + let summary = v + .get("summary") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()); + let labels = v.get("labels").and_then(|x| { + x.as_array().map(|arr| { + arr.iter() + .filter_map(|s| s.as_str().map(|s| s.to_string())) + .collect::>() + }) + }); + + rows.push(ScheduleLight { + workspace_id: w_id.clone(), + path, + edited_by: String::new(), + edited_at: row.created_at, + schedule, + timezone, + enabled, + script_path, + is_flow, + summary, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + labels, + // No deployed row to inherit folder labels from. + inherited_labels: None, + draft_only: Some(true), + // Synthesized rows are the authed user's draft. + is_draft: Some(true), + }); + } + } + Ok(Json(rows)) } @@ -839,16 +949,28 @@ async fn list_schedule_with_jobs( async fn get_schedule( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { + Query(q): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("schedules:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; let schedule_o = windmill_queue::schedule::get_schedule_opt(&mut *tx, &w_id, path).await?; - let schedule = not_found_if_none(schedule_o, "Schedule", path)?; tx.commit().await?; - Ok(Json(schedule)) + let overlay = overlay_or_draft_only( + &db, + &w_id, + &authed.email, + UserDraftItemKind::TriggerSchedule, + path, + q.get_draft, + schedule_o, + || Error::NotFound(format!("Schedule not found at path {path}")), + ) + .await?; + Ok(Json(overlay)) } async fn exists_schedule( @@ -1137,6 +1259,9 @@ async fn delete_schedule( tx.commit().await?; + // Schedule gone for everyone: wipe ALL users' drafts at this path; see scripts.rs. + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::TriggerSchedule, path).await?; + handle_deployment_metadata( &authed.email, &authed.username, diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index ce375fa64f..944329c316 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -13,6 +13,7 @@ use windmill_api_auth::{ ApiAuthed, }; use windmill_common::{ + user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay}, utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT}, webhook::{WebhookMessage, WebhookShared}, workspaces::{check_deploy_rules, RuleCheckResult}, @@ -33,7 +34,6 @@ use itertools::Itertools; use quick_cache::sync::Cache; use serde::{Deserialize, Serialize}; use serde_json::json; -use serde_json::value::RawValue; use sql_builder::prelude::*; use sqlx::{FromRow, Postgres, Transaction}; use std::{collections::HashMap, sync::Arc}; @@ -45,7 +45,7 @@ use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap; use windmill_common::{ assets::{ clear_static_asset_usage, clear_static_asset_usage_by_script_hash, - insert_static_asset_usage, AssetUsageKind, AssetWithAltAccessType, + insert_static_asset_usage, AssetUsageKind, }, error::{self, to_anyhow}, min_version::{MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2}, @@ -82,122 +82,6 @@ use windmill_queue::{ const MAX_HASH_HISTORY_LENGTH_STORED: usize = 20; -#[derive(Serialize, sqlx::FromRow)] -pub struct ScriptWDraft { - pub hash: ScriptHash, - pub path: String, - pub summary: String, - pub description: String, - pub content: String, - pub language: ScriptLang, - pub kind: ScriptKind, - pub tag: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft: Option>>, - /// Timestamp at which the most recent DB draft was created. - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_created_at: Option>, - pub schema: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub envs: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub cache_ttl: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cache_ignore_s3_path: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub dedicated_worker: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ws_error_handler_muted: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub priority: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub restart_unless_cancelled: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub delete_after_use: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub delete_after_secs: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub timeout: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub visible_to_runner_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub auto_kind: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub has_preprocessor: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub on_behalf_of_email: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[sqlx(json(nullable))] - pub assets: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[sqlx(json(nullable))] - pub modules: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub labels: Option>, - #[serde(flatten)] - #[sqlx(flatten)] - pub runnable_settings: SR, -} - -impl ScriptWDraft { - pub async fn prefetch_cached<'a>( - self, - db: &DB, - ) -> error::Result> { - let (debouncing_settings, concurrency_settings) = - windmill_common::runnable_settings::prefetch_cached_from_handle( - self.runnable_settings.runnable_settings_handle, - db, - ) - .await?; - - Ok(ScriptWDraft { - runnable_settings: ScriptRunnableSettingsInline { - concurrency_settings: concurrency_settings.maybe_fallback( - self.runnable_settings.concurrency_key, - self.runnable_settings.concurrent_limit, - self.runnable_settings.concurrency_time_window_s, - ), - debouncing_settings: debouncing_settings.maybe_fallback( - self.runnable_settings.debounce_key, - self.runnable_settings.debounce_delay_s, - ), - }, - hash: self.hash, - path: self.path, - summary: self.summary, - description: self.description, - content: self.content, - language: self.language, - kind: self.kind, - tag: self.tag, - draft: self.draft, - draft_created_at: self.draft_created_at, - schema: self.schema, - draft_only: self.draft_only, - envs: self.envs, - cache_ttl: self.cache_ttl, - cache_ignore_s3_path: self.cache_ignore_s3_path, - dedicated_worker: self.dedicated_worker, - ws_error_handler_muted: self.ws_error_handler_muted, - priority: self.priority, - restart_unless_cancelled: self.restart_unless_cancelled, - delete_after_use: self.delete_after_use, - delete_after_secs: self.delete_after_secs, - timeout: self.timeout, - visible_to_runner_only: self.visible_to_runner_only, - auto_kind: self.auto_kind, - has_preprocessor: self.has_preprocessor, - on_behalf_of_email: self.on_behalf_of_email, - assets: self.assets, - modules: self.modules, - labels: self.labels, - }) - } -} - pub fn global_service() -> Router { Router::new() .route("/hub/top", get(get_top_hub_scripts)) @@ -222,7 +106,6 @@ pub fn workspaced_service() -> Router { .route("/create", post(create_script)) .route("/create_snapshot", post(create_snapshot_script)) .route("/archive/p/{*path}", post(archive_script_by_path)) - .route("/get/draft/{*path}", get(get_script_by_path_w_draft)) .route("/get/p/{*path}", get(get_script_by_path)) .route("/list_tokens/{*path}", get(list_tokens)) .route("/raw/p/{*path}", get(raw_script_by_path)) @@ -295,6 +178,7 @@ async fn list_search_scripts( async fn list_scripts( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, Query(pagination): Query, Query(lq): Query, @@ -305,7 +189,7 @@ async fn list_scripts( "hash", "o.path", "summary", - "COALESCE(draft.created_at, o.created_at) as created_at", + "o.created_at as created_at", "archived", "extra_perms", if !lq.without_description.unwrap_or(false) { @@ -317,13 +201,22 @@ async fn list_scripts( "language", "favorite.path IS NOT NULL as starred", "tag", - "draft.path IS NOT NULL as has_draft", - "draft_only", "ws_error_handler_muted", "auto_kind", "codebase IS NOT NULL as use_codebase", "kind", "o.labels", + "draft.email IS NOT NULL as is_draft", + // Canonical reference for the draft-feature comments; flows/apps point here. + // Per-path draft owners as a JSON array (`Json>`); NULL -> None, + // never an empty array. LEFT JOIN `usr` keeps orphaned drafts (user left workspace) + // visible with `username = None`. In the `admins` workspace username IS the email, + // so fall back to `d.email` there or the authed user's own draft resolves to a phantom + // "Legacy workspace draft"; the genuine NULL-email legacy row stays None. + "(SELECT json_agg(json_build_object('username', COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END)) ORDER BY COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) NULLS LAST) \ + FROM draft d \ + LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \ + WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'script') as draft_users", "folder_labels(o.workspace_id, o.path) as inherited_labels" ]) .left() @@ -335,7 +228,8 @@ async fn list_scripts( .left() .join("draft") .on( - "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'script'" + "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'script' AND draft.email = ?" + .bind(&authed.email), ) .order_desc("favorite.path IS NOT NULL") .order_by("created_at", lq.order_desc.unwrap_or(true)) @@ -361,10 +255,6 @@ async fn list_scripts( sqlb.and_where("(o.auto_kind IS NULL OR o.auto_kind <> 'lib')"); } - if !lq.include_draft_only.unwrap_or(false) || authed.is_operator { - sqlb.and_where("draft_only IS NOT TRUE"); - } - if lq.show_archived.unwrap_or(false) { sqlb.and_where_eq( "o.ctid", @@ -434,7 +324,7 @@ async fn list_scripts( .fields(&["dm.deployment_msg"]); } - if let Some(languages) = lq.languages { + if let Some(languages) = &lq.languages { sqlb.and_where_in( "language", &languages @@ -447,13 +337,114 @@ async fn list_scripts( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let allowed = build_scope_path_predicate(&authed, "scripts", "read"); - let rows = sqlx::query_as::<_, ListableScript>(&sql) + let mut rows = sqlx::query_as::<_, ListableScript>(&sql) .fetch_all(&mut *tx) .await? .into_iter() .filter(|r| allowed(&r.path)) .collect::>(); tx.commit().await?; + + // Canonical reference for draft-only synthesis; the other kinds point here. + // Append the authed user's drafts at paths with no deployed script. Gated on + // `include_draft_only` so picker callers stay deployed-only (home page opts in); + // skipped past page 0 or under any narrowing filter to keep pagination clean. + if lq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lq.path_start.is_none() + && lq.path_exact.is_none() + && lq.created_by.is_none() + && lq.first_parent_hash.is_none() + && lq.last_parent_hash.is_none() + && lq.parent_hash.is_none() + && lq.is_template.is_none() + && lq.dedicated_worker.is_none() + && lq.label.is_none() + && lq.languages.is_none() + && !lq.starred_only.unwrap_or(false) + && !lq.show_archived.unwrap_or(false) + { + // `(email = $2 OR email IS NULL)` surfaces the user's own draft-only rows plus + // legacy NULL-email workspace rows; `DISTINCT ON (path)` ordered `email IS NULL` + // last collapses a path holding both to the owned row. + let draft_only_rows = sqlx::query!( + r#"SELECT DISTINCT ON (path) + path, + value as "value!: sqlx::types::Json>", + created_at + FROM draft + WHERE workspace_id = $1 + AND typ = 'script' + AND (email = $2 OR email IS NULL) + AND NOT EXISTS ( + SELECT 1 FROM script s + WHERE s.workspace_id = draft.workspace_id + AND s.path = draft.path + ) + ORDER BY path, (email IS NULL)"#, + &w_id, + &authed.email, + ) + .fetch_all(&db) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + let language: ScriptLang = v + .get("language") + .and_then(|x| serde_json::from_value(x.clone()).ok()) + .unwrap_or_default(); + let kind: ScriptKind = v + .get("kind") + .and_then(|x| serde_json::from_value(x.clone()).ok()) + .unwrap_or(ScriptKind::Script); + // Scripts bind the Path widget to `script.path`, so the typed path + // round-trips through the draft JSON's own `path` (no `draft_path` field). + let draft_path = v + .get("path") + .and_then(|s| s.as_str()) + .filter(|s| !s.is_empty() && *s != row.path.as_str()) + .map(|s| s.to_string()); + rows.push(ListableScript { + hash: ScriptHash(0), + path: row.path, + summary: v + .get("summary") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(), + created_at: row.created_at, + archived: false, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + language, + starred: false, + tag: v.get("tag").and_then(|s| s.as_str()).map(|s| s.to_string()), + description: v + .get("description") + .and_then(|s| s.as_str()) + .map(|s| s.to_string()), + draft_only: Some(true), + has_deploy_errors: false, + ws_error_handler_muted: None, + auto_kind: None, + use_codebase: false, + deployment_msg: None, + kind, + labels: None, + // Synthesized rows have no deployed row to inherit folder labels from. + inherited_labels: None, + is_draft: true, + draft_path, + // Synthesized rows are the authed user's own draft (single-user case). + draft_users: Some(sqlx::types::Json(vec![DraftUserRef { + username: Some(authed.username.clone()), + }])), + }); + } + } + Ok(Json(rows)) } @@ -721,7 +712,6 @@ async fn is_noop_deploy_against_parent( language, kind, tag, - draft_only, envs, concurrency_settings, debouncing_settings, @@ -798,7 +788,6 @@ async fn is_noop_deploy_against_parent( || visible_to_runner_only != &parent.visible_to_runner_only || has_preprocessor != &parent.has_preprocessor || is_template.unwrap_or(false) != parent.is_template.unwrap_or(false) - || draft_only.unwrap_or(false) != parent.draft_only.unwrap_or(false) { return Ok(false); } @@ -1026,20 +1015,10 @@ async fn create_script_internal<'c>( let parent_hashes_and_perms: Option = match (&ns.parent_hash, clashing_script) { (None, None) => Ok(None), - (None, Some(s)) if !s.draft_only.unwrap_or(false) => Err(Error::BadRequest(format!( + (None, Some(s)) => Err(Error::BadRequest(format!( "Path conflict for {} with non-archived hash {}", &ns.path, &s.hash ))), - (None, Some(s)) => { - sqlx::query!( - "DELETE FROM script WHERE hash = $1 AND workspace_id = $2", - s.hash.0, - &w_id - ) - .execute(&mut *tx) - .await?; - Ok(None) - } (Some(p_hash), o) => { // Lock the parent row to prevent concurrent updates with the same parent_hash // This ensures linear lineage - only one script can have a given parent at a time @@ -1292,10 +1271,10 @@ async fn create_script_internal<'c>( sqlx::query!( "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, \ content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, \ - draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \ + envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \ dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41)", + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40)", &w_id, &hash.0, ns.path, @@ -1311,7 +1290,6 @@ async fn create_script_internal<'c>( lang as ScriptLang, ns.kind.unwrap_or(ScriptKind::Script) as ScriptKind, ns.tag, - ns.draft_only, envs, guarded_concurrent_limit, guarded_concurrency_time_window_s, @@ -1376,10 +1354,15 @@ async fn create_script_internal<'c>( let p_path_opt = parent_hashes_and_perms.as_ref().map(|x| x.p_path.clone()); if let Some(ref p_path) = p_path_opt { if !skip_draft_deletion { + // Canonical: on deploy only wipe the deployer's own draft (plus the legacy + // NULL-email row). Teammates' drafts are independent — they stay and fire the + // StaleDraftModal on the teammate's next reload rather than vanishing silently. sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' \ + AND (email = $3 OR email IS NULL)", p_path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -1463,10 +1446,14 @@ async fn create_script_internal<'c>( } } } else if !skip_draft_deletion { + // See the matching branch above — only wipe the deployer's own + // draft (plus the legacy NULL-email row). sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' \ + AND (email = $3 OR email IS NULL)", ns.path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -1773,14 +1760,25 @@ pub async fn pick_hub_script_by_path( Ok::<_, Error>((status_code, headers, response)) } +// Canonical: fields inlined rather than `#[serde(flatten)]` from +// `WithStarredInfoQuery` / `WithDraftQuery`. axum's `serde_urlencoded` extractor +// drops type info through flatten, so `?get_draft=true` arrives as a String and +// fails the inner bool deserializer. Inlining lets the bool adapter see it directly. +#[derive(Deserialize)] +struct GetScriptByPathQuery { + with_starred_info: Option, + #[serde(default)] + get_draft: bool, +} + #[axum::debug_handler] async fn get_script_by_path( authed: ApiAuthed, Extension(user_db): Extension, Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, - Query(query): Query, -) -> JsonResult> { + Query(query): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("scripts:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; @@ -1800,7 +1798,7 @@ async fn get_script_by_path( ORDER BY s.created_at DESC LIMIT 1", ) .bind(path) - .bind(w_id) + .bind(&w_id) .bind(&authed.username) .fetch_optional(&mut *tx) .await? @@ -1812,19 +1810,33 @@ async fn get_script_by_path( ), ) .bind(path) - .bind(w_id) + .bind(&w_id) .fetch_optional(&mut *tx) .await? }; tx.commit().await?; - let script = windmill_common::scripts::prefetch_cached_script_with_starred( - not_found_if_none(script_o, "Script", path)?, + // Canonical: with no deployed row and `get_draft` set, fall back to the draft + // table so editing a never-deployed draft works like a deployed reload. + let deployed = match script_o { + Some(script_o) => Some( + windmill_common::scripts::prefetch_cached_script_with_starred(script_o, &db).await?, + ), + None => None, + }; + let overlay = overlay_or_draft_only( &db, + &w_id, + &authed.email, + UserDraftItemKind::Script, + path, + query.get_draft, + deployed, + || windmill_common::error::Error::NotFound(format!("Script not found at path {path}")), ) .await?; - Ok(Json(script)) + Ok(Json(overlay)) } async fn list_tokens( @@ -1835,32 +1847,6 @@ async fn list_tokens( list_tokens_internal(&db, &w_id, &path, false).await } -async fn get_script_by_path_w_draft( - authed: ApiAuthed, - Extension(db): Extension, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult> { - let path = path.to_path(); - check_scopes(&authed, || format!("scripts:read:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - - let script_o = sqlx::query_as::<_, ScriptWDraft>( - "SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, draft.created_at as draft_created_at, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, has_preprocessor, on_behalf_of_email, assets, modules, debounce_key, debounce_delay_s, labels FROM script LEFT JOIN draft ON - script.path = draft.path AND script.workspace_id = draft.workspace_id AND draft.typ = 'script' - WHERE script.path = $1 AND script.workspace_id = $2 - ORDER BY script.created_at DESC LIMIT 1", - ) - .bind(path) - .bind(w_id) - .fetch_optional(&mut *tx) - .await?; - tx.commit().await?; - - let script = not_found_if_none(script_o, "Script", path)?; - Ok(Json(script.prefetch_cached(&db).await?)) -} - async fn get_script_history( authed: ApiAuthed, Extension(user_db): Extension, @@ -2521,18 +2507,6 @@ async fn get_deployment_status( Ok(Json(deployment_status)) } -pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> { - return windmill_api_auth::require_is_writer( - authed, - path, - w_id, - db, - "SELECT extra_perms FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1", - "script", - ) - .await; -} - async fn archive_script_by_path( authed: ApiAuthed, Extension(webhook): Extension, @@ -2769,18 +2743,7 @@ async fn delete_script_by_path( let mut tx = user_db.begin(&authed).await?; - let draft_only = sqlx::query_scalar!( - "SELECT draft_only FROM script WHERE path = $1 AND workspace_id = $2", - path, - w_id - ) - .fetch_one(&db) - .await? - .unwrap_or(false); - - if !draft_only { - require_admin(authed.is_admin, &authed.username)?; - } + require_admin(authed.is_admin, &authed.username)?; // Capture all script versions and drafts for trashbin before deleting let trash_scripts: Vec = sqlx::query_scalar( diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index e0037861d3..d809cc1343 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -3844,10 +3844,15 @@ async fn create_workspace( Ok(format!("Created workspace {}", &nw.id)) } +// `authed_email` is the forker's email — `clone_drafts` only carries this +// user's per-user drafts (and the legacy NULL-email workspace draft, if any) +// across, since other users aren't added to the fork's `usr` table and +// their drafts would dangle as orphans. async fn clone_workspace_data( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, + authed_email: &str, ) -> Result<()> { // Clone workspace settings (merge with existing basic settings) update_workspace_settings(tx, source_workspace_id, target_workspace_id).await?; @@ -3888,6 +3893,14 @@ async fn clone_workspace_data( // Clone raw apps clone_raw_apps(tx, source_workspace_id, target_workspace_id).await?; + // Clone the forker's own per-user drafts (plus the legacy NULL-email + // workspace draft, if any) so they keep their pending edits in the + // fork. Other users' drafts are intentionally NOT cloned — they don't + // own a `usr` row in the fork (see `clone_workspace_full`) so their + // drafts would dangle and the home-page `draft_users` aggregate would + // surface them as duplicate legacy entries. + clone_drafts(tx, source_workspace_id, target_workspace_id, authed_email).await?; + // Clone workspace runnable dependencies and dependency map clone_workspace_runnable_dependencies(tx, source_workspace_id, target_workspace_id).await?; @@ -4364,7 +4377,7 @@ async fn clone_scripts( r#"INSERT INTO script ( workspace_id, hash, path, parent_hashes, summary, description, content, created_by, created_at, archived, schema, deleted, is_template, - extra_perms, lock, lock_error_logs, language, kind, tag, draft_only, + extra_perms, lock, lock_error_logs, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, timeout, delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key, @@ -4374,7 +4387,7 @@ async fn clone_scripts( SELECT $1, hash, path, parent_hashes, summary, description, content, created_by, created_at, archived, schema, deleted, is_template, - extra_perms, lock, lock_error_logs, language, kind, tag, draft_only, + extra_perms, lock, lock_error_logs, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, timeout, delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key, @@ -4417,12 +4430,12 @@ async fn clone_flows( sqlx::query!( "INSERT INTO flow ( workspace_id, path, summary, description, value, edited_by, edited_at, - archived, schema, extra_perms, dependency_job, draft_only, tag, + archived, schema, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, concurrency_key, versions, on_behalf_of_email, lock_error_logs ) SELECT $2, path, summary, description, value, edited_by, edited_at, - archived, schema, extra_perms, NULL, draft_only, tag, + archived, schema, extra_perms, NULL, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, concurrency_key, ARRAY[]::bigint[], on_behalf_of_email, lock_error_logs FROM flow @@ -4503,7 +4516,7 @@ async fn clone_apps( ) -> Result> { // Get all apps from source workspace let apps = sqlx::query!( - "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, draft_only, custom_path + "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, custom_path FROM app WHERE workspace_id = $1", source_workspace_id @@ -4516,8 +4529,8 @@ async fn clone_apps( // Clone apps with new IDs for app in apps { let new_app_id = sqlx::query_scalar!( - "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, draft_only, custom_path) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, custom_path) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id", target_workspace_id, app.path, @@ -4525,7 +4538,6 @@ async fn clone_apps( app.policy, &Vec::::new(), // Start with empty versions array app.extra_perms, - app.draft_only, app.custom_path, ) .fetch_one(&mut **tx) @@ -4729,6 +4741,39 @@ async fn clone_raw_apps( Ok(()) } +/// Clone every per-user draft (and the legacy NULL-email workspace draft, +/// if present) from the parent. The fork target is empty at create time so +/// a plain INSERT is safe — no need to UPSERT against the partial unique +/// indexes (`draft_pkey_with_user` / `draft_pkey_legacy`). `id` is the +/// BIGSERIAL synthetic PK and is regenerated by the default; we don't list +/// it in the column set. `created_at` is preserved so the per-tab +/// `last_sync` baseline the editor reads (`?get_draft=true` → overlay's +/// `draft_saved_at`) lines up with the parent's timeline — otherwise the +/// fork's first POST from any open editor would race a stale `last_sync` +/// and trip the conflict modal on every cloned draft. +// Only `email = authed_email` and the legacy NULL row are cloned — see +// `clone_workspace_data` for the rationale. +async fn clone_drafts( + tx: &mut Transaction<'_, Postgres>, + source_workspace_id: &str, + target_workspace_id: &str, + authed_email: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO draft (workspace_id, path, typ, value, created_at, email) + SELECT $2, path, typ, value, created_at, email + FROM draft + WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)", + source_workspace_id, + target_workspace_id, + authed_email, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + async fn clone_workspace_runnable_dependencies( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, @@ -5023,7 +5068,7 @@ async fn create_workspace_fork( .await?; // Clone all data from the parent workspace using Rust implementation - clone_workspace_data(&mut tx, &parent_workspace_id, &forked_id).await?; + clone_workspace_data(&mut tx, &parent_workspace_id, &forked_id, &authed.email).await?; // Clone triggers and schedules unconditionally, always with mode='disabled' / // enabled=false. Disabled rows have no side effects (no listener @@ -6972,7 +7017,7 @@ async fn compare_two_apps( FROM app JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)] - WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false", + WHERE app.workspace_id = $1 AND app.path = $2", source_workspace_id, path ) @@ -6984,7 +7029,7 @@ async fn compare_two_apps( FROM app JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)] - WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false", + WHERE app.workspace_id = $1 AND app.path = $2", fork_workspace_id, path ) @@ -7430,7 +7475,7 @@ async fn get_cloud_quotas( let scripts_prunable = sqlx::query_scalar!( "SELECT COUNT(*) FROM script s WHERE s.workspace_id = $1 AND s.hash NOT IN ( SELECT DISTINCT ON (path) hash FROM script - WHERE workspace_id = $1 AND deleted = false AND draft_only IS NOT TRUE + WHERE workspace_id = $1 AND deleted = false ORDER BY path, created_at DESC )", &w_id @@ -7525,7 +7570,7 @@ async fn prune_versions( "DELETE FROM script WHERE workspace_id = $1 AND hash NOT IN ( SELECT DISTINCT ON (path) hash FROM script - WHERE workspace_id = $1 AND deleted = false AND draft_only IS NOT TRUE + WHERE workspace_id = $1 AND deleted = false ORDER BY path, created_at DESC )", ) diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 55473a8861..45cac3a37d 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -279,8 +279,8 @@ pub(crate) async fn change_workspace_id( info!("Duplicating flow table rows"); sqlx::query!( "INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs) - SELECT $1, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs) + SELECT $1, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs FROM flow WHERE workspace_id = $2", &rw.new_id, &old_id diff --git a/backend/windmill-api/openapi-deref.json b/backend/windmill-api/openapi-deref.json index 00727e626f..0cf2e9aca0 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.713.1", + "version": "1.723.0", "title": "Windmill API", "contact": { "name": "Windmill Team", @@ -7879,6 +7879,54 @@ } } }, + "/users/tokens/update_label/{token_prefix}": { + "post": { + "summary": "update label of an existing token (owner only)", + "operationId": "updateTokenLabel", + "tags": [ + "user" + ], + "parameters": [ + { + "name": "token_prefix", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "new label (null or omitted = no label)", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "label": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "label updated", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/users/tokens/list": { "get": { "summary": "list token", @@ -8202,6 +8250,9 @@ "schema": { "type": "boolean" } + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -8210,7 +8261,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListableVariable" + "allOf": [ + { + "$ref": "#/components/schemas/ListableVariable" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -8350,6 +8408,15 @@ "schema": { "type": "string" } + }, + { + "name": "include_draft_only", + "description": "When true, append per-user draft variables whose path has no\ndeployed variable. Synthesized rows carry `draft_only: true`\nso the home page can render a \"Draft\" badge.\n", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { @@ -10264,6 +10331,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -10272,7 +10342,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Resource" + "allOf": [ + { + "$ref": "#/components/schemas/ListableResource" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -10507,6 +10584,15 @@ "schema": { "type": "string" } + }, + { + "name": "include_draft_only", + "description": "When true, append per-user draft resources whose path has\nno deployed resource. Synthesized rows carry\n`draft_only: true`.\n", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { @@ -12005,7 +12091,37 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Script" + "allOf": [ + { + "$ref": "#/components/schemas/Script" + }, + { + "type": "object", + "properties": { + "is_draft": { + "type": "boolean", + "description": "True when the authed user has a draft for this\nscript — either no deployed row exists at this\npath (draft-only) or the user saved a per-user\ndraft on top of the deployed row.\n" + }, + "draft_path": { + "type": "string", + "description": "User-typed path the editor has staged but not\nyet deployed. Surfaced for draft-only rows so\nthe home list can render the meaningful name\ninstead of the autogenerated\n`u/{user}/draft_{uuid}` URL path. Omitted\nwhen unchanged.\n" + }, + "draft_users": { + "description": "Workspace users (including the authed user, and\nthe legacy NULL-email row if any) who have a\nper-user draft at this path. Drives the home\npage's user-avatar circles inside the Draft\nbadge. Omitted when no drafts exist.\n", + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "nullable": true + } + } + } + } + } + } + ] } } } @@ -12043,10 +12159,10 @@ } } }, - "/w/{workspace}/drafts/create": { - "post": { - "summary": "create draft", - "operationId": "createDraft", + "/w/{workspace}/drafts/list": { + "get": { + "summary": "list every draft the current user has in this workspace, across all kinds", + "operationId": "listDrafts", "tags": [ "draft" ], @@ -12055,42 +12171,42 @@ "$ref": "#/components/parameters/WorkspaceId" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "path": { - "type": "string" - }, - "typ": { - "type": "string", - "enum": [ - "flow", - "script", - "app" - ] - }, - "value": {} - }, - "required": [ - "path", - "typ", - "enum" - ] - } - } - } - }, "responses": { - "201": { - "description": "draft created", + "200": { + "description": "the user's drafts", "content": { - "text/plain": { + "application/json": { "schema": { - "type": "string" + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/components/schemas/UserDraftItemKind" + }, + "path": { + "type": "string" + }, + "summary": { + "type": "string", + "description": "Best-effort, read from the draft JSON's `summary` field when the editor shape carries one." + }, + "draft_only": { + "type": "boolean", + "description": "No deployed counterpart exists at this path — the draft is the whole item." + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "kind", + "path", + "draft_only", + "created_at" + ] + } } } } @@ -12098,10 +12214,10 @@ } } }, - "/w/{workspace}/drafts/delete/{kind}/{path}": { - "delete": { - "summary": "delete draft", - "operationId": "deleteDraft", + "/w/{workspace}/drafts/get/{kind}/{path}": { + "get": { + "summary": "fetch a single draft's content by workspace username (or the legacy workspace-level row)", + "operationId": "getDraftForUser", "tags": [ "draft" ], @@ -12114,25 +12230,122 @@ "in": "path", "required": true, "schema": { - "type": "string", - "enum": [ - "script", - "flow", - "app" - ] + "$ref": "#/components/schemas/UserDraftItemKind" + } + }, + { + "$ref": "#/components/parameters/ScriptPath" + }, + { + "name": "username", + "in": "query", + "required": false, + "description": "Workspace username of the draft owner. Omit to fetch the legacy workspace-level (NULL email) row.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "draft content", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": {}, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "value", + "created_at" + ] + } + } + } + }, + "404": { + "description": "no draft for that owner at that path" + } + } + } + }, + "/w/{workspace}/drafts/update/{kind}/{path}": { + "post": { + "summary": "upsert (or clear) the current user's draft at a path", + "operationId": "updateDraft", + "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": { + "value": { + "nullable": true, + "description": "Draft content to save. `null` (or omitted) signals a delete — the row is removed under the same conflict rules." + }, + "last_sync": { + "type": "string", + "format": "date-time", + "description": "Server timestamp of the client's last known sync for this draft. Omit on first save." + }, + "force": { + "type": "boolean", + "description": "Skip the conflict check and overwrite the server copy." + } + } + } + } + } + }, "responses": { "200": { - "description": "draft deleted", + "description": "save result", "content": { - "text/plain": { + "application/json": { "schema": { - "type": "string" + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "saved", + "conflict" + ] + }, + "current_timestamp": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "status", + "current_timestamp" + ] } } } @@ -12729,6 +12942,9 @@ "schema": { "type": "boolean" } + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -12737,7 +12953,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Script" + "allOf": [ + { + "$ref": "#/components/schemas/Script" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -12806,35 +13029,6 @@ } } }, - "/w/{workspace}/scripts/get/draft/{path}": { - "get": { - "summary": "get script by path with draft", - "operationId": "getScriptByPathWithDraft", - "tags": [ - "script" - ], - "parameters": [ - { - "$ref": "#/components/parameters/WorkspaceId" - }, - { - "$ref": "#/components/parameters/ScriptPath" - } - ], - "responses": { - "200": { - "description": "script details", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewScriptWithDraft" - } - } - } - } - } - } - }, "/w/{workspace}/scripts/history/p/{path}": { "get": { "summary": "get history of a script by path", @@ -14541,6 +14735,42 @@ } } }, + "/w/{workspace}/jobs/job_view_token/{id}": { + "get": { + "summary": "mint a read-only share token for a job", + "description": "Returns a stateless `{job_id}.{hmac}` token that grants an authenticated workspace member read access to this job (and its flow subtree) via a `view_token` query param or `X-View-Token` header. Only callable by a user who can already read the job.\n", + "operationId": "getJobViewToken", + "tags": [ + "job" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "the share read token", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/w/{workspace}/flows/list_paths": { "get": { "summary": "list all flow paths", @@ -14722,11 +14952,29 @@ { "type": "object", "properties": { - "has_draft": { - "type": "boolean" - }, "draft_only": { "type": "boolean" + }, + "is_draft": { + "type": "boolean", + "description": "True when the authed user has a draft for this\nflow — either no deployed row exists at this\npath (draft-only) or the user saved a per-user\ndraft on top of the deployed row.\n" + }, + "draft_path": { + "type": "string", + "description": "User-typed path the editor has staged but not\nyet deployed. Sourced from the draft JSON's\n`draft_path` field (the editor only writes it\nwhen the typed path differs from the deployed\none). Lets the home list render the meaningful\nname instead of the autogenerated\n`u/{user}/draft_{uuid}` URL path. Omitted when\nunchanged.\n" + }, + "draft_users": { + "description": "Workspace users (including the authed user, and\nthe legacy NULL-email row if any) who have a\nper-user draft at this path. Drives the home\npage's user-avatar circles inside the Draft\nbadge. Omitted when no drafts exist.\n", + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "nullable": true + } + } + } } } } @@ -14950,6 +15198,9 @@ "schema": { "type": "boolean" } + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -14958,7 +15209,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Flow" + "allOf": [ + { + "$ref": "#/components/schemas/Flow" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -15110,52 +15368,6 @@ } } }, - "/w/{workspace}/flows/get/draft/{path}": { - "get": { - "summary": "get flow by path with draft", - "operationId": "getFlowByPathWithDraft", - "tags": [ - "flow" - ], - "parameters": [ - { - "$ref": "#/components/parameters/WorkspaceId" - }, - { - "$ref": "#/components/parameters/ScriptPath" - } - ], - "responses": { - "200": { - "description": "flow details with draft", - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/Flow" - }, - { - "type": "object", - "properties": { - "draft": { - "$ref": "#/components/schemas/Flow" - }, - "draft_created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check." - } - } - } - ] - } - } - } - } - } - } - }, "/w/{workspace}/flows/exists/{path}": { "get": { "summary": "exists flow by path", @@ -15224,9 +15436,6 @@ { "type": "object", "properties": { - "draft_only": { - "type": "boolean" - }, "deployment_message": { "type": "string" }, @@ -15643,6 +15852,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -16059,9 +16271,6 @@ "policy": { "$ref": "#/components/schemas/Policy" }, - "draft_only": { - "type": "boolean" - }, "deployment_message": { "type": "string" }, @@ -16140,9 +16349,6 @@ "policy": { "$ref": "#/components/schemas/Policy" }, - "draft_only": { - "type": "boolean" - }, "deployment_message": { "type": "string" }, @@ -16245,6 +16451,17 @@ "schema": { "type": "boolean" } + }, + { + "$ref": "#/components/parameters/GetDraft" + }, + { + "name": "raw_app", + "in": "query", + "description": "When no deployed app exists at this path and `get_draft` is set,\ndisambiguates which draft kind (`raw_app` or `app`) to look up.\nIgnored when a deployed row exists.\n", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -16253,7 +16470,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AppWithLastVersion" + "allOf": [ + { + "$ref": "#/components/schemas/AppWithLastVersion" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -16290,35 +16514,6 @@ } } }, - "/w/{workspace}/apps/get/draft/{path}": { - "get": { - "summary": "get app by path with draft", - "operationId": "getAppByPathWithDraft", - "tags": [ - "app" - ], - "parameters": [ - { - "$ref": "#/components/parameters/WorkspaceId" - }, - { - "$ref": "#/components/parameters/ScriptPath" - } - ], - "responses": { - "200": { - "description": "app details with draft", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AppWithLastVersionWDraft" - } - } - } - } - } - } - }, "/w/{workspace}/apps/history/p/{path}": { "get": { "summary": "get app history by path", @@ -18545,6 +18740,20 @@ "type": "boolean" } }, + { + "name": "status", + "description": "filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`.", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "success", + "failure", + "canceled", + "skipped" + ] + } + }, { "name": "all_workspaces", "description": "get jobs from all workspaces (only valid if request come from the `admins` workspace)", @@ -18962,6 +19171,20 @@ { "$ref": "#/components/parameters/Success" }, + { + "name": "status", + "description": "filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`.", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "success", + "failure", + "canceled", + "skipped" + ] + } + }, { "$ref": "#/components/parameters/JobKinds" }, @@ -19345,6 +19568,20 @@ "type": "boolean" } }, + { + "name": "status", + "description": "filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`.", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "success", + "failure", + "canceled", + "skipped" + ] + } + }, { "name": "all_workspaces", "description": "get jobs from all workspaces (only valid if request come from the `admins` workspace)", @@ -21488,6 +21725,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -21496,7 +21736,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Schedule" + "allOf": [ + { + "$ref": "#/components/schemas/Schedule" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -21618,6 +21865,15 @@ "type": "string" }, "description": "Filter by label" + }, + { + "name": "include_draft_only", + "description": "When true, append per-user draft schedules whose path has\nno deployed schedule. Synthesized rows carry\n`draft_only: true`.\n", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { @@ -21966,6 +22222,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -21974,7 +22233,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HttpTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/HttpTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -22030,6 +22296,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -22303,6 +22572,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -22311,7 +22583,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WebsocketTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/WebsocketTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -22367,6 +22646,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -22638,6 +22920,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -22646,7 +22931,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/KafkaTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/KafkaTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -22702,6 +22994,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -23039,6 +23334,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -23047,7 +23345,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NatsTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/NatsTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -23103,6 +23408,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -23367,6 +23675,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -23375,7 +23686,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SqsTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/SqsTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -23431,6 +23749,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -24145,6 +24466,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -24620,6 +24944,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -24628,7 +24955,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MqttTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/MqttTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -24684,6 +25018,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -24948,6 +25285,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -24956,7 +25296,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GcpTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/GcpTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -25012,6 +25359,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -25389,6 +25739,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -25397,7 +25750,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AzureTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/AzureTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -25443,6 +25803,9 @@ "schema": { "type": "string" } + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -26302,6 +26665,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -26310,7 +26676,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PostgresTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/PostgresTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -26366,6 +26739,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -26630,6 +27006,9 @@ }, { "$ref": "#/components/parameters/Path" + }, + { + "$ref": "#/components/parameters/GetDraft" } ], "responses": { @@ -26638,7 +27017,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmailTrigger" + "allOf": [ + { + "$ref": "#/components/schemas/EmailTrigger" + }, + { + "$ref": "#/components/schemas/UserDraftOverlay" + } + ] } } } @@ -26694,6 +27080,9 @@ "type": "string" }, "description": "Filter by label" + }, + { + "$ref": "#/components/parameters/IncludeDraftOnly" } ], "responses": { @@ -27667,6 +28056,12 @@ }, "default_permissioned_as": { "$ref": "#/components/schemas/FolderDefaultPermissionedAs" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -27729,6 +28124,12 @@ }, "default_permissioned_as": { "$ref": "#/components/schemas/FolderDefaultPermissionedAs" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } } } } @@ -31494,6 +31895,20 @@ "type": "boolean" } }, + { + "name": "status", + "description": "filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`.", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "success", + "failure", + "canceled", + "skipped" + ] + } + }, { "name": "all_workspaces", "description": "get jobs from all workspaces (only valid if request come from the `admins` workspace)", @@ -32604,6 +33019,24 @@ } }, "parameters": { + "GetDraft": { + "name": "get_draft", + "in": "query", + "required": false, + "description": "When true, overlay the authed user's draft (if any) onto the deployed payload.", + "schema": { + "type": "boolean" + } + }, + "IncludeDraftOnly": { + "name": "include_draft_only", + "in": "query", + "required": false, + "description": "When true, append per-user draft rows whose path has no\ndeployed counterpart. Synthesized rows carry `draft_only: true`\nso the home page can render a \"Draft\" badge. Gated to\nnon-operators + page 0 + no narrowing filters on the backend so\npicker callers stay deployed-only and pagination stays clean.\n", + "schema": { + "type": "boolean" + } + }, "Id": { "name": "id", "in": "path", @@ -33148,6 +33581,73 @@ } }, "schemas": { + "UserDraftOverlay": { + "type": "object", + "description": "Overlay fields added to every \"get by path\" response that accepts\nthe `get_draft` query parameter. The deployed payload is sent\nuntouched in the response body; the authed user's saved draft\nfor this path — whatever shape the editor wrote — is attached\nas the sibling `draft` field when `get_draft=true` and a draft\nexists. The frontend pairs the two to present diff / reset /\ndiscard UI; the server never merges them.\n\nWhen `no_deployed=true` there is no deployed row at this path —\nthe response body is a best-effort stand-in synthesized from\nthe draft, and only `draft` is canonical. Callers should disable\n\"diff vs deployed\" UI in that case.\n", + "properties": { + "is_draft": { + "type": "boolean" + }, + "draft_saved_at": { + "type": "string", + "format": "date-time" + }, + "no_deployed": { + "type": "boolean" + }, + "draft": { + "type": "object", + "additionalProperties": true + }, + "other_drafts_users": { + "description": "Other workspace users (and the legacy NULL-email row, if any)\nwith a saved draft at the same path. Populated only on the\nauthed user's \"get by path\" responses for kinds the editor\nsurfaces a fork banner for (script, flow, app, raw_app).\nEmpty / omitted for kinds without that UI.\n", + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "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" + } + } + } + } + }, + "required": [ + "is_draft" + ] + }, + "UserDraftItemKind": { + "type": "string", + "description": "Closed set of item kinds a user can autosave as a draft. Mirrors the\nPostgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`.\n", + "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" + ] + }, "OpenFlow": { "type": "object", "description": "Top-level flow definition containing metadata, configuration, and the flow structure", @@ -33354,6 +33854,10 @@ "type": "string", "nullable": true, "description": "Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised." + }, + "error_include_result": { + "type": "boolean", + "description": "When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false." } }, "required": [ @@ -34174,6 +34678,10 @@ "aiagent" ] }, + "tag": { + "type": "string", + "description": "Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)" + }, "omit_output_from_conversation": { "type": "boolean", "default": false, @@ -35367,6 +35875,9 @@ "items": { "type": "string" } + }, + "web_search_enabled": { + "type": "boolean" } }, "required": [ @@ -35633,9 +36144,6 @@ "tag": { "type": "string" }, - "has_draft": { - "type": "boolean" - }, "draft_only": { "type": "boolean" }, @@ -35723,6 +36231,13 @@ "type": "string" }, "default": [] + }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" } }, "required": [ @@ -35787,9 +36302,6 @@ "tag": { "type": "string" }, - "draft_only": { - "type": "boolean" - }, "envs": { "type": "array", "items": { @@ -35930,32 +36442,6 @@ "language" ] }, - "NewScriptWithDraft": { - "allOf": [ - { - "$ref": "#/components/schemas/NewScript" - }, - { - "type": "object", - "properties": { - "draft": { - "$ref": "#/components/schemas/NewScript" - }, - "draft_created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check." - }, - "hash": { - "type": "string" - } - }, - "required": [ - "hash" - ] - } - ] - }, "ScriptHistory": { "type": "object", "properties": { @@ -37391,6 +37877,13 @@ "type": "string" } }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" + }, "ws_specific": { "type": "boolean" }, @@ -37400,6 +37893,14 @@ }, "edited_by": { "type": "string" + }, + "draft_only": { + "description": "True when this row is a per-user draft with no deployed\nvariable at the same path. Frontend renders a \"Draft\" badge.\n", + "type": "boolean" + }, + "is_draft": { + "description": "True when the authed user has a per-user draft at this path\n(over a deployed row or a synthesized draft-only row).\nFrontend appends a `*` to the displayed name.\n", + "type": "boolean" } }, "required": [ @@ -38115,6 +38616,13 @@ "type": "string" } }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" + }, "ws_specific": { "type": "boolean" } @@ -38178,8 +38686,23 @@ "type": "string" } }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" + }, "ws_specific": { "type": "boolean" + }, + "draft_only": { + "description": "True when this row is a per-user draft with no deployed\nresource at the same path. Frontend renders a \"Draft\" badge.\n", + "type": "boolean" + }, + "is_draft": { + "description": "True when the authed user has a per-user draft at this path\n(over a deployed row or a synthesized draft-only row).\nFrontend appends a `*` to the displayed name.\n", + "type": "boolean" } }, "required": [ @@ -38384,6 +38907,21 @@ "type": "string" }, "default": [] + }, + "draft_only": { + "description": "True when this row is a per-user draft with no deployed\nschedule at the same path. Frontend renders a \"Draft\" badge.\n", + "type": "boolean" + }, + "is_draft": { + "description": "True when the authed user has a per-user draft at this path\n(over a deployed row or a synthesized draft-only row).\nFrontend appends a `*` to the displayed name.\n", + "type": "boolean" + }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" } }, "required": [ @@ -38785,6 +39323,14 @@ "type": "string" }, "default": [] + }, + "draft_only": { + "description": "True when this row is a per-user draft with no deployed\ntrigger at the same path. Set by list endpoints when\n`include_draft_only=true` synthesizes the row from the\ndraft. Frontend renders a \"Draft\" badge.\n", + "type": "boolean" + }, + "is_draft": { + "description": "True when the authed user has a per-user draft at this path\n(over a deployed row or a synthesized draft-only row).\nFrontend appends a `*` to the displayed name.\n", + "type": "boolean" } }, "required": [ @@ -41871,6 +42417,13 @@ }, "default_permissioned_as": { "$ref": "#/components/schemas/FolderDefaultPermissionedAs" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels set on the folder. Items inside the folder inherit them, exposed as `inherited_labels` on scripts and flows and stamped into job labels at run time.\n" } }, "required": [ @@ -42369,6 +42922,13 @@ "type": "string" }, "default": [] + }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" } }, "required": [ @@ -42599,6 +43159,34 @@ "type": "string" }, "default": [] + }, + "is_draft": { + "type": "boolean", + "description": "True when the authed user has a draft for this app — either no\ndeployed row exists at this path (draft-only) or the user has\nsaved a per-user draft on top of the deployed row.\n" + }, + "draft_path": { + "type": "string", + "description": "User-typed path the editor has staged but not yet deployed.\nSourced from the draft JSON's `draft_path` field (the editor\nonly writes it when the typed path differs from the deployed\none). Lets the home list render the meaningful name instead of\nthe autogenerated `u/{user}/draft_{uuid}` URL path. Omitted\nwhen unchanged.\n" + }, + "draft_users": { + "description": "Workspace users (including the authed user, and the legacy\nNULL-email row if any) who have a per-user draft at this\npath. Drives the home page's user-avatar circles inside the\nDraft badge. Omitted when no drafts exist.\n", + "type": "array", + "items": { + "type": "object", + "properties": { + "username": { + "type": "string", + "nullable": true + } + } + } + }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" } }, "required": [ @@ -42691,6 +43279,13 @@ "type": "string" }, "default": [] + }, + "inherited_labels": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Labels inherited from the parent folder, computed at read time. Read-only — edit them on the folder.\n" } }, "required": [ @@ -42780,27 +43375,6 @@ "raw_app" ] }, - "AppWithLastVersionWDraft": { - "allOf": [ - { - "$ref": "#/components/schemas/AppWithLastVersion" - }, - { - "type": "object", - "properties": { - "draft_only": { - "type": "boolean" - }, - "draft": {}, - "draft_created_at": { - "type": "string", - "format": "date-time", - "description": "Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check." - } - } - } - ] - }, "AppHistory": { "type": "object", "properties": { @@ -44350,7 +44924,8 @@ "enum": [ "DisableDirectDeployment", "DisableWorkspaceForking", - "RestrictDeployToDeployers" + "RestrictDeployToDeployers", + "RestrictAnonymousAppDeployment" ] }, "RuleBypasserGroups": { @@ -46030,6 +46605,10 @@ "aiagent" ] }, + "tag": { + "type": "string", + "description": "Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)" + }, "omit_output_from_conversation": { "type": "boolean", "default": false, @@ -46062,6 +46641,10 @@ "type": "string", "nullable": true, "description": "Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised." + }, + "error_include_result": { + "type": "boolean", + "description": "When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false." } }, "required": [ diff --git a/backend/windmill-api/openapi-deref.yaml b/backend/windmill-api/openapi-deref.yaml index 4ced5ef265..22792b38e3 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.713.1 + version: 1.723.0 title: Windmill API contact: name: Windmill Team @@ -146,18 +146,18 @@ paths: checks: type: object description: Detailed health checks - required: &ref_347 + required: &ref_352 - database - readiness - properties: &ref_348 + properties: &ref_353 database: type: object description: Database health status - required: &ref_349 + required: &ref_354 - healthy - latency_ms - pool - properties: &ref_350 + properties: &ref_355 healthy: type: boolean description: Whether the database is reachable @@ -168,11 +168,11 @@ paths: pool: type: object description: Database connection pool statistics - required: &ref_351 + required: &ref_356 - size - idle - max_connections - properties: &ref_352 + properties: &ref_357 size: type: integer description: Current number of connections in the pool @@ -186,13 +186,13 @@ paths: description: Workers health status nullable: true type: object - required: &ref_353 + required: &ref_358 - healthy - active_count - worker_groups - min_version - versions - properties: &ref_354 + properties: &ref_359 healthy: type: boolean description: Whether any workers are active @@ -219,10 +219,10 @@ paths: description: Job queue status nullable: true type: object - required: &ref_355 + required: &ref_360 - pending_jobs - running_jobs - properties: &ref_356 + properties: &ref_361 pending_jobs: type: integer format: int64 @@ -234,9 +234,9 @@ paths: readiness: type: object description: Server readiness status - required: &ref_357 + required: &ref_362 - healthy - properties: &ref_358 + properties: &ref_363 healthy: type: boolean description: Whether the server is ready to accept requests @@ -337,7 +337,7 @@ paths: - name: id in: path required: true - schema: &ref_79 + schema: &ref_84 type: integer responses: '200': @@ -488,24 +488,24 @@ paths: - name: before description: filter on started before (inclusive) timestamp in: query - schema: &ref_299 + schema: &ref_304 type: string format: date-time - name: after description: filter on created after (exclusive) timestamp in: query - schema: &ref_300 + schema: &ref_305 type: string format: date-time - name: username description: filter on exact username of user in: query - schema: &ref_308 + schema: &ref_313 type: string - name: operation description: filter on exact or prefix name of operation in: query - schema: &ref_309 + schema: &ref_314 type: string - name: operations in: query @@ -520,12 +520,12 @@ paths: - name: resource description: filter on exact or prefix name of resource in: query - schema: &ref_310 + schema: &ref_315 type: string - name: action_kind description: filter on type of operation in: query - schema: &ref_311 + schema: &ref_316 type: string enum: - Create @@ -562,12 +562,12 @@ paths: application/json: schema: type: object - properties: &ref_397 + properties: &ref_403 email: type: string password: type: string - required: &ref_398 + required: &ref_404 - email - password responses: @@ -757,7 +757,7 @@ paths: nullable: true allOf: - type: object - properties: &ref_394 + properties: &ref_400 source: type: string enum: @@ -775,7 +775,7 @@ paths: description: >- The instance group name (when source is 'instance_group') - required: &ref_395 + required: &ref_401 - source is_service_account: type: boolean @@ -813,7 +813,7 @@ paths: application/json: schema: type: object - properties: &ref_399 + properties: &ref_405 is_admin: type: boolean operator: @@ -1187,7 +1187,7 @@ paths: type: array items: type: object - properties: &ref_413 + properties: &ref_419 jwt_hash: type: integer format: int64 @@ -1210,7 +1210,7 @@ paths: last_used_at: type: string format: date-time - required: &ref_414 + required: &ref_420 - jwt_hash - email - username @@ -1342,7 +1342,7 @@ paths: type: array items: type: object - properties: &ref_400 + properties: &ref_406 label: type: string scopes: @@ -1351,7 +1351,7 @@ paths: type: string expiration: type: string - required: &ref_401 + required: &ref_407 - label - scopes description: Tokens owned by this user (will be deleted) @@ -1395,7 +1395,7 @@ paths: application/json: schema: type: object - properties: &ref_402 + properties: &ref_408 reassign_to: type: string description: 'Target for reassignment: ''u/{username}'' or ''f/{folder}''' @@ -1409,7 +1409,7 @@ paths: type: boolean default: true description: Whether to also remove the user from the workspace - required: &ref_403 + required: &ref_409 - reassign_to responses: '200': @@ -1428,7 +1428,7 @@ paths: on success. summary: type: object - properties: &ref_404 + properties: &ref_410 scripts_reassigned: type: integer flows_reassigned: @@ -1445,7 +1445,7 @@ paths: type: integer drafts_deleted: type: integer - required: &ref_405 + required: &ref_411 - scripts_reassigned - flows_reassigned - apps_reassigned @@ -1475,12 +1475,12 @@ paths: application/json: schema: type: object - properties: &ref_406 + properties: &ref_412 workspaces: type: array items: type: object - properties: &ref_408 + properties: &ref_414 workspace_id: type: string username: @@ -1489,11 +1489,11 @@ paths: type: object properties: *ref_12 required: *ref_13 - required: &ref_409 + required: &ref_415 - workspace_id - username - preview - required: &ref_407 + required: &ref_413 - workspaces /users/offboard/{email}: post: @@ -1515,12 +1515,12 @@ paths: application/json: schema: type: object - properties: &ref_410 + properties: &ref_416 reassignments: type: object additionalProperties: type: object - properties: &ref_411 + properties: &ref_417 reassign_to: type: string description: 'Target: ''u/{username}'' or ''f/{folder}''' @@ -1529,7 +1529,7 @@ paths: description: >- Required when reassign_to is a folder. Username to use as permissioned_as. - required: &ref_412 + required: &ref_418 - reassign_to description: Map of workspace_id to reassignment config delete_user: @@ -1589,7 +1589,7 @@ paths: application/json: schema: type: array - items: &ref_562 + items: &ref_563 type: object properties: workspace_id: @@ -1699,7 +1699,7 @@ paths: application/json: schema: type: object - properties: &ref_502 + properties: &ref_504 email: type: string workspaces: @@ -1774,7 +1774,7 @@ paths: - username - color - disabled - required: &ref_503 + required: &ref_505 - email - workspaces /w/{workspace}/workspaces/get_as_superadmin: @@ -1836,7 +1836,7 @@ paths: application/json: schema: type: object - properties: &ref_504 + properties: &ref_506 id: type: string name: @@ -1845,7 +1845,7 @@ paths: type: string color: type: string - required: &ref_505 + required: &ref_507 - id - name responses: @@ -2021,7 +2021,7 @@ paths: properties: &ref_24 logs: type: object - properties: &ref_470 + properties: &ref_472 super_admin: type: string enum: &ref_21 @@ -2828,11 +2828,11 @@ paths: type: array items: type: object - properties: &ref_545 + properties: &ref_546 name: type: string value: {} - required: &ref_546 + required: &ref_547 - name - value /settings/instance_config: @@ -2930,9 +2930,9 @@ paths: application/json: schema: type: object - required: &ref_370 + required: &ref_375 - keys - properties: &ref_371 + properties: &ref_376 keys: type: array items: @@ -3049,11 +3049,11 @@ paths: type: array items: type: object - required: &ref_368 + required: &ref_373 - workspace_id - path - error - properties: &ref_369 + properties: &ref_374 workspace_id: type: string description: Workspace ID where the secret is located @@ -4348,13 +4348,13 @@ paths: application/json: schema: type: object - required: &ref_554 + required: &ref_555 - all_ahead_items_visible - all_behind_items_visible - skipped_comparison - diffs - summary - properties: &ref_555 + properties: &ref_556 all_ahead_items_visible: type: boolean description: >- @@ -4375,7 +4375,7 @@ paths: description: List of differences found between workspaces items: type: object - required: &ref_556 + required: &ref_557 - kind - path - ahead @@ -4383,7 +4383,7 @@ paths: - has_changes - exists_in_source - exists_in_fork - properties: &ref_557 + properties: &ref_558 kind: type: string enum: @@ -4428,7 +4428,7 @@ paths: summary: description: Summary statistics of the comparison type: object - required: &ref_558 + required: &ref_559 - total_diffs - total_ahead - total_behind @@ -4442,7 +4442,7 @@ paths: - schedules_changed - triggers_changed - conflicts - properties: &ref_559 + properties: &ref_560 total_diffs: type: integer description: Total number of items with differences @@ -4639,12 +4639,12 @@ paths: type: array items: type: object - properties: &ref_531 + properties: &ref_532 pattern: type: string allow: type: string - required: &ref_532 + required: &ref_533 - pattern - allow secondary_storage: @@ -4777,7 +4777,7 @@ paths: auto_invite: type: object description: Configuration for auto-inviting users to the workspace - properties: &ref_359 + properties: &ref_364 enabled: type: boolean default: false @@ -4818,14 +4818,16 @@ paths: type: object additionalProperties: type: object - properties: &ref_378 + properties: &ref_383 resource_path: type: string models: type: array items: type: string - required: &ref_379 + web_search_enabled: + type: boolean + required: &ref_384 - resource_path - models default_model: @@ -4871,7 +4873,7 @@ paths: error_handler: type: object description: Configuration for the workspace error handler - properties: &ref_360 + properties: &ref_365 path: type: string description: Path to the error handler script or flow @@ -4888,7 +4890,7 @@ paths: success_handler: type: object description: Configuration for the workspace success handler - properties: &ref_361 + properties: &ref_366 path: type: string description: Path to the success handler script or flow @@ -5192,7 +5194,7 @@ paths: type: array items: type: object - properties: &ref_507 + properties: &ref_509 importer_path: type: string importer_kind: @@ -5206,7 +5208,7 @@ paths: items: type: string nullable: true - required: &ref_508 + required: &ref_510 - importer_path - importer_kind /w/{workspace}/workspaces/get_imports/{importer_path}: @@ -5264,13 +5266,13 @@ paths: type: array items: type: object - properties: &ref_509 + properties: &ref_511 imported_path: type: string count: type: integer format: int64 - required: &ref_510 + required: &ref_512 - imported_path - count /w/{workspace}/workspaces/get_dependency_map: @@ -5293,7 +5295,7 @@ paths: type: array items: type: object - properties: &ref_506 + properties: &ref_508 workspace_id: type: string nullable: true @@ -5825,7 +5827,7 @@ paths: type: array items: type: object - properties: &ref_380 + properties: &ref_385 provider: type: string enum: *ref_51 @@ -5833,7 +5835,7 @@ paths: type: array items: type: string - required: &ref_381 + required: &ref_386 - provider - models default_model: @@ -5924,10 +5926,10 @@ paths: Request body for editing the workspace error handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: &ref_362 + oneOf: &ref_367 - type: object description: New grouped format for editing error handler - properties: &ref_363 + properties: &ref_368 path: type: string description: Path to the error handler script or flow @@ -5945,7 +5947,7 @@ paths: description: >- Legacy flat format for editing error handler (deprecated, use new format) - properties: &ref_364 + properties: &ref_369 error_handler: type: string description: Path to the error handler script or flow @@ -5984,10 +5986,10 @@ paths: Request body for editing the workspace success handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: &ref_365 + oneOf: &ref_370 - type: object description: New grouped format for editing success handler - properties: &ref_366 + properties: &ref_371 path: type: string description: Path to the success handler script or flow @@ -5999,7 +6001,7 @@ paths: description: >- Legacy flat format for editing success handler (deprecated, use new format) - properties: &ref_367 + properties: &ref_372 success_handler: type: string description: Path to the success handler script or flow @@ -6116,10 +6118,10 @@ paths: type: array items: type: object - required: &ref_523 + required: &ref_524 - datatable_name - schemas - properties: &ref_524 + properties: &ref_525 datatable_name: type: string schemas: @@ -6161,10 +6163,10 @@ paths: type: array items: type: object - required: &ref_525 + required: &ref_526 - datatable_name - schemas - properties: &ref_526 + properties: &ref_527 datatable_name: type: string schemas: @@ -6209,12 +6211,12 @@ paths: application/json: schema: type: object - required: &ref_527 + required: &ref_528 - datatable_name - schema_name - table_name - columns - properties: &ref_528 + properties: &ref_529 datatable_name: type: string schema_name: @@ -6975,7 +6977,7 @@ paths: type: array items: type: object - properties: &ref_396 + properties: &ref_402 email: type: string executions: @@ -7038,7 +7040,7 @@ paths: type: array items: type: object - properties: &ref_518 + properties: &ref_520 name: type: string description: @@ -7048,7 +7050,7 @@ paths: type: array items: type: object - properties: &ref_516 + properties: &ref_518 value: type: string label: @@ -7058,11 +7060,11 @@ paths: nullable: true requires_resource_path: type: boolean - required: &ref_517 + required: &ref_519 - value - label - requires_resource_path - required: &ref_519 + required: &ref_521 - name - scopes /users/tokens/create: @@ -7078,7 +7080,7 @@ paths: application/json: schema: type: object - properties: &ref_415 + properties: &ref_421 label: type: string expiration: @@ -7119,7 +7121,7 @@ paths: application/json: schema: type: object - properties: &ref_416 + properties: &ref_422 label: type: string expiration: @@ -7129,7 +7131,7 @@ paths: type: string workspace_id: type: string - required: &ref_417 + required: &ref_423 - impersonate_email responses: '201': @@ -7189,6 +7191,36 @@ paths: text/plain: schema: type: string + /users/tokens/update_label/{token_prefix}: + post: + summary: update label of an existing token (owner only) + operationId: updateTokenLabel + tags: + - user + parameters: + - name: token_prefix + in: path + required: true + schema: + type: string + requestBody: + description: new label (null or omitted = no label) + required: true + content: + application/json: + schema: + type: object + properties: + label: + type: string + nullable: true + responses: + '200': + description: label updated + content: + text/plain: + schema: + type: string /users/tokens/list: get: summary: list token @@ -7217,7 +7249,7 @@ paths: type: array items: type: object - properties: &ref_102 + properties: &ref_108 label: type: string expiration: @@ -7241,7 +7273,7 @@ paths: type: string read_only: type: boolean - required: &ref_103 + required: &ref_109 - token_prefix - created_at - last_used_at @@ -7297,7 +7329,7 @@ paths: application/json: schema: type: object - properties: &ref_420 + properties: &ref_426 path: type: string description: The path to the variable @@ -7326,7 +7358,7 @@ paths: type: string ws_specific: type: boolean - required: &ref_421 + required: &ref_427 - path - value - is_secret @@ -7448,7 +7480,7 @@ paths: application/json: schema: type: object - properties: &ref_422 + properties: &ref_428 path: type: string description: The path to the variable @@ -7504,59 +7536,168 @@ paths: in: query schema: type: boolean + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: &ref_77 + type: boolean responses: '200': description: variable content: application/json: schema: - type: object - properties: &ref_61 - workspace_id: - type: string - path: - type: string - value: - type: string - is_secret: - type: boolean - description: - type: string - account: - type: integer - is_oauth: - type: boolean - extra_perms: - type: object - additionalProperties: - type: boolean - is_expired: - type: boolean - refresh_error: - type: string - is_linked: - type: boolean - is_refreshed: - type: boolean - expires_at: - type: string - format: date-time - labels: - type: array - items: - type: string - ws_specific: - type: boolean - edited_at: - type: string - format: date-time - edited_by: - type: string - required: &ref_62 - - workspace_id - - path - - is_secret - - extra_perms + allOf: + - type: object + properties: &ref_61 + workspace_id: + type: string + path: + type: string + value: + type: string + is_secret: + type: boolean + description: + type: string + account: + type: integer + is_oauth: + type: boolean + extra_perms: + type: object + additionalProperties: + type: boolean + is_expired: + type: boolean + refresh_error: + type: string + is_linked: + type: boolean + is_refreshed: + type: boolean + expires_at: + type: string + format: date-time + labels: + type: array + items: + type: string + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + ws_specific: + type: boolean + edited_at: + type: string + format: date-time + edited_by: + type: string + draft_only: + description: > + True when this row is a per-user draft with no + deployed + + variable at the same path. Frontend renders a "Draft" + badge. + type: boolean + is_draft: + description: > + True when the authed user has a per-user draft at this + path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a `*` to the displayed name. + type: boolean + required: &ref_62 + - workspace_id + - path + - is_secret + - extra_perms + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: &ref_78 + is_draft: + type: boolean + draft_saved_at: + type: string + format: date-time + no_deployed: + type: boolean + draft: + type: object + additionalProperties: true + other_drafts_users: + description: > + Other workspace users (and the legacy NULL-email row, + if any) + + with a saved draft at the same path. Populated only on + the + + authed user's "get by path" responses for kinds the + editor + + surfaces a fork banner for (script, flow, app, + raw_app). + + Empty / omitted for kinds without that UI. + type: array + items: + type: object + properties: + username: + type: string + nullable: true + description: > + Workspace username of the draft owner. `null` + represents + + the legacy workspace-level (NULL-email) row. + Emails never + + leave the server. + required: &ref_79 + - is_draft /w/{workspace}/variables/get_value/{path}: get: summary: get variable value @@ -7662,6 +7803,15 @@ paths: required: false schema: type: string + - name: include_draft_only + description: | + When true, append per-user draft variables whose path has no + deployed variable. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. + in: query + required: false + schema: + type: boolean responses: '200': description: variable list @@ -7693,7 +7843,7 @@ paths: type: array items: type: object - properties: &ref_418 + properties: &ref_424 name: type: string value: @@ -7702,7 +7852,7 @@ paths: type: string is_custom: type: boolean - required: &ref_419 + required: &ref_425 - name - value - description @@ -7889,12 +8039,12 @@ paths: description: >- A workspace protection rule defining restrictions and bypass permissions - required: &ref_565 + required: &ref_566 - name - rules - bypass_groups - bypass_users - properties: &ref_566 + properties: &ref_567 name: type: string description: Unique name for the protection rule @@ -7906,10 +8056,11 @@ paths: description: Configuration of protection restrictions items: &ref_64 type: string - enum: &ref_567 + enum: &ref_568 - DisableDirectDeployment - DisableWorkspaceForking - RestrictDeployToDeployers + - RestrictAnonymousAppDeployment bypass_groups: type: array description: Groups that can bypass this ruleset @@ -8066,11 +8217,11 @@ paths: type: array items: type: object - required: &ref_568 + required: &ref_569 - username - email - is_admin - properties: &ref_569 + properties: &ref_570 username: type: string email: @@ -8125,10 +8276,10 @@ paths: type: array items: type: object - required: &ref_570 + required: &ref_571 - username - email - properties: &ref_571 + properties: &ref_572 username: type: string email: @@ -9051,7 +9202,7 @@ paths: application/json: schema: type: object - properties: &ref_427 + properties: &ref_433 path: type: string description: The path to the resource @@ -9068,7 +9219,7 @@ paths: type: string ws_specific: type: boolean - required: &ref_428 + required: &ref_434 - path - value - resource_type @@ -9159,7 +9310,7 @@ paths: application/json: schema: type: object - properties: &ref_429 + properties: &ref_435 path: type: string description: The path to the resource @@ -9230,44 +9381,123 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: resource content: application/json: schema: - type: object - properties: &ref_430 - workspace_id: - type: string - path: - type: string - description: - type: string - resource_type: - type: string - value: {} - is_oauth: - type: boolean - extra_perms: - type: object - additionalProperties: - type: boolean - created_by: - type: string - edited_at: - type: string - format: date-time - labels: - type: array - items: - type: string - ws_specific: - type: boolean - required: &ref_431 - - path - - resource_type - - is_oauth + allOf: + - type: object + properties: &ref_80 + workspace_id: + type: string + path: + type: string + description: + type: string + resource_type: + type: string + value: {} + is_oauth: + type: boolean + extra_perms: + type: object + additionalProperties: + type: boolean + is_expired: + type: boolean + refresh_error: + type: string + is_linked: + type: boolean + is_refreshed: + type: boolean + account: + type: number + created_by: + type: string + edited_at: + type: string + format: date-time + labels: + type: array + items: + type: string + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + ws_specific: + type: boolean + draft_only: + description: > + True when this row is a per-user draft with no + deployed + + resource at the same path. Frontend renders a "Draft" + badge. + type: boolean + is_draft: + description: > + True when the authed user has a per-user draft at this + path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a `*` to the displayed name. + type: boolean + required: &ref_81 + - path + - resource_type + - is_oauth + - is_linked + - is_refreshed + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/resources/get_value_interpolated/{path}: get: summary: get resource interpolated (variables and resources are fully unrolled) @@ -9438,6 +9668,15 @@ paths: required: false schema: type: string + - name: include_draft_only + description: | + When true, append per-user draft resources whose path has + no deployed resource. Synthesized rows carry + `draft_only: true`. + in: query + required: false + schema: + type: boolean responses: '200': description: resource list @@ -9447,49 +9686,8 @@ paths: type: array items: type: object - properties: &ref_432 - workspace_id: - type: string - path: - type: string - description: - type: string - resource_type: - type: string - value: {} - is_oauth: - type: boolean - extra_perms: - type: object - additionalProperties: - type: boolean - is_expired: - type: boolean - refresh_error: - type: string - is_linked: - type: boolean - is_refreshed: - type: boolean - account: - type: number - created_by: - type: string - edited_at: - type: string - format: date-time - labels: - type: array - items: - type: string - ws_specific: - type: boolean - required: &ref_433 - - path - - resource_type - - is_oauth - - is_linked - - is_refreshed + properties: *ref_80 + required: *ref_81 /w/{workspace}/resources/list_search: get: summary: list resources for search @@ -9565,7 +9763,7 @@ paths: - name: name in: path required: true - schema: &ref_273 + schema: &ref_278 type: string responses: '200': @@ -9602,7 +9800,7 @@ paths: application/json: schema: type: object - properties: &ref_77 + properties: &ref_82 workspace_id: type: string name: @@ -9619,7 +9817,7 @@ paths: type: string is_fileset: type: boolean - required: &ref_78 + required: &ref_83 - name responses: '201': @@ -9698,7 +9896,7 @@ paths: application/json: schema: type: object - properties: &ref_434 + properties: &ref_436 schema: {} description: type: string @@ -9733,8 +9931,8 @@ paths: application/json: schema: type: object - properties: *ref_77 - required: *ref_78 + properties: *ref_82 + required: *ref_83 /w/{workspace}/resources/type/exists/{path}: get: summary: does resource_type exists @@ -9778,8 +9976,8 @@ paths: type: array items: type: object - properties: *ref_77 - required: *ref_78 + properties: *ref_82 + required: *ref_83 /w/{workspace}/resources/type/listnames: get: summary: list resource_types names @@ -10061,7 +10259,7 @@ paths: - name: id in: path required: true - schema: *ref_79 + schema: *ref_84 responses: '200': description: flow @@ -10075,7 +10273,7 @@ paths: description: >- Top-level flow definition containing metadata, configuration, and the flow structure - properties: &ref_120 + properties: &ref_124 summary: type: string description: Short description of what this flow does @@ -10087,7 +10285,7 @@ paths: description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: &ref_610 + properties: &ref_611 modules: type: array description: >- @@ -10098,7 +10296,7 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: &ref_82 + properties: &ref_87 id: type: string description: >- @@ -10112,14 +10310,14 @@ paths: Can be a script (inline or referenced), subflow, loop, branch, or special module type - oneOf: &ref_88 + oneOf: &ref_93 - type: object description: >- Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms - properties: &ref_323 + properties: &ref_328 input_transforms: type: object description: >- @@ -10133,14 +10331,14 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: &ref_80 + oneOf: &ref_85 - type: object description: >- Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource' - properties: &ref_136 + properties: &ref_140 value: description: >- The static value. For resources, use @@ -10149,7 +10347,7 @@ paths: type: string enum: - static - required: &ref_137 + required: &ref_141 - type - type: object description: >- @@ -10159,7 +10357,7 @@ paths: inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value - properties: &ref_84 + properties: &ref_89 expr: type: string description: >- @@ -10172,7 +10370,7 @@ paths: type: string enum: - javascript - required: &ref_85 + required: &ref_90 - expr - type - type: object @@ -10180,14 +10378,14 @@ paths: Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter. - properties: &ref_86 + properties: &ref_91 type: type: string enum: - ai - required: &ref_87 + required: &ref_92 - type - discriminator: &ref_81 + discriminator: &ref_86 propertyName: type mapping: static: >- @@ -10297,7 +10495,7 @@ paths: - r - w - rw - required: &ref_324 + required: &ref_329 - type - content - language @@ -10307,7 +10505,7 @@ paths: Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code - properties: &ref_325 + properties: &ref_330 input_transforms: type: object description: >- @@ -10321,8 +10519,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 path: type: string description: >- @@ -10347,7 +10545,7 @@ paths: description: >- If true, this script is a trigger that can start the flow - required: &ref_326 + required: &ref_331 - type - path - input_transforms @@ -10356,7 +10554,7 @@ paths: Reference to an existing flow by path. Use this to call another flow as a subflow - properties: &ref_327 + properties: &ref_332 input_transforms: type: object description: >- @@ -10370,8 +10568,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 path: type: string description: >- @@ -10381,7 +10579,7 @@ paths: type: string enum: - flow - required: &ref_328 + required: &ref_333 - type - path - input_transforms @@ -10394,7 +10592,7 @@ paths: 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations - properties: &ref_329 + properties: &ref_334 modules: type: array description: >- @@ -10406,8 +10604,8 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: &ref_83 + properties: *ref_87 + required: &ref_88 - value - id iterator: @@ -10416,8 +10614,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 skip_failures: type: boolean description: >- @@ -10439,11 +10637,11 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 squash: type: boolean - required: &ref_330 + required: &ref_335 - modules - iterator - skip_failures @@ -10455,7 +10653,7 @@ paths: condition after each iteration. Use stop_after_if on modules to control loop termination - properties: &ref_331 + properties: &ref_336 modules: type: array description: >- @@ -10467,8 +10665,8 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 skip_failures: type: boolean description: >- @@ -10489,11 +10687,11 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 squash: type: boolean - required: &ref_332 + required: &ref_337 - modules - skip_failures - type @@ -10505,7 +10703,7 @@ paths: one with a true expression runs. If no branches match, the default branch executes - properties: &ref_333 + properties: &ref_338 branches: type: array description: >- @@ -10536,8 +10734,8 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 required: - modules - expr @@ -10551,13 +10749,13 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 type: type: string enum: - branchone - required: &ref_334 + required: &ref_339 - branches - default - type @@ -10568,7 +10766,7 @@ paths: BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently - properties: &ref_335 + properties: &ref_340 branches: type: array description: >- @@ -10595,8 +10793,8 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 required: - modules type: @@ -10609,7 +10807,7 @@ paths: If true, all branches execute concurrently. If false, they execute sequentially - required: &ref_336 + required: &ref_341 - branches - type - type: object @@ -10617,7 +10815,7 @@ paths: Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder - properties: &ref_337 + properties: &ref_342 type: type: string enum: @@ -10627,7 +10825,7 @@ paths: description: >- If true, marks this as a flow identity (special handling) - required: &ref_338 + required: &ref_343 - type - type: object description: >- @@ -10635,7 +10833,7 @@ paths: accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task - properties: &ref_339 + properties: &ref_344 input_transforms: type: object description: >- @@ -10647,22 +10845,22 @@ paths: Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined - oneOf: &ref_341 + oneOf: &ref_346 - type: object description: >- Static provider configuration passed directly to the AI agent - properties: &ref_595 + properties: &ref_596 value: type: object description: >- Complete AI provider configuration with resource reference and model selection - properties: &ref_593 + properties: &ref_594 kind: type: string description: Supported AI provider types - enum: &ref_316 + enum: &ref_321 - openai - azure_openai - anthropic @@ -10685,7 +10883,7 @@ paths: description: >- Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro') - required: &ref_594 + required: &ref_595 - kind - resource - model @@ -10693,7 +10891,7 @@ paths: type: string enum: - static - required: &ref_596 + required: &ref_597 - type - value - type: object @@ -10704,16 +10902,16 @@ paths: inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value - properties: *ref_84 - required: *ref_85 + properties: *ref_89 + required: *ref_90 - type: object description: >- Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter. - properties: *ref_86 - required: *ref_87 - discriminator: &ref_342 + properties: *ref_91 + required: *ref_92 + discriminator: &ref_347 propertyName: type mapping: static: >- @@ -10728,8 +10926,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Output format type. @@ -10743,8 +10941,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: >- The user's prompt/message to the AI agent. Supports variable interpolation @@ -10756,8 +10954,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: >- System instructions that guide the AI's behavior, persona, and response style. @@ -10769,8 +10967,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Boolean. If true, stream the AI response incrementally. @@ -10783,27 +10981,27 @@ paths: Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined - oneOf: &ref_343 + oneOf: &ref_348 - type: object description: >- Static memory configuration passed directly to the AI agent - properties: &ref_601 + properties: &ref_602 value: description: Conversation memory configuration - oneOf: &ref_599 + oneOf: &ref_600 - type: object description: No conversation memory/context - properties: &ref_317 + properties: &ref_322 kind: type: string enum: - 'off' - required: &ref_318 + required: &ref_323 - kind - type: object description: Automatic context management - properties: &ref_319 + properties: &ref_324 kind: type: string enum: @@ -10818,11 +11016,11 @@ paths: description: >- Identifier for persistent memory across agent invocations - required: &ref_320 + required: &ref_325 - kind - type: object description: Explicit message history - properties: &ref_321 + properties: &ref_326 kind: type: string enum: @@ -10832,7 +11030,7 @@ paths: items: type: object description: A single message in conversation history - properties: &ref_597 + properties: &ref_598 role: type: string enum: @@ -10841,13 +11039,13 @@ paths: - system content: type: string - required: &ref_598 + required: &ref_599 - role - content - required: &ref_322 + required: &ref_327 - kind - messages - discriminator: &ref_600 + discriminator: &ref_601 propertyName: kind mapping: 'off': '#/components/schemas/MemoryOff' @@ -10857,7 +11055,7 @@ paths: type: string enum: - static - required: &ref_602 + required: &ref_603 - type - value - type: object @@ -10868,16 +11066,16 @@ paths: inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value - properties: *ref_84 - required: *ref_85 + properties: *ref_89 + required: *ref_90 - type: object description: >- Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter. - properties: *ref_86 - required: *ref_87 - discriminator: &ref_344 + properties: *ref_91 + required: *ref_92 + discriminator: &ref_349 propertyName: type mapping: static: >- @@ -10892,8 +11090,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > JSON Schema object defining structured output format. Used when you need the AI @@ -10914,8 +11112,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Array of file references (images or PDFs) for the AI agent. @@ -10932,8 +11130,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Integer. Maximum number of tokens the AI will generate in its response. @@ -10947,8 +11145,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Float. Controls randomness/creativity of responses. @@ -10967,8 +11165,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Number. Limits how many times the agent can loop through reasoning and tool use. @@ -10990,7 +11188,7 @@ paths: A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool - properties: &ref_345 + properties: &ref_350 id: type: string description: >- @@ -11008,12 +11206,12 @@ paths: The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference - oneOf: &ref_608 + oneOf: &ref_609 - description: >- A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module - allOf: &ref_603 + allOf: &ref_604 - type: object properties: tool_type: @@ -11027,8 +11225,8 @@ paths: step. Can be a script (inline or referenced), subflow, loop, branch, or special module type - oneOf: *ref_88 - discriminator: &ref_89 + oneOf: *ref_93 + discriminator: &ref_94 propertyName: type mapping: rawscript: '#/components/schemas/schemas-RawScript' @@ -11046,7 +11244,7 @@ paths: Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers - properties: &ref_604 + properties: &ref_605 tool_type: type: string enum: @@ -11070,7 +11268,7 @@ paths: MCP server items: type: string - required: &ref_605 + required: &ref_606 - tool_type - resource_path - type: object @@ -11078,26 +11276,32 @@ paths: A tool implemented as a websearch tool. The AI can call this like any other websearch tool - properties: &ref_606 + properties: &ref_607 tool_type: type: string enum: - websearch - required: &ref_607 + required: &ref_608 - tool_type - discriminator: &ref_609 + discriminator: &ref_610 propertyName: tool_type mapping: flowmodule: '#/components/schemas/FlowModuleTool' mcp: '#/components/schemas/McpToolValue' websearch: '#/components/schemas/WebsearchToolValue' - required: &ref_346 + required: &ref_351 - id - value type: type: string enum: - aiagent + tag: + type: string + description: >- + Worker group tag for execution routing. + If not set, the AI agent step runs on + the flow's tag (default `flow`) omit_output_from_conversation: type: boolean default: false @@ -11111,15 +11315,15 @@ paths: description: >- If true, the agent can execute multiple tool calls in parallel - required: &ref_340 + required: &ref_345 - tools - type - input_transforms - discriminator: *ref_89 + discriminator: *ref_94 stop_after_if: description: Early termination condition for a module type: object - properties: &ref_90 + properties: &ref_95 skip_if_stopped: type: boolean description: >- @@ -11143,13 +11347,22 @@ paths: If empty string, a default error message is used. If null or omitted, no error is raised. - required: &ref_91 + error_include_result: + type: boolean + description: >- + When stopping with an error + (error_message set), embed the stopping + step's own result inside the raised + error object (as error.result) instead + of discarding it. The top-level result + stays { error }. Defaults to false. + required: &ref_96 - expr stop_after_all_iters_if: description: Early termination condition for a module type: object - properties: *ref_90 - required: *ref_91 + properties: *ref_95 + required: *ref_96 skip_if: type: object description: >- @@ -11169,8 +11382,8 @@ paths: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 cache_ttl: type: number description: >- @@ -11183,8 +11396,8 @@ paths: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 delete_after_secs: type: integer description: >- @@ -11243,8 +11456,8 @@ paths: a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 self_approval_disabled: type: boolean description: >- @@ -11275,7 +11488,7 @@ paths: Retry configuration for failed module executions type: object - properties: &ref_315 + properties: &ref_320 constant: type: object description: >- @@ -11316,14 +11529,14 @@ paths: description: >- Conditional retry based on error or result - properties: &ref_195 + properties: &ref_199 expr: type: string description: >- JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables - required: &ref_196 + required: &ref_200 - expr debouncing: description: >- @@ -11360,21 +11573,21 @@ paths: description: >- Maximum number of debounces before forced execution - required: *ref_83 + required: *ref_88 failure_module: description: >- A single step in a flow. Can be a script, subflow, loop, or branch type: object - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 preprocessor_module: description: >- A single step in a flow. Can be a script, subflow, loop, or branch type: object - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 same_worker: type: boolean description: >- @@ -11464,7 +11677,7 @@ paths: description: >- A sticky note attached to a flow for documentation and annotation - properties: &ref_145 + properties: &ref_149 id: type: string description: Unique identifier for the note @@ -11523,7 +11736,7 @@ paths: description: >- For group notes, the IDs of nodes contained within this group - required: &ref_146 + required: &ref_150 - id - text - color @@ -11543,7 +11756,7 @@ paths: collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id. - properties: &ref_147 + properties: &ref_151 summary: type: string description: Display name for this group @@ -11569,10 +11782,10 @@ paths: color: type: string description: Color for the group in the flow editor - required: &ref_148 + required: &ref_152 - start_id - end_id - required: &ref_611 + required: &ref_612 - modules schema: type: object @@ -11586,7 +11799,7 @@ paths: description: >- The flow will be run with the permissions of the user with this email. - required: &ref_121 + required: &ref_125 - summary - value /apps/hub/list: @@ -11639,7 +11852,7 @@ paths: - name: id in: path required: true - schema: *ref_79 + schema: *ref_84 responses: '200': description: app @@ -11669,7 +11882,7 @@ paths: - name: id in: path required: true - schema: *ref_79 + schema: *ref_84 responses: '200': description: raw app @@ -11699,7 +11912,7 @@ paths: - name: custom_path in: path required: true - schema: &ref_133 + schema: &ref_137 type: string responses: '200': @@ -11709,7 +11922,7 @@ paths: schema: allOf: - type: object - properties: &ref_128 + properties: &ref_132 id: type: integer workspace_id: @@ -11730,7 +11943,7 @@ paths: value: {} policy: type: object - properties: &ref_127 + properties: &ref_131 triggerables: type: object additionalProperties: @@ -11783,7 +11996,7 @@ paths: items: type: string default: [] - required: &ref_129 + required: &ref_133 - id - workspace_id - path @@ -11810,7 +12023,7 @@ paths: - name: path in: path required: true - schema: &ref_92 + schema: &ref_97 type: string responses: '200': @@ -11829,7 +12042,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script details @@ -11860,7 +12073,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script pick recorded @@ -11923,7 +12136,7 @@ paths: type: number kind: type: string - enum: &ref_93 + enum: &ref_98 - script - failure - trigger @@ -11994,7 +12207,7 @@ paths: type: string kind: type: string - enum: *ref_93 + enum: *ref_98 score: type: number required: @@ -12056,7 +12269,7 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: &ref_118 + schema: &ref_122 type: boolean - name: created_by description: >- @@ -12064,7 +12277,7 @@ paths: (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: &ref_119 + schema: &ref_123 type: string - name: path_start description: mask to filter matching starting path @@ -12204,183 +12417,225 @@ paths: schema: type: array items: - type: object - properties: &ref_99 - workspace_id: - type: string - hash: - type: string - path: - type: string - parent_hashes: - type: array - description: > - The first element is the direct parent of the script, - the second is the parent of the first, etc - items: - type: string - summary: - type: string - description: - type: string - content: - type: string - created_by: - type: string - created_at: - type: string - format: date-time - archived: - type: boolean - schema: - type: object - deleted: - type: boolean - is_template: - type: boolean - extra_perms: - type: object - additionalProperties: - type: boolean - lock: - type: string - lock_error_logs: - type: string - language: - type: string - enum: &ref_94 - - python3 - - deno - - go - - bash - - powershell - - postgresql - - mysql - - bigquery - - snowflake - - mssql - - oracledb - - graphql - - nativets - - bun - - php - - rust - - ansible - - csharp - - nu - - java - - ruby - - rlang - - duckdb - - bunnative - kind: - type: string - enum: - - script - - failure - - trigger - - command - - approval - - preprocessor - starred: - type: boolean - tag: - type: string - has_draft: - type: boolean - draft_only: - type: boolean - envs: - type: array - items: - type: string - concurrent_limit: - type: integer - concurrency_time_window_s: - type: integer - concurrency_key: - type: string - debounce_key: - type: string - debounce_delay_s: - type: integer - debounce_args_to_accumulate: - type: array - items: - type: string - max_total_debouncing_time: - type: integer - max_total_debounces_amount: - type: integer - cache_ttl: - type: number - dedicated_worker: - type: boolean - ws_error_handler_muted: - type: boolean - priority: - type: integer - restart_unless_cancelled: - type: boolean - timeout: - type: integer - delete_after_secs: - type: integer - description: >- - If set, delete the job's args, result and logs after - this many seconds following job completion - visible_to_runner_only: - type: boolean - auto_kind: - type: string - codebase: - type: string - has_preprocessor: - type: boolean - on_behalf_of_email: - type: string - modules: - type: object - nullable: true - description: Additional script modules keyed by relative file path - additionalProperties: - type: object - description: An additional module file associated with a script - properties: &ref_95 - content: + allOf: + - type: object + properties: &ref_105 + workspace_id: + type: string + hash: + type: string + path: + type: string + parent_hashes: + type: array + description: > + The first element is the direct parent of the + script, the second is the parent of the first, etc + items: type: string - description: The source code content of this module - language: + summary: + type: string + description: + type: string + content: + type: string + created_by: + type: string + created_at: + type: string + format: date-time + archived: + type: boolean + schema: + type: object + deleted: + type: boolean + is_template: + type: boolean + extra_perms: + type: object + additionalProperties: + type: boolean + lock: + type: string + lock_error_logs: + type: string + language: + type: string + enum: &ref_99 + - python3 + - deno + - go + - bash + - powershell + - postgresql + - mysql + - bigquery + - snowflake + - mssql + - oracledb + - graphql + - nativets + - bun + - php + - rust + - ansible + - csharp + - nu + - java + - ruby + - rlang + - duckdb + - bunnative + kind: + type: string + enum: + - script + - failure + - trigger + - command + - approval + - preprocessor + starred: + type: boolean + tag: + type: string + draft_only: + type: boolean + envs: + type: array + items: type: string - enum: *ref_94 - lock: + concurrent_limit: + type: integer + concurrency_time_window_s: + type: integer + concurrency_key: + type: string + debounce_key: + type: string + debounce_delay_s: + type: integer + debounce_args_to_accumulate: + type: array + items: type: string - nullable: true - description: Lock file content for this module's dependencies - required: &ref_96 - - content - - language - labels: - type: array - items: - type: string - default: [] - required: &ref_100 - - hash - - path - - summary - - description - - content - - created_by - - created_at - - archived - - deleted - - is_template - - extra_perms - - language - - kind - - starred - - has_preprocessor + max_total_debouncing_time: + type: integer + max_total_debounces_amount: + type: integer + cache_ttl: + type: number + dedicated_worker: + type: boolean + ws_error_handler_muted: + type: boolean + priority: + type: integer + restart_unless_cancelled: + type: boolean + timeout: + type: integer + delete_after_secs: + type: integer + description: >- + If set, delete the job's args, result and logs after + this many seconds following job completion + visible_to_runner_only: + type: boolean + auto_kind: + type: string + codebase: + type: string + has_preprocessor: + type: boolean + on_behalf_of_email: + type: string + modules: + type: object + nullable: true + description: >- + Additional script modules keyed by relative file + path + additionalProperties: + type: object + description: An additional module file associated with a script + properties: &ref_101 + content: + type: string + description: The source code content of this module + language: + type: string + enum: *ref_99 + lock: + type: string + nullable: true + description: >- + Lock file content for this module's + dependencies + required: &ref_102 + - content + - language + labels: + type: array + items: + type: string + default: [] + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + required: &ref_106 + - hash + - path + - summary + - description + - content + - created_by + - created_at + - archived + - deleted + - is_template + - extra_perms + - language + - kind + - starred + - has_preprocessor + - type: object + properties: + is_draft: + type: boolean + description: | + True when the authed user has a draft for this + script — either no deployed row exists at this + path (draft-only) or the user saved a per-user + draft on top of the deployed row. + draft_path: + type: string + description: | + User-typed path the editor has staged but not + yet deployed. Surfaced for draft-only rows so + the home list can render the meaningful name + instead of the autogenerated + `u/{user}/draft_{uuid}` URL path. Omitted + when unchanged. + draft_users: + description: | + Workspace users (including the authed user, and + the legacy NULL-email row if any) who have a + per-user draft at this path. Drives the home + page's user-avatar circles inside the Draft + badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true /w/{workspace}/scripts/list_paths: get: summary: list all scripts paths @@ -12401,10 +12656,12 @@ paths: type: array items: type: string - /w/{workspace}/drafts/create: - post: - summary: create draft - operationId: createDraft + /w/{workspace}/drafts/list: + get: + summary: >- + list every draft the current user has in this workspace, across all + kinds + operationId: listDrafts tags: - draft parameters: @@ -12412,37 +12669,75 @@ paths: in: path required: true schema: *ref_4 - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - path: - type: string - typ: - type: string - enum: - - flow - - script - - app - value: {} - required: - - path - - typ - - enum responses: - '201': - description: draft created + '200': + description: the user's drafts content: - text/plain: + application/json: schema: - type: string - /w/{workspace}/drafts/delete/{kind}/{path}: - delete: - summary: delete draft - operationId: deleteDraft + type: array + items: + type: object + properties: + kind: + type: string + description: > + Closed set of item kinds a user can autosave as a draft. + Mirrors the + + Postgres `DRAFT_KIND` enum and the backend + `UserDraftItemKind`. + enum: &ref_100 + - 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 + path: + type: string + summary: + type: string + description: >- + Best-effort, read from the draft JSON's `summary` field + when the editor shape carries one. + draft_only: + type: boolean + description: >- + No deployed counterpart exists at this path — the draft + is the whole item. + created_at: + type: string + format: date-time + required: + - kind + - path + - draft_only + - created_at + /w/{workspace}/drafts/get/{kind}/{path}: + get: + summary: >- + fetch a single draft's content by workspace username (or the legacy + workspace-level row) + operationId: getDraftForUser tags: - draft parameters: @@ -12455,21 +12750,107 @@ paths: required: true schema: type: string - enum: - - script - - flow - - app + description: > + Closed set of item kinds a user can autosave as a draft. Mirrors + the + + Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`. + enum: *ref_100 - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 + - name: username + in: query + required: false + description: >- + Workspace username of the draft owner. Omit to fetch the legacy + workspace-level (NULL email) row. + schema: + type: string responses: '200': - description: draft deleted + description: draft content content: - text/plain: + application/json: schema: - type: string + type: object + properties: + value: {} + created_at: + type: string + format: date-time + required: + - value + - created_at + '404': + description: no draft for that owner at that path + /w/{workspace}/drafts/update/{kind}/{path}: + post: + summary: upsert (or clear) the current user's draft at a path + operationId: updateDraft + tags: + - draft + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + - name: kind + in: path + required: true + schema: + type: string + description: > + Closed set of item kinds a user can autosave as a draft. Mirrors + the + + Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`. + enum: *ref_100 + - name: path + in: path + required: true + schema: *ref_97 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + value: + nullable: true + description: >- + Draft content to save. `null` (or omitted) signals a delete + — the row is removed under the same conflict rules. + last_sync: + type: string + format: date-time + description: >- + Server timestamp of the client's last known sync for this + draft. Omit on first save. + force: + type: boolean + description: Skip the conflict check and overwrite the server copy. + responses: + '200': + description: save result + content: + application/json: + schema: + type: object + properties: + status: + type: string + enum: + - saved + - conflict + current_timestamp: + type: string + format: date-time + required: + - status + - current_timestamp /w/{workspace}/scripts/create: post: summary: create script @@ -12507,7 +12888,7 @@ paths: application/json: schema: type: object - properties: &ref_104 + properties: &ref_392 path: type: string parent_hash: @@ -12526,7 +12907,7 @@ paths: type: string language: type: string - enum: *ref_94 + enum: *ref_99 kind: type: string enum: @@ -12538,8 +12919,6 @@ paths: - preprocessor tag: type: string - draft_only: - type: boolean envs: type: array items: @@ -12611,7 +12990,7 @@ paths: type: string kind: type: string - enum: &ref_301 + enum: &ref_306 - s3object - resource - ducklake @@ -12636,8 +13015,8 @@ paths: additionalProperties: type: object description: An additional module file associated with a script - properties: *ref_95 - required: *ref_96 + properties: *ref_101 + required: *ref_102 labels: type: array items: @@ -12647,7 +13026,7 @@ paths: description: >- When true (set by the CLI / git sync), deploying this script does not delete an existing user draft at the same path. - required: &ref_105 + required: &ref_393 - path - summary - content @@ -12673,7 +13052,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: Workspace error handler enabled required: true @@ -12780,19 +13159,19 @@ paths: application/json: schema: type: object - properties: &ref_385 + properties: &ref_390 workspace_id: type: string language: type: string - enum: *ref_94 + enum: *ref_99 name: type: string description: type: string content: type: string - required: &ref_386 + required: &ref_391 - workspace_id - language - content @@ -12819,7 +13198,7 @@ paths: required: true schema: type: string - enum: *ref_94 + enum: *ref_99 - name: name in: query required: false @@ -12847,7 +13226,7 @@ paths: required: true schema: type: string - enum: *ref_94 + enum: *ref_99 - name: name in: query required: false @@ -12879,7 +13258,7 @@ paths: type: array items: type: object - properties: &ref_97 + properties: &ref_103 id: type: integer archived: @@ -12892,13 +13271,13 @@ paths: type: string language: type: string - enum: *ref_94 + enum: *ref_99 workspace_id: type: string created_at: type: string format: date-time - required: &ref_98 + required: &ref_104 - workspace_id - language - created_at @@ -12921,7 +13300,7 @@ paths: required: true schema: type: string - enum: *ref_94 + enum: *ref_99 - name: name in: query required: false @@ -12934,8 +13313,8 @@ paths: application/json: schema: type: object - properties: *ref_97 - required: *ref_98 + properties: *ref_103 + required: *ref_104 /w/{workspace}/scripts/archive/p/{path}: post: summary: archive script by path @@ -12950,7 +13329,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script archived @@ -12972,7 +13351,7 @@ paths: - name: hash in: path required: true - schema: &ref_101 + schema: &ref_107 type: string responses: '200': @@ -12981,8 +13360,8 @@ paths: application/json: schema: type: object - properties: *ref_99 - required: *ref_100 + properties: *ref_105 + required: *ref_106 /w/{workspace}/scripts/delete/h/{hash}: post: summary: delete script by hash (erase content but keep hash, require admin) @@ -12998,7 +13377,7 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 responses: '200': description: script details @@ -13006,8 +13385,8 @@ paths: application/json: schema: type: object - properties: *ref_99 - required: *ref_100 + properties: *ref_105 + required: *ref_106 /w/{workspace}/scripts/delete/p/{path}: post: summary: delete script at a given path (require admin) @@ -13023,7 +13402,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: keep_captures description: keep captures in: query @@ -13085,20 +13464,63 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: with_starred_info in: query schema: type: boolean + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: script details content: application/json: schema: - type: object - properties: *ref_99 - required: *ref_100 + allOf: + - type: object + properties: *ref_105 + required: *ref_106 + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/scripts/get_triggers_count/{path}: get: summary: get triggers count of script @@ -13113,7 +13535,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: triggers count @@ -13121,7 +13543,7 @@ paths: application/json: schema: type: object - properties: &ref_125 + properties: &ref_129 primary_schedule: type: object properties: @@ -13173,7 +13595,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: tokens list @@ -13183,50 +13605,8 @@ paths: type: array items: type: object - properties: *ref_102 - required: *ref_103 - /w/{workspace}/scripts/get/draft/{path}: - get: - summary: get script by path with draft - operationId: getScriptByPathWithDraft - tags: - - script - parameters: - - name: workspace - in: path - required: true - schema: *ref_4 - - name: path - in: path - required: true - schema: *ref_92 - responses: - '200': - description: script details - content: - application/json: - schema: - allOf: &ref_387 - - type: object - properties: *ref_104 - required: *ref_105 - - type: object - properties: - draft: - type: object - properties: *ref_104 - required: *ref_105 - draft_created_at: - type: string - format: date-time - description: >- - Timestamp at which the most recent DB draft was - created. Used by the frontend's UserDraft staleness - check. - hash: - type: string - required: - - hash + properties: *ref_108 + required: *ref_109 /w/{workspace}/scripts/history/p/{path}: get: summary: get history of a script by path @@ -13241,7 +13621,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script history @@ -13251,12 +13631,12 @@ paths: type: array items: type: object - properties: &ref_106 + properties: &ref_110 script_hash: type: string deployment_msg: type: string - required: &ref_107 + required: &ref_111 - script_hash /w/{workspace}/scripts/list_paths_from_workspace_runnable/{path}: get: @@ -13272,7 +13652,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: list of script paths @@ -13294,7 +13674,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 tags: - script responses: @@ -13304,8 +13684,8 @@ paths: application/json: schema: type: object - properties: *ref_106 - required: *ref_107 + properties: *ref_110 + required: *ref_111 /w/{workspace}/scripts/history_update/h/{hash}/p/{path}: post: summary: update history of a script @@ -13320,11 +13700,11 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: Script deployment message required: true @@ -13412,7 +13792,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script content @@ -13436,12 +13816,12 @@ paths: - name: token in: path required: true - schema: &ref_305 + schema: &ref_310 type: string - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script content @@ -13463,7 +13843,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: does it exists @@ -13485,7 +13865,7 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 - name: with_starred_info in: query schema: @@ -13501,8 +13881,8 @@ paths: application/json: schema: type: object - properties: *ref_99 - required: *ref_100 + properties: *ref_105 + required: *ref_106 /w/{workspace}/scripts/raw/h/{path}: get: summary: raw script by hash @@ -13517,7 +13897,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: script content @@ -13539,7 +13919,7 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 responses: '200': description: script details @@ -13589,7 +13969,7 @@ paths: type: array items: type: object - properties: &ref_108 + properties: &ref_112 test_script_path: type: string job_id: @@ -13603,7 +13983,7 @@ paths: type: string format: date-time nullable: true - required: &ref_109 + required: &ref_113 - test_script_path /w/{workspace}/scripts/ci_test_results_batch: post: @@ -13652,8 +14032,8 @@ paths: type: array items: type: object - properties: *ref_108 - required: *ref_109 + properties: *ref_112 + required: *ref_113 /w/{workspace}/scripts/raw_temp/store: post: summary: store raw script content temporarily for CLI lock generation @@ -13720,7 +14100,7 @@ paths: type: string language: type: string - enum: *ref_94 + enum: *ref_99 name: description: named workspace dependency (null for default) type: string @@ -13817,7 +14197,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: scheduled_for description: when to schedule this job (leave empty for immediate run) in: query @@ -13839,20 +14219,20 @@ paths: The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: &ref_110 + schema: &ref_114 type: string format: uuid - name: tag description: Override the tag to use in: query - schema: &ref_111 + schema: &ref_115 type: string - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: &ref_112 + schema: &ref_116 type: string - name: job_id description: >- @@ -13861,7 +14241,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: &ref_113 + schema: &ref_117 type: string format: uuid - name: invisible_to_owner @@ -13900,23 +14280,23 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -13924,7 +14304,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -13933,19 +14313,19 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: &ref_114 + schema: &ref_118 type: string - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: &ref_115 + schema: &ref_119 type: string - name: skip_preprocessor description: skip the preprocessor in: query - schema: &ref_116 + schema: &ref_120 type: boolean requestBody: description: script args @@ -13975,23 +14355,23 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -13999,7 +14379,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14008,13 +14388,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14022,12 +14402,12 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: &ref_117 + schema: &ref_121 type: string - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 responses: '200': description: job result @@ -14048,7 +14428,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14057,13 +14437,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14071,11 +14451,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14123,13 +14503,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14137,11 +14517,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14188,13 +14568,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14202,7 +14582,7 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14210,11 +14590,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14241,7 +14621,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14250,13 +14630,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14264,11 +14644,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14310,7 +14690,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14319,13 +14699,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14333,7 +14713,7 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14341,11 +14721,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14391,13 +14771,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14405,11 +14785,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14463,13 +14843,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14477,7 +14857,7 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14485,11 +14865,11 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -14523,23 +14903,23 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14547,7 +14927,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14556,17 +14936,17 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: poll_delay_ms description: delay between polling for job updates in milliseconds in: query @@ -14602,23 +14982,23 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14626,7 +15006,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14635,13 +15015,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14649,11 +15029,11 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: poll_delay_ms description: delay between polling for job updates in milliseconds in: query @@ -14688,17 +15068,17 @@ paths: The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14706,7 +15086,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14715,17 +15095,17 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: poll_delay_ms description: delay between polling for job updates in milliseconds in: query @@ -14768,17 +15148,17 @@ paths: The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -14786,7 +15166,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -14795,13 +15175,13 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -14809,11 +15189,11 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: poll_delay_ms description: delay between polling for job updates in milliseconds in: query @@ -14854,6 +15234,35 @@ paths: content: application/json: schema: {} + /w/{workspace}/jobs/job_view_token/{id}: + get: + summary: mint a read-only share token for a job + description: > + Returns a stateless `{job_id}.{hmac}` token that grants an authenticated + workspace member read access to this job (and its flow subtree) via a + `view_token` query param or `X-View-Token` header. Only callable by a + user who can already read the job. + operationId: getJobViewToken + tags: + - job + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: the share read token + content: + text/plain: + schema: + type: string /w/{workspace}/flows/list_paths: get: summary: list all flow paths @@ -14924,14 +15333,14 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: path_start description: mask to filter matching starting path in: query @@ -15006,15 +15415,15 @@ paths: type: array items: allOf: - - allOf: &ref_124 + - allOf: &ref_128 - type: object description: >- Top-level flow definition containing metadata, configuration, and the flow structure - properties: *ref_120 - required: *ref_121 + properties: *ref_124 + required: *ref_125 - type: object - properties: &ref_512 + properties: &ref_514 workspace_id: type: string path: @@ -15028,7 +15437,7 @@ paths: type: boolean extra_perms: type: object - additionalProperties: &ref_511 + additionalProperties: &ref_513 type: boolean starred: type: boolean @@ -15053,7 +15462,15 @@ paths: items: type: string default: [] - required: &ref_513 + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, + computed at read time. Read-only — edit them on + the folder. + required: &ref_515 - path - edited_by - edited_at @@ -15067,10 +15484,40 @@ paths: type: number - type: object properties: - has_draft: - type: boolean draft_only: type: boolean + is_draft: + type: boolean + description: | + True when the authed user has a draft for this + flow — either no deployed row exists at this + path (draft-only) or the user saved a per-user + draft on top of the deployed row. + draft_path: + type: string + description: | + User-typed path the editor has staged but not + yet deployed. Sourced from the draft JSON's + `draft_path` field (the editor only writes it + when the typed path differs from the deployed + one). Lets the home list render the meaningful + name instead of the autogenerated + `u/{user}/draft_{uuid}` URL path. Omitted when + unchanged. + draft_users: + description: | + Workspace users (including the authed user, and + the legacy NULL-email row if any) who have a + per-user draft at this path. Drives the home + page's user-avatar circles inside the Draft + badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true /w/{workspace}/flows/history/p/{path}: get: summary: get flow history by path @@ -15083,7 +15530,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 tags: - flow responses: @@ -15095,7 +15542,7 @@ paths: type: array items: type: object - properties: &ref_122 + properties: &ref_126 id: type: integer created_at: @@ -15103,7 +15550,7 @@ paths: format: date-time deployment_msg: type: string - required: &ref_123 + required: &ref_127 - id - created_at /w/{workspace}/flows/get_latest_version/{path}: @@ -15118,7 +15565,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 tags: - flow responses: @@ -15128,8 +15575,8 @@ paths: application/json: schema: type: object - properties: *ref_122 - required: *ref_123 + properties: *ref_126 + required: *ref_127 /w/{workspace}/flows/list_paths_from_workspace_runnable/{runnable_kind}/{path}: get: summary: list flow paths from workspace runnable @@ -15144,7 +15591,7 @@ paths: - name: runnable_kind in: path required: true - schema: &ref_132 + schema: &ref_136 type: string enum: - script @@ -15152,7 +15599,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: match_path_start in: query schema: @@ -15188,7 +15635,7 @@ paths: content: application/json: schema: - allOf: *ref_124 + allOf: *ref_128 /w/{workspace}/flows/history_update/v/{version}: post: summary: update flow history @@ -15239,18 +15686,61 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: with_starred_info in: query schema: type: boolean + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: flow details content: application/json: schema: - allOf: *ref_124 + allOf: + - allOf: *ref_128 + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/flows/deployment_status/p/{path}: get: summary: get flow deployment status @@ -15265,7 +15755,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: flow status @@ -15293,7 +15783,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: triggers count @@ -15301,7 +15791,7 @@ paths: application/json: schema: type: object - properties: *ref_125 + properties: *ref_129 /w/{workspace}/flows/list_tokens/{path}: get: summary: get tokens with flow scope @@ -15316,7 +15806,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: tokens list @@ -15326,8 +15816,8 @@ paths: type: array items: type: object - properties: *ref_102 - required: *ref_103 + properties: *ref_108 + required: *ref_109 /w/{workspace}/flows/toggle_workspace_error_handler/{path}: post: summary: Toggle ON and OFF the workspace error handler for a given flow @@ -15342,7 +15832,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: Workspace error handler enabled required: true @@ -15360,40 +15850,6 @@ paths: text/plain: schema: type: string - /w/{workspace}/flows/get/draft/{path}: - get: - summary: get flow by path with draft - operationId: getFlowByPathWithDraft - tags: - - flow - parameters: - - name: workspace - in: path - required: true - schema: *ref_4 - - name: path - in: path - required: true - schema: *ref_92 - responses: - '200': - description: flow details with draft - content: - application/json: - schema: - allOf: - - allOf: *ref_124 - - type: object - properties: - draft: - allOf: *ref_124 - draft_created_at: - type: string - format: date-time - description: >- - Timestamp at which the most recent DB draft was - created. Used by the frontend's UserDraft staleness - check. /w/{workspace}/flows/exists/{path}: get: summary: exists flow by path @@ -15408,7 +15864,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: flow details @@ -15446,13 +15902,13 @@ paths: application/json: schema: allOf: - - allOf: &ref_126 + - allOf: &ref_130 - type: object description: >- Top-level flow definition containing metadata, configuration, and the flow structure - properties: *ref_120 - required: *ref_121 + properties: *ref_124 + required: *ref_125 - type: object properties: path: @@ -15485,8 +15941,6 @@ paths: - path - type: object properties: - draft_only: - type: boolean deployment_message: type: string skip_draft_deletion: @@ -15528,7 +15982,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: Partially filled flow required: true @@ -15536,7 +15990,7 @@ paths: application/json: schema: allOf: - - allOf: *ref_126 + - allOf: *ref_130 - type: object properties: deployment_message: @@ -15568,7 +16022,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: archiveFlow required: true @@ -15601,7 +16055,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: keep_captures description: keep captures in: query @@ -15647,14 +16101,14 @@ paths: type: array items: type: object - required: &ref_372 + required: &ref_377 - id - workspace_id - flow_path - created_at - updated_at - created_by - properties: &ref_373 + properties: &ref_378 id: type: string format: uuid @@ -15747,14 +16201,14 @@ paths: type: array items: type: object - required: &ref_374 + required: &ref_379 - id - conversation_id - message_type - content - created_at - created_seq - properties: &ref_375 + properties: &ref_380 id: type: string format: uuid @@ -15860,14 +16314,14 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: path_start description: mask to filter matching starting path in: query @@ -15891,6 +16345,17 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: &ref_212 + type: boolean responses: '200': description: All raw apps @@ -15900,7 +16365,7 @@ paths: type: array items: type: object - properties: &ref_520 + properties: &ref_522 workspace_id: type: string path: @@ -15923,7 +16388,14 @@ paths: items: type: string default: [] - required: &ref_521 + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + required: &ref_523 - workspace_id - path - summary @@ -16135,14 +16607,14 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: path_start description: mask to filter matching starting path in: query @@ -16189,7 +16661,7 @@ paths: type: array items: type: object - properties: &ref_514 + properties: &ref_516 id: type: integer workspace_id: @@ -16222,7 +16694,61 @@ paths: items: type: string default: [] - required: &ref_515 + is_draft: + type: boolean + description: > + True when the authed user has a draft for this app — + either no + + deployed row exists at this path (draft-only) or the + user has + + saved a per-user draft on top of the deployed row. + draft_path: + type: string + description: > + User-typed path the editor has staged but not yet + deployed. + + Sourced from the draft JSON's `draft_path` field (the + editor + + only writes it when the typed path differs from the + deployed + + one). Lets the home list render the meaningful name + instead of + + the autogenerated `u/{user}/draft_{uuid}` URL path. + Omitted + + when unchanged. + draft_users: + description: > + Workspace users (including the authed user, and the + legacy + + NULL-email row if any) who have a per-user draft at this + + path. Drives the home page's user-avatar circles inside + the + + Draft badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + required: &ref_517 - id - workspace_id - path @@ -16267,9 +16793,7 @@ paths: type: string policy: type: object - properties: *ref_127 - draft_only: - type: boolean + properties: *ref_131 deployment_message: type: string custom_path: @@ -16330,9 +16854,7 @@ paths: type: string policy: type: object - properties: *ref_127 - draft_only: - type: boolean + properties: *ref_131 deployment_message: type: string custom_path: @@ -16406,20 +16928,71 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: with_starred_info in: query schema: type: boolean + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 + - name: raw_app + in: query + description: | + When no deployed app exists at this path and `get_draft` is set, + disambiguates which draft kind (`raw_app` or `app`) to look up. + Ignored when a deployed row exists. + schema: + type: boolean responses: '200': description: app details content: application/json: schema: - type: object - properties: *ref_128 - required: *ref_129 + allOf: + - type: object + properties: *ref_132 + required: *ref_133 + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/apps/get/lite/{path}: get: summary: get app lite by path @@ -16434,7 +17007,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: app lite details @@ -16442,45 +17015,8 @@ paths: application/json: schema: type: object - properties: *ref_128 - required: *ref_129 - /w/{workspace}/apps/get/draft/{path}: - get: - summary: get app by path with draft - operationId: getAppByPathWithDraft - tags: - - app - parameters: - - name: workspace - in: path - required: true - schema: *ref_4 - - name: path - in: path - required: true - schema: *ref_92 - responses: - '200': - description: app details with draft - content: - application/json: - schema: - allOf: &ref_522 - - type: object - properties: *ref_128 - required: *ref_129 - - type: object - properties: - draft_only: - type: boolean - draft: {} - draft_created_at: - type: string - format: date-time - description: >- - Timestamp at which the most recent DB draft was - created. Used by the frontend's UserDraft staleness - check. + properties: *ref_132 + required: *ref_133 /w/{workspace}/apps/history/p/{path}: get: summary: get app history by path @@ -16495,7 +17031,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: app history @@ -16505,12 +17041,12 @@ paths: type: array items: type: object - properties: &ref_130 + properties: &ref_134 version: type: integer deployment_msg: type: string - required: &ref_131 + required: &ref_135 - version /w/{workspace}/apps/get_latest_version/{path}: get: @@ -16524,7 +17060,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 tags: - app responses: @@ -16534,8 +17070,8 @@ paths: application/json: schema: type: object - properties: *ref_130 - required: *ref_131 + properties: *ref_134 + required: *ref_135 /w/{workspace}/apps/list_paths_from_workspace_runnable/{runnable_kind}/{path}: get: summary: list app paths from workspace runnable @@ -16550,11 +17086,11 @@ paths: - name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 responses: '200': description: list of app paths @@ -16578,11 +17114,11 @@ paths: - name: id in: path required: true - schema: *ref_79 + schema: *ref_84 - name: version in: path required: true - schema: &ref_306 + schema: &ref_311 type: integer requestBody: description: App deployment message @@ -16623,8 +17159,8 @@ paths: application/json: schema: type: object - properties: *ref_128 - required: *ref_129 + properties: *ref_132 + required: *ref_133 /w/{workspace}/apps_u/public_resource/{path}: get: summary: get public resource @@ -16704,7 +17240,7 @@ paths: - name: id in: path required: true - schema: *ref_79 + schema: *ref_84 responses: '200': description: app details @@ -16712,8 +17248,8 @@ paths: application/json: schema: type: object - properties: *ref_128 - required: *ref_129 + properties: *ref_132 + required: *ref_133 /w/{workspace}/apps/delete/{path}: delete: summary: delete app @@ -16760,7 +17296,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: update app required: true @@ -16776,7 +17312,7 @@ paths: value: {} policy: type: object - properties: *ref_127 + properties: *ref_131 deployment_message: type: string custom_path: @@ -16817,7 +17353,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: update app required: true @@ -16836,7 +17372,7 @@ paths: value: {} policy: type: object - properties: *ref_127 + properties: *ref_131 deployment_message: type: string custom_path: @@ -16883,7 +17419,7 @@ paths: - name: custom_path in: path required: true - schema: *ref_133 + schema: *ref_137 responses: '200': description: custom path exists @@ -16914,7 +17450,7 @@ paths: type: array items: type: object - properties: &ref_134 + properties: &ref_138 s3: type: string filename: @@ -16923,7 +17459,7 @@ paths: type: string presigned: type: string - required: &ref_135 + required: &ref_139 - s3 required: - s3_objects @@ -16936,8 +17472,8 @@ paths: type: array items: type: object - properties: *ref_134 - required: *ref_135 + properties: *ref_138 + required: *ref_139 /w/{workspace}/apps_u/execute_component/{path}: post: summary: executeComponent @@ -16952,7 +17488,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: update app required: true @@ -17137,7 +17673,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 - name: scheduled_for description: when to schedule this job (leave empty for immediate run) in: query @@ -17152,17 +17688,17 @@ paths: - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -17170,7 +17706,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -17179,7 +17715,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the flow owner (default false) in: query @@ -17240,17 +17776,17 @@ paths: - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -17258,7 +17794,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -17267,7 +17803,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the flow owner (default false) in: query @@ -17335,14 +17871,14 @@ paths: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: &ref_138 + oneOf: &ref_142 - type: object description: >- Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource' - properties: *ref_136 - required: *ref_137 + properties: *ref_140 + required: *ref_141 - type: object description: >- JavaScript expression evaluated at runtime. Can @@ -17351,16 +17887,16 @@ paths: 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value - properties: *ref_84 - required: *ref_85 + properties: *ref_89 + required: *ref_90 - type: object description: >- Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter. - properties: *ref_86 - required: *ref_87 - discriminator: &ref_139 + properties: *ref_91 + required: *ref_92 + discriminator: &ref_143 propertyName: type mapping: static: '#/components/schemas/schemas-StaticTransform' @@ -17380,8 +17916,8 @@ paths: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_138 - discriminator: *ref_139 + oneOf: *ref_142 + discriminator: *ref_143 use_latest_version: type: boolean responses: @@ -17407,7 +17943,7 @@ paths: - name: id in: path required: true - schema: &ref_172 + schema: &ref_176 type: string format: uuid - name: scheduled_for @@ -17426,11 +17962,11 @@ paths: The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -17438,7 +17974,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -17447,7 +17983,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the flow owner (default false) in: query @@ -17521,7 +18057,7 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 - name: scheduled_for description: when to schedule this job (leave empty for immediate run) in: query @@ -17536,23 +18072,23 @@ paths: - name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 - name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 - name: job_id description: >- The job id to assign to the created job. if missing, job is chosen @@ -17560,7 +18096,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: include_header description: > List of headers's keys (separated with ',') whove value are added to @@ -17569,7 +18105,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the script owner (default false) in: query @@ -17609,7 +18145,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the script owner (default false) in: query @@ -17622,7 +18158,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 requestBody: description: preview required: true @@ -17630,7 +18166,7 @@ paths: application/json: schema: type: object - properties: &ref_141 + properties: &ref_145 content: type: string description: The code to run @@ -17646,7 +18182,7 @@ paths: additionalProperties: true language: type: string - enum: *ref_94 + enum: *ref_99 tag: type: string kind: @@ -17668,8 +18204,8 @@ paths: additionalProperties: type: object description: An additional module file associated with a script - properties: *ref_95 - required: *ref_96 + properties: *ref_101 + required: *ref_102 temp_script_refs: type: object nullable: true @@ -17679,7 +18215,7 @@ paths: local content instead of the deployed script additionalProperties: type: string - required: &ref_142 + required: &ref_146 - args responses: '201': @@ -17707,7 +18243,7 @@ paths: application/json: schema: type: object - properties: &ref_423 + properties: &ref_429 content: type: string description: The code to run @@ -17717,8 +18253,8 @@ paths: additionalProperties: true language: type: string - enum: *ref_94 - required: &ref_424 + enum: *ref_99 + required: &ref_430 - content - args - language @@ -17742,7 +18278,7 @@ paths: - name: path in: path required: true - schema: *ref_92 + schema: *ref_97 requestBody: description: script args required: true @@ -17750,7 +18286,7 @@ paths: application/json: schema: type: object - properties: &ref_140 + properties: &ref_144 args: type: object description: The arguments to pass to the script or flow @@ -17775,7 +18311,7 @@ paths: - name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 requestBody: description: script args required: true @@ -17783,7 +18319,7 @@ paths: application/json: schema: type: object - properties: *ref_140 + properties: *ref_144 responses: '200': description: script result @@ -17817,8 +18353,8 @@ paths: application/json: schema: type: object - properties: *ref_141 - required: *ref_142 + properties: *ref_145 + required: *ref_146 responses: '200': description: job result @@ -17853,12 +18389,12 @@ paths: application/json: schema: type: object - properties: &ref_425 + properties: &ref_431 args: type: object description: The arguments to pass to the script or flow additionalProperties: true - required: &ref_426 + required: &ref_432 - args responses: '201': @@ -17891,15 +18427,15 @@ paths: type: array items: type: object - properties: &ref_143 + properties: &ref_147 raw_code: type: string path: type: string language: type: string - enum: *ref_94 - required: &ref_144 + enum: *ref_99 + required: &ref_148 - raw_code - path - language @@ -17943,8 +18479,8 @@ paths: type: array items: type: object - properties: *ref_143 - required: *ref_144 + properties: *ref_147 + required: *ref_148 entrypoint: type: string required: @@ -17984,7 +18520,7 @@ paths: description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: &ref_149 + properties: &ref_153 modules: type: array description: >- @@ -17995,22 +18531,22 @@ paths: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 failure_module: description: >- A single step in a flow. Can be a script, subflow, loop, or branch type: object - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 preprocessor_module: description: >- A single step in a flow. Can be a script, subflow, loop, or branch type: object - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 same_worker: type: boolean description: >- @@ -18093,8 +18629,8 @@ paths: description: >- A sticky note attached to a flow for documentation and annotation - properties: *ref_145 - required: *ref_146 + properties: *ref_149 + required: *ref_150 groups: type: array description: Semantic groups of modules for organizational purposes @@ -18107,9 +18643,9 @@ paths: naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id. - properties: *ref_147 - required: *ref_148 - required: &ref_150 + properties: *ref_151 + required: *ref_152 + required: &ref_154 - modules required: - path @@ -18141,7 +18677,7 @@ paths: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 - name: invisible_to_owner description: make the run invisible to the the script owner (default false) in: query @@ -18154,7 +18690,7 @@ paths: queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 - name: memory_id description: memory ID for chat-enabled flows in: query @@ -18168,14 +18704,14 @@ paths: application/json: schema: type: object - properties: &ref_152 + properties: &ref_156 value: type: object description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: *ref_149 - required: *ref_150 + properties: *ref_153 + required: *ref_154 path: type: string args: @@ -18186,7 +18722,7 @@ paths: type: string restarted_from: type: object - properties: &ref_151 + properties: &ref_155 flow_job_id: type: string format: uuid @@ -18219,7 +18755,7 @@ paths: `RestartedFlow` against `nested.flow_job_id` instead of fresh-launching it. type: object - properties: *ref_151 + properties: *ref_155 temp_script_refs: type: object nullable: true @@ -18230,7 +18766,7 @@ paths: of the deployed script additionalProperties: type: string - required: &ref_153 + required: &ref_157 - value - content - args @@ -18266,8 +18802,8 @@ paths: application/json: schema: type: object - properties: *ref_152 - required: *ref_153 + properties: *ref_156 + required: *ref_157 responses: '200': description: job result @@ -18292,7 +18828,7 @@ paths: application/json: schema: type: object - properties: &ref_529 + properties: &ref_530 entrypoint_function: type: string description: Name of the function to execute for dynamic select @@ -18313,7 +18849,7 @@ paths: description: Path to the deployed script or flow runnable_kind: type: string - enum: &ref_200 + enum: &ref_204 - script - flow required: @@ -18331,11 +18867,11 @@ paths: description: Code content for inline execution language: type: string - enum: *ref_94 + enum: *ref_99 required: - source - code - required: &ref_530 + required: &ref_531 - entrypoint_function - runnable_ref responses: @@ -18361,27 +18897,27 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: worker description: >- filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: &ref_156 + schema: &ref_160 type: string - name: script_path_exact description: >- @@ -18389,7 +18925,7 @@ paths: (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: &ref_157 + schema: &ref_161 type: string - name: script_path_start description: >- @@ -18397,12 +18933,12 @@ paths: 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: &ref_158 + schema: &ref_162 type: string - name: schedule_path description: mask to filter by schedule path in: query - schema: &ref_159 + schema: &ref_163 type: string - name: trigger_path description: >- @@ -18410,7 +18946,7 @@ paths: 'f/trigger1,f/trigger2') and negation by prefixing all values with '!' (e.g. '!f/trigger1,!f/trigger2') in: query - schema: &ref_307 + schema: &ref_312 type: string - name: trigger_kind description: >- @@ -18419,34 +18955,34 @@ paths: (e.g. '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: &ref_188 + schema: &ref_192 type: string - name: script_hash description: mask to filter exact matching path in: query - schema: &ref_160 + schema: &ref_164 type: string - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: &ref_161 + schema: &ref_165 type: string format: date-time - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: &ref_162 + schema: &ref_166 type: string format: date-time - name: success description: filter on successful jobs in: query - schema: &ref_170 + schema: &ref_174 type: boolean - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: &ref_164 + schema: &ref_168 type: boolean - name: job_kinds description: >- @@ -18454,36 +18990,36 @@ paths: ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: &ref_165 + schema: &ref_169 type: string - name: suspended description: filter on suspended jobs in: query - schema: &ref_166 + schema: &ref_170 type: boolean - name: running description: filter on running jobs in: query - schema: &ref_163 + schema: &ref_167 type: boolean - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: &ref_167 + schema: &ref_171 type: string - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: &ref_169 + schema: &ref_173 type: string - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: &ref_171 + schema: &ref_175 type: boolean - name: tag description: >- @@ -18491,7 +19027,7 @@ paths: 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: &ref_168 + schema: &ref_172 type: string - name: page description: which page to return (start at 1, default 1) @@ -18522,7 +19058,7 @@ paths: type: array items: type: object - properties: &ref_191 + properties: &ref_195 workspace_id: type: string id: @@ -18597,14 +19133,14 @@ paths: by extension its DT_TOKEN. flow_status: type: object - properties: &ref_175 + properties: &ref_179 step: type: integer modules: type: array items: type: object - properties: &ref_154 + properties: &ref_158 type: type: string enum: @@ -18758,20 +19294,20 @@ paths: type: array items: type: boolean - required: &ref_155 + required: &ref_159 - type user_states: additionalProperties: true preprocessor_module: allOf: - type: object - properties: *ref_154 - required: *ref_155 + properties: *ref_158 + required: *ref_159 failure_module: allOf: - type: object - properties: *ref_154 - required: *ref_155 + properties: *ref_158 + required: *ref_159 - type: object properties: parent_module: @@ -18786,13 +19322,13 @@ paths: items: type: string format: uuid - required: &ref_176 + required: &ref_180 - step - modules - failure_module workflow_as_code_status: type: object - properties: &ref_177 + properties: &ref_181 scheduled_for: type: string format: date-time @@ -18808,13 +19344,13 @@ paths: description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: *ref_149 - required: *ref_150 + properties: *ref_153 + required: *ref_154 is_flow_step: type: boolean language: type: string - enum: *ref_94 + enum: *ref_99 email: type: string visible_to_owner: @@ -18835,7 +19371,7 @@ paths: type: boolean worker: type: string - required: &ref_192 + required: &ref_196 - id - running - canceled @@ -18951,14 +19487,14 @@ paths: (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: label description: >- filter by exact matching job label. Supports comma-separated list (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: &ref_174 + schema: &ref_178 type: string - name: worker description: >- @@ -18966,117 +19502,117 @@ paths: (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_156 + schema: *ref_160 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: script_path_exact description: >- filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 - name: created_before description: filter on created before (inclusive) timestamp in: query - schema: &ref_182 + schema: &ref_186 type: string format: date-time - name: created_after description: filter on created after (exclusive) timestamp in: query - schema: &ref_183 + schema: &ref_187 type: string format: date-time - name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: &ref_184 + schema: &ref_188 type: string format: date-time - name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: &ref_185 + schema: &ref_189 type: string format: date-time - name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: &ref_186 + schema: &ref_190 type: string format: date-time - name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: &ref_187 + schema: &ref_191 type: string format: date-time - name: running description: filter on running jobs in: query - schema: *ref_163 + schema: *ref_167 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_164 + schema: *ref_168 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 - name: suspended description: filter on suspended jobs in: query - schema: *ref_166 + schema: *ref_170 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 - name: page description: which page to return (start at 1, default 1) in: query @@ -19105,6 +19641,19 @@ paths: in: query schema: type: boolean + - name: status + description: >- + filter on the exact completed job status. Unlike `success=true` + (which also matches `skipped`), `status=success` matches only + `success`. + in: query + schema: + type: string + enum: + - success + - failure + - canceled + - skipped - name: all_workspaces description: >- get jobs from all workspaces (only valid if request come from the @@ -19140,96 +19689,96 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: script_path_exact description: >- filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 - name: success description: filter on successful jobs in: query - schema: *ref_170 + schema: *ref_174 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_164 + schema: *ref_168 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 - name: suspended description: filter on suspended jobs in: query - schema: *ref_166 + schema: *ref_170 - name: running description: filter on running jobs in: query - schema: *ref_163 + schema: *ref_167 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_171 + schema: *ref_175 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 - name: page description: which page to return (start at 1, default 1) in: query @@ -19315,7 +19864,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: list of OTEL Span objects (compatible with OpenTelemetry Span proto) @@ -19343,7 +19892,7 @@ paths: schema: description: job trigger kind (schedule, http, websocket...) type: string - enum: &ref_173 + enum: &ref_177 - webhook - default_email - email @@ -19409,7 +19958,7 @@ paths: schema: description: job trigger kind (schedule, http, websocket...) type: string - enum: *ref_173 + enum: *ref_177 - name: trigger_path description: The path of the trigger (can contain forward slashes) in: path @@ -19456,98 +20005,111 @@ paths: - name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 - name: created_by description: >- filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: label description: >- filter by exact matching job label. Supports comma-separated list (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_174 + schema: *ref_178 - name: worker description: >- filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_156 + schema: *ref_160 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: script_path_exact description: >- filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 - name: success description: filter on successful jobs in: query - schema: *ref_170 + schema: *ref_174 + - name: status + description: >- + filter on the exact completed job status. Unlike `success=true` + (which also matches `skipped`), `status=success` matches only + `success`. + in: query + schema: + type: string + enum: + - success + - failure + - canceled + - skipped - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_171 + schema: *ref_175 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 - name: page description: which page to return (start at 1, default 1) in: query @@ -19585,7 +20147,7 @@ paths: type: array items: type: object - properties: &ref_189 + properties: &ref_193 workspace_id: type: string id: @@ -19662,23 +20224,23 @@ paths: by extension its DT_TOKEN. flow_status: type: object - properties: *ref_175 - required: *ref_176 + properties: *ref_179 + required: *ref_180 workflow_as_code_status: type: object - properties: *ref_177 + properties: *ref_181 raw_flow: type: object description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: *ref_149 - required: *ref_150 + properties: *ref_153 + required: *ref_154 is_flow_step: type: boolean language: type: string - enum: *ref_94 + enum: *ref_99 is_skipped: type: boolean email: @@ -19703,7 +20265,7 @@ paths: type: boolean worker: type: string - required: &ref_190 + required: &ref_194 - id - created_by - duration_ms @@ -19747,7 +20309,7 @@ paths: items: type: object description: Completed job with full data for export/import operations - properties: &ref_178 + properties: &ref_182 id: type: string format: uuid @@ -19842,7 +20404,7 @@ paths: type: boolean language: type: string - enum: *ref_94 + enum: *ref_99 is_skipped: type: boolean email: @@ -19885,7 +20447,7 @@ paths: status: type: string description: Actual job status from database - required: &ref_179 + required: &ref_183 - id - created_by - created_at @@ -19913,8 +20475,8 @@ paths: items: type: object description: Completed job with full data for export/import operations - properties: *ref_178 - required: *ref_179 + properties: *ref_182 + required: *ref_183 responses: '200': description: Successfully imported completed jobs @@ -19951,7 +20513,7 @@ paths: items: type: object description: Queued job with full data for export/import operations - properties: &ref_180 + properties: &ref_184 id: type: string format: uuid @@ -20041,7 +20603,7 @@ paths: type: boolean language: type: string - enum: *ref_94 + enum: *ref_99 email: type: string visible_to_owner: @@ -20082,7 +20644,7 @@ paths: suspend_until: type: string format: date-time - required: &ref_181 + required: &ref_185 - id - created_by - created_at @@ -20110,8 +20672,8 @@ paths: items: type: object description: Queued job with full data for export/import operations - properties: *ref_180 - required: *ref_181 + properties: *ref_184 + required: *ref_185 responses: '200': description: Successfully imported queued jobs @@ -20166,123 +20728,123 @@ paths: (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: label description: >- filter by exact matching job label. Supports comma-separated list (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_174 + schema: *ref_178 - name: worker description: >- filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_156 + schema: *ref_160 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: script_path_exact description: >- filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 - name: created_before description: filter on created before (inclusive) timestamp in: query - schema: *ref_182 + schema: *ref_186 - name: created_after description: filter on created after (exclusive) timestamp in: query - schema: *ref_183 + schema: *ref_187 - name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_184 + schema: *ref_188 - name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_185 + schema: *ref_189 - name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: *ref_186 + schema: *ref_190 - name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: *ref_187 + schema: *ref_191 - name: running description: filter on running jobs in: query - schema: *ref_163 + schema: *ref_167 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_164 + schema: *ref_168 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 - name: suspended description: filter on suspended jobs in: query - schema: *ref_166 + schema: *ref_170 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_171 + schema: *ref_175 - name: per_page description: number of items to return for a given page (default 30, max 100) in: query @@ -20294,7 +20856,7 @@ paths: (e.g. '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: *ref_188 + schema: *ref_192 - name: is_skipped description: is the job skipped in: query @@ -20315,6 +20877,19 @@ paths: in: query schema: type: boolean + - name: status + description: >- + filter on the exact completed job status. Unlike `success=true` + (which also matches `skipped`), `status=success` matches only + `success`. + in: query + schema: + type: string + enum: + - success + - failure + - canceled + - skipped - name: all_workspaces description: >- get jobs from all workspaces (only valid if request come from the @@ -20349,11 +20924,11 @@ paths: schema: type: array items: - oneOf: &ref_193 + oneOf: &ref_197 - allOf: - type: object - properties: *ref_189 - required: *ref_190 + properties: *ref_193 + required: *ref_194 - type: object properties: type: @@ -20362,15 +20937,15 @@ paths: - CompletedJob - allOf: - type: object - properties: *ref_191 - required: *ref_192 + properties: *ref_195 + required: *ref_196 - type: object properties: type: type: string enum: - QueuedJob - discriminator: &ref_194 + discriminator: &ref_198 propertyName: type /jobs/db_clock: get: @@ -20438,7 +21013,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: no_logs in: query schema: @@ -20461,8 +21036,8 @@ paths: content: application/json: schema: - oneOf: *ref_193 - discriminator: *ref_194 + oneOf: *ref_197 + discriminator: *ref_198 /w/{workspace}/jobs_u/get_root_job_id/{id}: get: summary: get root job id @@ -20477,7 +21052,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: get root job id @@ -20500,7 +21075,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: remove_ansi_warnings in: query schema: @@ -20526,7 +21101,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: concatenated logs of all flow steps @@ -20548,7 +21123,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: completed job logs tail @@ -20570,7 +21145,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: job args @@ -20621,7 +21196,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: running in: query schema: @@ -20668,11 +21243,11 @@ paths: type: string flow_status: type: object - properties: *ref_175 - required: *ref_176 + properties: *ref_179 + required: *ref_180 workflow_as_code_status: type: object - properties: *ref_177 + properties: *ref_181 /w/{workspace}/jobs_u/getupdate_sse/{id}: get: summary: get job updates via server-sent events @@ -20687,7 +21262,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: running in: query schema: @@ -20760,7 +21335,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: flow debug info details @@ -20781,7 +21356,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: job details @@ -20789,8 +21364,8 @@ paths: application/json: schema: type: object - properties: *ref_189 - required: *ref_190 + properties: *ref_193 + required: *ref_194 /w/{workspace}/jobs_u/completed/get_result/{id}: get: summary: get completed job result @@ -20805,7 +21380,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: suspended_job in: query schema: @@ -20842,10 +21417,10 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: get_started in: query - schema: &ref_313 + schema: &ref_318 type: boolean responses: '200': @@ -20879,7 +21454,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: job timing details @@ -20912,7 +21487,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: job details @@ -20920,8 +21495,8 @@ paths: application/json: schema: type: object - properties: *ref_189 - required: *ref_190 + properties: *ref_193 + required: *ref_194 /w/{workspace}/jobs_u/queue/cancel/{id}: post: summary: cancel queued or running job @@ -20936,7 +21511,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 requestBody: description: reason required: true @@ -21000,7 +21575,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 requestBody: description: reason required: true @@ -21062,7 +21637,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: scheduled for timestamp @@ -21084,7 +21659,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21115,7 +21690,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21165,7 +21740,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: approver in: query schema: @@ -21226,7 +21801,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: approver in: query schema: @@ -21414,7 +21989,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -21422,7 +21997,7 @@ paths: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 - name: resume_id in: path required: true @@ -21457,7 +22032,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21499,7 +22074,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: key in: path required: true @@ -21531,7 +22106,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: key in: path required: true @@ -21557,7 +22132,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 requestBody: required: true content: @@ -21585,7 +22160,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21620,7 +22195,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21662,7 +22237,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 - name: resume_id in: path required: true @@ -21686,8 +22261,8 @@ paths: type: object properties: job: - oneOf: *ref_193 - discriminator: *ref_194 + oneOf: *ref_197 + discriminator: *ref_198 approvers: type: array items: @@ -21762,7 +22337,7 @@ paths: application/json: schema: type: object - properties: &ref_436 + properties: &ref_438 path: type: string description: >- @@ -21855,7 +22430,7 @@ paths: nullable: true type: object description: Retry configuration for failed module executions - properties: &ref_197 + properties: &ref_201 constant: type: object description: Retry with constant delay between attempts @@ -21890,8 +22465,8 @@ paths: retry_if: type: object description: Conditional retry based on error or result - properties: *ref_195 - required: *ref_196 + properties: *ref_199 + required: *ref_200 no_flow_overlap: type: boolean description: >- @@ -21944,7 +22519,7 @@ paths: type: array items: type: string - required: &ref_437 + required: &ref_439 - path - schedule - timezone @@ -21988,7 +22563,7 @@ paths: application/json: schema: type: object - properties: &ref_438 + properties: &ref_440 schedule: type: string description: >- @@ -22063,7 +22638,7 @@ paths: nullable: true type: object description: Retry configuration for failed module executions - properties: *ref_197 + properties: *ref_201 no_flow_overlap: type: boolean description: >- @@ -22119,7 +22694,7 @@ paths: type: array items: type: string - required: &ref_439 + required: &ref_441 - schedule - timezone - args @@ -22208,189 +22783,257 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: schedule deleted content: application/json: schema: - type: object - properties: &ref_198 - path: - type: string - description: >- - The unique Windmill path for this schedule. Must be of the - form `u//` or `f//`. - edited_by: - type: string - description: Username of the last person who edited this schedule - edited_at: - type: string - format: date-time - description: Timestamp of the last edit - schedule: - type: string - description: >- - Cron expression with 6 fields (seconds, minutes, hours, - day of month, month, day of week). Example '0 0 12 * * *' - for daily at noon - timezone: - type: string - description: >- - IANA timezone for the schedule (e.g., 'UTC', - 'Europe/Paris', 'America/New_York') - enabled: - type: boolean - description: >- - Whether the schedule is currently active and will trigger - jobs - script_path: - type: string - description: Path to the script or flow to execute when triggered - is_flow: - type: boolean - description: >- - True if script_path points to a flow, false if it points - to a script - args: - nullable: true - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - extra_perms: - type: object - additionalProperties: - type: boolean - description: Additional permissions for this schedule - email: - type: string - description: >- - Email of the user who owns this schedule, used for - permissioned_as - permissioned_as: - type: string - description: >- - The user or group this schedule runs as (e.g., 'u/admin' - or 'g/mygroup') - error: - type: string - nullable: true - description: Last error message if the schedule failed to trigger - on_failure: - type: string - nullable: true - description: >- - Path to a script or flow to run when the scheduled job - fails - on_failure_times: - type: number - nullable: true - description: >- - Number of consecutive failures before the on_failure - handler is triggered (default 1) - on_failure_exact: - type: boolean - nullable: true - description: >- - If true, trigger on_failure handler only on exactly N - failures, not on every failure after N - on_failure_extra_args: - nullable: true - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - on_recovery: - type: string - nullable: true - description: >- - Path to a script or flow to run when the schedule recovers - after failures - on_recovery_times: - type: number - nullable: true - description: >- - Number of consecutive successes before the on_recovery - handler is triggered (default 1) - on_recovery_extra_args: - nullable: true - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - on_success: - type: string - nullable: true - description: >- - Path to a script or flow to run after each successful - execution - on_success_extra_args: - nullable: true - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - ws_error_handler_muted: - type: boolean - description: >- - If true, the workspace-level error handler will not be - triggered for this schedule's failures - retry: - nullable: true - type: object - description: Retry configuration for failed module executions - properties: *ref_197 - summary: - type: string - nullable: true - description: Short summary describing the purpose of this schedule - description: - type: string - nullable: true - description: Detailed description of what this schedule does - no_flow_overlap: - type: boolean - description: >- - If true, skip this schedule's execution if the previous - run is still in progress (prevents concurrent runs) - tag: - type: string - nullable: true - description: Worker tag to route jobs to specific worker groups - paused_until: - type: string - format: date-time - nullable: true - description: >- - ISO 8601 datetime until which the schedule is paused. - Schedule resumes automatically after this time - cron_version: - type: string - nullable: true - description: >- - Cron parser version. Use 'v2' for extended syntax with - additional features - dynamic_skip: - type: string - nullable: true - description: >- - Path to a script that validates scheduled datetimes. - Receives scheduled_for datetime and returns boolean to - skip (true) or run (false) - labels: - type: array - items: - type: string - default: [] - required: &ref_199 - - path - - edited_by - - edited_at - - schedule - - script_path - - timezone - - extra_perms - - is_flow - - enabled - - email - - permissioned_as + allOf: + - type: object + properties: &ref_202 + path: + type: string + description: >- + The unique Windmill path for this schedule. Must be of + the form `u//` or `f//`. + edited_by: + type: string + description: Username of the last person who edited this schedule + edited_at: + type: string + format: date-time + description: Timestamp of the last edit + schedule: + type: string + description: >- + Cron expression with 6 fields (seconds, minutes, + hours, day of month, month, day of week). Example '0 0 + 12 * * *' for daily at noon + timezone: + type: string + description: >- + IANA timezone for the schedule (e.g., 'UTC', + 'Europe/Paris', 'America/New_York') + enabled: + type: boolean + description: >- + Whether the schedule is currently active and will + trigger jobs + script_path: + type: string + description: Path to the script or flow to execute when triggered + is_flow: + type: boolean + description: >- + True if script_path points to a flow, false if it + points to a script + args: + nullable: true + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + extra_perms: + type: object + additionalProperties: + type: boolean + description: Additional permissions for this schedule + email: + type: string + description: >- + Email of the user who owns this schedule, used for + permissioned_as + permissioned_as: + type: string + description: >- + The user or group this schedule runs as (e.g., + 'u/admin' or 'g/mygroup') + error: + type: string + nullable: true + description: Last error message if the schedule failed to trigger + on_failure: + type: string + nullable: true + description: >- + Path to a script or flow to run when the scheduled job + fails + on_failure_times: + type: number + nullable: true + description: >- + Number of consecutive failures before the on_failure + handler is triggered (default 1) + on_failure_exact: + type: boolean + nullable: true + description: >- + If true, trigger on_failure handler only on exactly N + failures, not on every failure after N + on_failure_extra_args: + nullable: true + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + on_recovery: + type: string + nullable: true + description: >- + Path to a script or flow to run when the schedule + recovers after failures + on_recovery_times: + type: number + nullable: true + description: >- + Number of consecutive successes before the on_recovery + handler is triggered (default 1) + on_recovery_extra_args: + nullable: true + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + on_success: + type: string + nullable: true + description: >- + Path to a script or flow to run after each successful + execution + on_success_extra_args: + nullable: true + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + ws_error_handler_muted: + type: boolean + description: >- + If true, the workspace-level error handler will not be + triggered for this schedule's failures + retry: + nullable: true + type: object + description: Retry configuration for failed module executions + properties: *ref_201 + summary: + type: string + nullable: true + description: Short summary describing the purpose of this schedule + description: + type: string + nullable: true + description: Detailed description of what this schedule does + no_flow_overlap: + type: boolean + description: >- + If true, skip this schedule's execution if the + previous run is still in progress (prevents concurrent + runs) + tag: + type: string + nullable: true + description: Worker tag to route jobs to specific worker groups + paused_until: + type: string + format: date-time + nullable: true + description: >- + ISO 8601 datetime until which the schedule is paused. + Schedule resumes automatically after this time + cron_version: + type: string + nullable: true + description: >- + Cron parser version. Use 'v2' for extended syntax with + additional features + dynamic_skip: + type: string + nullable: true + description: >- + Path to a script that validates scheduled datetimes. + Receives scheduled_for datetime and returns boolean to + skip (true) or run (false) + labels: + type: array + items: + type: string + default: [] + draft_only: + description: > + True when this row is a per-user draft with no + deployed + + schedule at the same path. Frontend renders a "Draft" + badge. + type: boolean + is_draft: + description: > + True when the authed user has a per-user draft at this + path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a `*` to the displayed name. + type: boolean + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at + read time. Read-only — edit them on the folder. + required: &ref_203 + - path + - edited_by + - edited_at + - schedule + - script_path + - timezone + - extra_perms + - is_flow + - enabled + - email + - permissioned_as + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/schedules/exists/{path}: get: summary: does schedule exists @@ -22438,7 +23081,7 @@ paths: filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: path description: filter by path (script path) in: query @@ -22482,6 +23125,15 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + description: | + When true, append per-user draft schedules whose path has + no deployed schedule. Synthesized rows carry + `draft_only: true`. + in: query + required: false + schema: + type: boolean responses: '200': description: schedule list @@ -22491,8 +23143,8 @@ paths: type: array items: type: object - properties: *ref_198 - required: *ref_199 + properties: *ref_202 + required: *ref_203 /w/{workspace}/schedules/list_with_jobs: get: summary: list schedules with last 20 jobs @@ -22520,10 +23172,10 @@ paths: schema: type: array items: - allOf: &ref_435 + allOf: &ref_437 - type: object - properties: *ref_198 - required: *ref_199 + properties: *ref_202 + required: *ref_203 - type: object properties: jobs: @@ -22601,10 +23253,10 @@ paths: application/json: schema: type: object - properties: &ref_201 + properties: &ref_205 info: type: object - properties: &ref_445 + properties: &ref_447 title: type: string version: @@ -22633,28 +23285,28 @@ paths: type: string required: - name - required: &ref_446 + required: &ref_448 - title - version url: type: string openapi_spec_format: type: string - enum: &ref_440 + enum: &ref_442 - yaml - json http_route_filters: type: array items: type: object - properties: &ref_441 + properties: &ref_443 folder_regex: type: string path_regex: type: string route_path_regex: type: string - required: &ref_442 + required: &ref_444 - folder_regex - path_regex - route_path_regex @@ -22662,7 +23314,7 @@ paths: type: array items: type: object - properties: &ref_443 + properties: &ref_445 user_or_folder_regex: type: string enum: @@ -22675,8 +23327,8 @@ paths: type: string runnable_kind: type: string - enum: *ref_200 - required: &ref_444 + enum: *ref_204 + required: &ref_446 - user_or_folder_regex - user_or_folder_regex_value - path @@ -22705,7 +23357,7 @@ paths: application/json: schema: type: object - properties: *ref_201 + properties: *ref_205 responses: '200': description: Downloaded OpenAPI spec @@ -22734,7 +23386,7 @@ paths: type: array items: type: object - properties: &ref_202 + properties: &ref_206 path: type: string description: >- @@ -22788,7 +23440,7 @@ paths: HTTP method (get, post, put, delete, patch) that triggers this endpoint type: string - enum: &ref_204 + enum: &ref_208 - get - post - put @@ -22810,7 +23462,7 @@ paths: 'async' returns job ID immediately, 'sync_sse' streams results via Server-Sent Events type: string - enum: &ref_205 + enum: &ref_209 - sync - async - sync_sse @@ -22820,7 +23472,7 @@ paths: 'windmill' (Windmill token), 'api_key', 'basic_http', 'custom_script', 'signature' type: string - enum: &ref_206 + enum: &ref_210 - none - windmill - api_key @@ -22838,7 +23490,7 @@ paths: mode: description: job trigger mode type: string - enum: &ref_207 + enum: &ref_211 - enabled - disabled - suspended @@ -22859,7 +23511,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -22875,7 +23527,7 @@ paths: type: array items: type: string - required: &ref_203 + required: &ref_207 - path - script_path - route_path @@ -22908,8 +23560,8 @@ paths: application/json: schema: type: object - properties: *ref_202 - required: *ref_203 + properties: *ref_206 + required: *ref_207 responses: '201': description: http trigger created @@ -22939,7 +23591,7 @@ paths: application/json: schema: type: object - properties: &ref_447 + properties: &ref_449 path: type: string description: >- @@ -22999,7 +23651,7 @@ paths: HTTP method (get, post, put, delete, patch) that triggers this endpoint type: string - enum: *ref_204 + enum: *ref_208 is_async: type: boolean description: Deprecated, use request_type instead @@ -23009,14 +23661,14 @@ paths: 'async' returns job ID immediately, 'sync_sse' streams results via Server-Sent Events type: string - enum: *ref_205 + enum: *ref_209 authentication_method: description: >- How requests are authenticated - 'none' (public), 'windmill' (Windmill token), 'api_key', 'basic_http', 'custom_script', 'signature' type: string - enum: *ref_206 + enum: *ref_210 is_static_website: type: boolean description: >- @@ -23040,7 +23692,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -23056,7 +23708,7 @@ paths: type: array items: type: string - required: &ref_448 + required: &ref_450 - path - script_path - is_flow @@ -23108,167 +23760,239 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: http trigger deleted content: application/json: schema: - allOf: &ref_208 - - type: object + allOf: + - allOf: &ref_213 + - type: object + properties: &ref_219 + path: + type: string + description: >- + The unique Windmill path for this trigger. Must be + of the form `u//` or + `f//`. This is the trigger object + path, not the HTTP route path. + script_path: + type: string + description: >- + Path to the script or flow to execute when + triggered + permissioned_as: + type: string + description: >- + The user or group this trigger runs as + (permissioned_as) + extra_perms: + type: object + description: Additional permissions for this trigger + additionalProperties: + type: boolean + workspace_id: + type: string + description: The workspace this trigger belongs to + edited_by: + type: string + description: >- + Username of the last person who edited this + trigger + edited_at: + type: string + format: date-time + description: Timestamp of the last edit + is_flow: + type: boolean + description: >- + True if script_path points to a flow, false if it + points to a script + mode: + description: job trigger mode + type: string + enum: *ref_211 + labels: + type: array + items: + type: string + default: [] + draft_only: + description: > + True when this row is a per-user draft with no + deployed + + trigger at the same path. Set by list endpoints + when + + `include_draft_only=true` synthesizes the row from + the + + draft. Frontend renders a "Draft" badge. + type: boolean + is_draft: + description: > + True when the authed user has a per-user draft at + this path + + (over a deployed row or a synthesized draft-only + row). + + Frontend appends a `*` to the displayed name. + type: boolean + required: &ref_220 + - path + - script_path + - permissioned_as + - extra_perms + - workspace_id + - edited_by + - edited_at + - is_flow + - mode + type: object properties: &ref_214 - path: + route_path: type: string description: >- - The unique Windmill path for this trigger. Must be of - the form `u//` or `f//`. - This is the trigger object path, not the HTTP route - path. - script_path: - type: string - description: Path to the script or flow to execute when triggered - permissioned_as: - type: string - description: >- - The user or group this trigger runs as - (permissioned_as) - extra_perms: + The URL route path that will trigger this endpoint + (e.g., 'api/myendpoint'). Must NOT start with a /. + static_asset_config: type: object - description: Additional permissions for this trigger - additionalProperties: - type: boolean - workspace_id: + nullable: true + description: >- + Configuration for serving static assets (s3 bucket, + storage path, filename) + properties: + s3: + type: string + description: S3 bucket path for static assets + storage: + type: string + description: Storage path for static assets + filename: + type: string + description: Filename for the static asset + required: + - s3 + http_method: + description: >- + HTTP method (get, post, put, delete, patch) that + triggers this endpoint type: string - description: The workspace this trigger belongs to - edited_by: + enum: *ref_208 + authentication_resource_path: type: string - description: Username of the last person who edited this trigger - edited_at: + nullable: true + description: >- + Path to the resource containing authentication + configuration (for api_key, basic_http, custom_script, + signature methods) + summary: type: string - format: date-time - description: Timestamp of the last edit - is_flow: + nullable: true + description: Short summary describing the purpose of this trigger + description: + type: string + nullable: true + description: Detailed description of what this trigger does + request_type: + description: >- + How the request is handled - 'sync' waits for result, + 'async' returns job ID immediately, 'sync_sse' streams + results via Server-Sent Events + type: string + enum: *ref_209 + authentication_method: + description: >- + How requests are authenticated - 'none' (public), + 'windmill' (Windmill token), 'api_key', 'basic_http', + 'custom_script', 'signature' + type: string + enum: *ref_210 + is_static_website: type: boolean description: >- - True if script_path points to a flow, false if it - points to a script - mode: - description: job trigger mode + If true, serves static files from S3/storage instead + of running a script + workspaced_route: + type: boolean + description: >- + If true, the route includes the workspace ID in the + path + wrap_body: + type: boolean + description: If true, wraps the request body in a 'body' parameter + raw_string: + type: boolean + description: >- + If true, passes the request body as a raw string + instead of parsing as JSON + error_handler_path: type: string - enum: *ref_207 - labels: - type: array - items: - type: string - default: [] + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 required: &ref_215 - - path - - script_path - - permissioned_as - - extra_perms - - workspace_id - - edited_by - - edited_at - - is_flow - - mode - type: object - properties: &ref_209 - route_path: - type: string - description: >- - The URL route path that will trigger this endpoint (e.g., - 'api/myendpoint'). Must NOT start with a /. - static_asset_config: - type: object - nullable: true - description: >- - Configuration for serving static assets (s3 bucket, - storage path, filename) - properties: - s3: - type: string - description: S3 bucket path for static assets - storage: - type: string - description: Storage path for static assets - filename: - type: string - description: Filename for the static asset - required: - - s3 - http_method: - description: >- - HTTP method (get, post, put, delete, patch) that triggers - this endpoint - type: string - enum: *ref_204 - authentication_resource_path: - type: string - nullable: true - description: >- - Path to the resource containing authentication - configuration (for api_key, basic_http, custom_script, - signature methods) - summary: - type: string - nullable: true - description: Short summary describing the purpose of this trigger - description: - type: string - nullable: true - description: Detailed description of what this trigger does - request_type: - description: >- - How the request is handled - 'sync' waits for result, - 'async' returns job ID immediately, 'sync_sse' streams - results via Server-Sent Events - type: string - enum: *ref_205 - authentication_method: - description: >- - How requests are authenticated - 'none' (public), - 'windmill' (Windmill token), 'api_key', 'basic_http', - 'custom_script', 'signature' - type: string - enum: *ref_206 - is_static_website: - type: boolean - description: >- - If true, serves static files from S3/storage instead of - running a script - workspaced_route: - type: boolean - description: If true, the route includes the workspace ID in the path - wrap_body: - type: boolean - description: If true, wraps the request body in a 'body' parameter - raw_string: - type: boolean - description: >- - If true, passes the request body as a raw string instead - of parsing as JSON - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_210 - - route_path - - request_type - - authentication_method - - http_method - - is_static_website - - workspaced_route - - wrap_body - - raw_string + - route_path + - request_type + - authentication_method + - http_method + - is_static_website + - workspaced_route + - wrap_body + - raw_string + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/http_triggers/list: get: summary: list http triggers @@ -23307,6 +24031,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: http trigger list @@ -23315,10 +24049,10 @@ paths: schema: type: array items: - allOf: *ref_208 + allOf: *ref_213 type: object - properties: *ref_209 - required: *ref_210 + properties: *ref_214 + required: *ref_215 /w/{workspace}/http_triggers/exists/{path}: get: summary: does http trigger exists @@ -23364,7 +24098,7 @@ paths: type: string http_method: type: string - enum: *ref_204 + enum: *ref_208 trigger_path: type: string workspaced_route: @@ -23404,7 +24138,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -23437,7 +24171,7 @@ paths: application/json: schema: type: object - properties: &ref_449 + properties: &ref_451 path: type: string description: >- @@ -23462,7 +24196,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 filters: type: array description: >- @@ -23493,7 +24227,7 @@ paths: Messages to send immediately after connecting (can be raw strings or computed by runnables) items: - anyOf: &ref_211 + anyOf: &ref_216 - type: object properties: raw_message: @@ -23536,7 +24270,7 @@ paths: nullable: true description: Optional periodic heartbeat message configuration type: object - properties: &ref_212 + properties: &ref_217 interval_secs: type: integer minimum: 1 @@ -23553,7 +24287,7 @@ paths: Optional. Top-level JSON field to extract from incoming messages. The extracted value replaces {{state}} in the heartbeat message. - required: &ref_213 + required: &ref_218 - interval_secs - message error_handler_path: @@ -23566,7 +24300,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -23582,7 +24316,7 @@ paths: type: array items: type: string - required: &ref_450 + required: &ref_452 - path - script_path - url @@ -23619,7 +24353,7 @@ paths: application/json: schema: type: object - properties: &ref_451 + properties: &ref_453 url: type: string description: >- @@ -23671,7 +24405,7 @@ paths: Messages to send immediately after connecting (can be raw strings or computed by runnables) items: - anyOf: *ref_211 + anyOf: *ref_216 url_runnable_args: description: The arguments to pass to the script or flow nullable: true @@ -23689,8 +24423,8 @@ paths: nullable: true description: Optional periodic heartbeat message configuration type: object - properties: *ref_212 - required: *ref_213 + properties: *ref_217 + required: *ref_218 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -23701,7 +24435,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -23717,7 +24451,7 @@ paths: type: array items: type: string - required: &ref_452 + required: &ref_454 - path - script_path - url @@ -23769,103 +24503,149 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: websocket trigger deleted content: application/json: schema: - allOf: &ref_216 + allOf: + - allOf: &ref_221 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_222 + url: + type: string + description: >- + The WebSocket URL to connect to (can be a static URL + or computed by a runnable) + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error: + type: string + description: Last error message if the trigger failed + filters: + type: array + description: >- + Array of key-value filters to match incoming messages + (only matching messages trigger the script) + items: + type: object + properties: + key: + type: string + value: {} + required: + - key + - value + filter_logic: + type: string + enum: + - and + - or + default: and + description: >- + Logic to apply when evaluating filters. 'and' requires + all filters to match, 'or' requires any filter to + match. + initial_messages: + type: array + nullable: true + description: >- + Messages to send immediately after connecting (can be + raw strings or computed by runnables) + items: + anyOf: *ref_216 + url_runnable_args: + description: The arguments to pass to the script or flow + nullable: true + type: object + additionalProperties: true + can_return_message: + type: boolean + description: >- + If true, the script can return a message to send back + through the WebSocket + can_return_error_result: + type: boolean + description: >- + If true, error results are sent back through the + WebSocket + heartbeat: + nullable: true + description: Optional periodic heartbeat message configuration + type: object + properties: *ref_217 + required: *ref_218 + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_223 + - url + - filters + - can_return_message + - can_return_error_result - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_217 - url: - type: string - description: >- - The WebSocket URL to connect to (can be a static URL or - computed by a runnable) - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error: - type: string - description: Last error message if the trigger failed - filters: - type: array - description: >- - Array of key-value filters to match incoming messages - (only matching messages trigger the script) - items: - type: object - properties: - key: - type: string - value: {} - required: - - key - - value - filter_logic: - type: string - enum: - - and - - or - default: and - description: >- - Logic to apply when evaluating filters. 'and' requires all - filters to match, 'or' requires any filter to match. - initial_messages: - type: array - nullable: true - description: >- - Messages to send immediately after connecting (can be raw - strings or computed by runnables) - items: - anyOf: *ref_211 - url_runnable_args: - description: The arguments to pass to the script or flow - nullable: true - type: object - additionalProperties: true - can_return_message: - type: boolean - description: >- - If true, the script can return a message to send back - through the WebSocket - can_return_error_result: - type: boolean - description: If true, error results are sent back through the WebSocket - heartbeat: - nullable: true - description: Optional periodic heartbeat message configuration - type: object - properties: *ref_212 - required: *ref_213 - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_218 - - url - - filters - - can_return_message - - can_return_error_result + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/websocket_triggers/list: get: summary: list websocket triggers @@ -23904,6 +24684,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: websocket trigger list @@ -23912,10 +24702,10 @@ paths: schema: type: array items: - allOf: *ref_216 + allOf: *ref_221 type: object - properties: *ref_217 - required: *ref_218 + properties: *ref_222 + required: *ref_223 /w/{workspace}/websocket_triggers/exists/{path}: get: summary: does websocket trigger exists @@ -23964,7 +24754,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -24034,7 +24824,7 @@ paths: application/json: schema: type: object - properties: &ref_484 + properties: &ref_486 path: type: string description: >- @@ -24103,7 +24893,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -24114,7 +24904,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -24130,7 +24920,7 @@ paths: type: array items: type: string - required: &ref_485 + required: &ref_487 - path - script_path - is_flow @@ -24167,7 +24957,7 @@ paths: application/json: schema: type: object - properties: &ref_486 + properties: &ref_488 kafka_resource_path: type: string description: >- @@ -24243,7 +25033,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -24259,7 +25049,7 @@ paths: type: array items: type: string - required: &ref_487 + required: &ref_489 - path - script_path - kafka_resource_path @@ -24311,98 +25101,143 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: kafka trigger deleted content: application/json: schema: - allOf: &ref_219 - - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_220 - kafka_resource_path: - type: string - description: >- - Path to the Kafka resource containing connection - configuration - group_id: - type: string - description: Kafka consumer group ID for this trigger - topics: - type: array - items: - type: string - description: Array of Kafka topic names to subscribe to - filters: - type: array - items: - type: object - properties: - key: + allOf: + - allOf: &ref_224 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_225 + kafka_resource_path: + type: string + description: >- + Path to the Kafka resource containing connection + configuration + group_id: + type: string + description: Kafka consumer group ID for this trigger + topics: + type: array + items: type: string - value: {} - required: - - key - - value - filter_logic: - type: string - enum: - - and - - or - default: and - description: >- - Logic to apply when evaluating filters. 'and' requires all - filters to match, 'or' requires any filter to match. - auto_offset_reset: - type: string - enum: - - latest - - earliest - default: latest - description: >- - Initial offset behavior when consumer group has no - committed offset. 'latest' starts from new messages only, - 'earliest' starts from the beginning. - auto_commit: - type: boolean - default: true - description: >- - When true (default), offsets are committed automatically - after receiving each message. When false, you must - manually commit offsets using the commit_offsets endpoint. - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error: - type: string - description: Last error message if the trigger failed - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_221 - - kafka_resource_path - - group_id - - topics - - filters + description: Array of Kafka topic names to subscribe to + filters: + type: array + items: + type: object + properties: + key: + type: string + value: {} + required: + - key + - value + filter_logic: + type: string + enum: + - and + - or + default: and + description: >- + Logic to apply when evaluating filters. 'and' requires + all filters to match, 'or' requires any filter to + match. + auto_offset_reset: + type: string + enum: + - latest + - earliest + default: latest + description: >- + Initial offset behavior when consumer group has no + committed offset. 'latest' starts from new messages + only, 'earliest' starts from the beginning. + auto_commit: + type: boolean + default: true + description: >- + When true (default), offsets are committed + automatically after receiving each message. When + false, you must manually commit offsets using the + commit_offsets endpoint. + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error: + type: string + description: Last error message if the trigger failed + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_226 + - kafka_resource_path + - group_id + - topics + - filters + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/kafka_triggers/list: get: summary: list kafka triggers @@ -24441,6 +25276,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: kafka trigger list @@ -24449,10 +25294,10 @@ paths: schema: type: array items: - allOf: *ref_219 + allOf: *ref_224 type: object - properties: *ref_220 - required: *ref_221 + properties: *ref_225 + required: *ref_226 /w/{workspace}/kafka_triggers/exists/{path}: get: summary: does kafka trigger exists @@ -24501,7 +25346,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -24620,7 +25465,7 @@ paths: application/json: schema: type: object - properties: &ref_488 + properties: &ref_490 path: type: string description: >- @@ -24663,7 +25508,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -24674,7 +25519,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -24690,7 +25535,7 @@ paths: type: array items: type: string - required: &ref_489 + required: &ref_491 - path - script_path - is_flow @@ -24726,7 +25571,7 @@ paths: application/json: schema: type: object - properties: &ref_490 + properties: &ref_492 nats_resource_path: type: string description: >- @@ -24776,7 +25621,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -24792,7 +25637,7 @@ paths: type: array items: type: string - required: &ref_491 + required: &ref_493 - path - script_path - nats_resource_path @@ -24843,72 +25688,117 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: nats trigger deleted content: application/json: schema: - allOf: &ref_222 + allOf: + - allOf: &ref_227 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_228 + nats_resource_path: + type: string + description: >- + Path to the NATS resource containing connection + configuration + use_jetstream: + type: boolean + description: >- + If true, uses NATS JetStream for durable message + delivery + stream_name: + type: string + nullable: true + description: >- + JetStream stream name (required when use_jetstream is + true) + consumer_name: + type: string + nullable: true + description: >- + JetStream consumer name (required when use_jetstream + is true) + subjects: + type: array + items: + type: string + description: Array of NATS subjects to subscribe to + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error: + type: string + description: Last error message if the trigger failed + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_229 + - nats_resource_path + - use_jetstream + - subjects - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_223 - nats_resource_path: - type: string - description: >- - Path to the NATS resource containing connection - configuration - use_jetstream: - type: boolean - description: If true, uses NATS JetStream for durable message delivery - stream_name: - type: string - nullable: true - description: >- - JetStream stream name (required when use_jetstream is - true) - consumer_name: - type: string - nullable: true - description: >- - JetStream consumer name (required when use_jetstream is - true) - subjects: - type: array - items: - type: string - description: Array of NATS subjects to subscribe to - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error: - type: string - description: Last error message if the trigger failed - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_224 - - nats_resource_path - - use_jetstream - - subjects + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/nats_triggers/list: get: summary: list nats triggers @@ -24947,6 +25837,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: nats trigger list @@ -24955,10 +25855,10 @@ paths: schema: type: array items: - allOf: *ref_222 + allOf: *ref_227 type: object - properties: *ref_223 - required: *ref_224 + properties: *ref_228 + required: *ref_229 /w/{workspace}/nats_triggers/exists/{path}: get: summary: does nats trigger exists @@ -25007,7 +25907,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -25070,7 +25970,7 @@ paths: application/json: schema: type: object - properties: &ref_471 + properties: &ref_473 queue_url: type: string description: The full URL of the AWS SQS queue to poll for messages @@ -25079,7 +25979,7 @@ paths: Authentication type - 'credentials' for access key/secret, 'oidc' for OpenID Connect type: string - enum: &ref_225 + enum: &ref_230 - oidc - credentials aws_resource_path: @@ -25114,7 +26014,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -25125,7 +26025,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -25141,7 +26041,7 @@ paths: type: array items: type: string - required: &ref_472 + required: &ref_474 - queue_url - aws_resource_path - path @@ -25177,7 +26077,7 @@ paths: application/json: schema: type: object - properties: &ref_473 + properties: &ref_475 queue_url: type: string description: The full URL of the AWS SQS queue to poll for messages @@ -25186,7 +26086,7 @@ paths: Authentication type - 'credentials' for access key/secret, 'oidc' for OpenID Connect type: string - enum: *ref_225 + enum: *ref_230 aws_resource_path: type: string description: >- @@ -25219,7 +26119,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -25230,7 +26130,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -25246,7 +26146,7 @@ paths: type: array items: type: string - required: &ref_474 + required: &ref_476 - queue_url - aws_resource_path - path @@ -25298,69 +26198,112 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: sqs trigger deleted content: application/json: schema: - allOf: &ref_226 + allOf: + - allOf: &ref_231 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_232 + queue_url: + type: string + description: The full URL of the AWS SQS queue to poll for messages + aws_auth_resource_type: + description: >- + Authentication type - 'credentials' for access + key/secret, 'oidc' for OpenID Connect + type: string + enum: *ref_230 + aws_resource_path: + type: string + description: >- + Path to the AWS resource containing credentials or + OIDC configuration + message_attributes: + type: array + nullable: true + items: + type: string + description: >- + Array of SQS message attribute names to include with + each message + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error: + type: string + description: Last error message if the trigger failed + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_233 + - queue_url + - aws_resource_path + - aws_auth_resource_type - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_227 - queue_url: - type: string - description: The full URL of the AWS SQS queue to poll for messages - aws_auth_resource_type: - description: >- - Authentication type - 'credentials' for access key/secret, - 'oidc' for OpenID Connect - type: string - enum: *ref_225 - aws_resource_path: - type: string - description: >- - Path to the AWS resource containing credentials or OIDC - configuration - message_attributes: - type: array - nullable: true - items: - type: string - description: >- - Array of SQS message attribute names to include with each - message - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error: - type: string - description: Last error message if the trigger failed - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_228 - - queue_url - - aws_resource_path - - aws_auth_resource_type + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/sqs_triggers/list: get: summary: list sqs triggers @@ -25399,6 +26342,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: sqs trigger list @@ -25407,10 +26360,10 @@ paths: schema: type: array items: - allOf: *ref_226 + allOf: *ref_231 type: object - properties: *ref_227 - required: *ref_228 + properties: *ref_232 + required: *ref_233 /w/{workspace}/sqs_triggers/exists/{path}: get: summary: does sqs trigger exists @@ -25459,7 +26412,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -25524,17 +26477,17 @@ paths: type: array items: type: object - properties: &ref_576 + properties: &ref_577 service_name: type: string - enum: &ref_229 + enum: &ref_234 - nextcloud - google - github oauth_data: nullable: true type: object - properties: &ref_230 + properties: &ref_235 client_id: type: string description: The OAuth client ID for the workspace @@ -25549,7 +26502,7 @@ paths: type: string format: uri description: The OAuth redirect URI - required: &ref_231 + required: &ref_236 - client_id - client_secret - base_url @@ -25558,7 +26511,7 @@ paths: type: string nullable: true description: Path to the resource storing the OAuth token - required: &ref_577 + required: &ref_578 - service_name /w/{workspace}/native_triggers/integrations/{service_name}/exists: get: @@ -25576,7 +26529,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 responses: '200': description: integration exists @@ -25600,7 +26553,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 requestBody: description: new native trigger service required: true @@ -25608,8 +26561,8 @@ paths: application/json: schema: type: object - properties: *ref_230 - required: *ref_231 + properties: *ref_235 + required: *ref_236 responses: '201': description: native trigger service created @@ -25633,7 +26586,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 requestBody: description: redirect_uri required: true @@ -25641,10 +26594,10 @@ paths: application/json: schema: type: object - properties: &ref_232 + properties: &ref_237 redirect_uri: type: string - required: &ref_233 + required: &ref_238 - redirect_uri responses: '200': @@ -25669,7 +26622,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 responses: '200': description: whether instance sharing is available @@ -25693,7 +26646,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 requestBody: description: redirect_uri required: true @@ -25701,8 +26654,8 @@ paths: application/json: schema: type: object - properties: *ref_232 - required: *ref_233 + properties: *ref_237 + required: *ref_238 responses: '200': description: authorization URL using instance credentials @@ -25726,7 +26679,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 responses: '200': description: native trigger service deleted @@ -25750,7 +26703,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 requestBody: description: OAuth callback data required: true @@ -25799,7 +26752,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 requestBody: description: new native trigger configuration required: true @@ -25808,7 +26761,7 @@ paths: schema: type: object description: Data for creating or updating a native trigger - properties: &ref_234 + properties: &ref_239 script_path: type: string description: The path to the script or flow that will be triggered @@ -25825,7 +26778,7 @@ paths: type: string nullable: true description: Short summary to be displayed when listed - required: &ref_235 + required: &ref_240 - script_path - is_flow - service_config @@ -25837,13 +26790,13 @@ paths: schema: type: object description: Response returned when a native trigger is created - properties: &ref_579 + properties: &ref_580 external_id: type: string description: >- The external ID of the created trigger from the external service - required: &ref_580 + required: &ref_581 - external_id /w/{workspace}/native_triggers/{service_name}/update/{external_id}: post: @@ -25866,7 +26819,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: external_id in: path required: true @@ -25881,8 +26834,8 @@ paths: schema: type: object description: Data for creating or updating a native trigger - properties: *ref_234 - required: *ref_235 + properties: *ref_239 + required: *ref_240 responses: '200': description: native trigger updated @@ -25911,7 +26864,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: external_id in: path required: true @@ -25928,7 +26881,7 @@ paths: description: >- Full trigger response containing both Windmill data and external service data - properties: &ref_574 + properties: &ref_575 external_id: type: string description: The unique identifier from the external service @@ -25937,7 +26890,7 @@ paths: description: The workspace this trigger belongs to service_name: type: string - enum: *ref_229 + enum: *ref_234 script_path: type: string description: The path to the script or flow that will be triggered @@ -25964,7 +26917,7 @@ paths: type: object description: Configuration data from the external service additionalProperties: true - required: &ref_575 + required: &ref_576 - external_id - workspace_id - service_name @@ -25993,7 +26946,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: external_id in: path required: true @@ -26024,7 +26977,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: page description: which page to return (start at 1, default 1) in: query @@ -26049,6 +27002,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: native triggers list @@ -26059,7 +27022,7 @@ paths: items: type: object description: A native trigger stored in Windmill - properties: &ref_572 + properties: &ref_573 external_id: type: string description: The unique identifier from the external service @@ -26068,7 +27031,7 @@ paths: description: The workspace this trigger belongs to service_name: type: string - enum: *ref_229 + enum: *ref_234 script_path: type: string description: The path to the script or flow that will be triggered @@ -26091,7 +27054,7 @@ paths: type: string nullable: true description: Short summary to be displayed when listed - required: &ref_573 + required: &ref_574 - external_id - workspace_id - service_name @@ -26115,7 +27078,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: external_id in: path required: true @@ -26145,7 +27108,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 responses: '200': description: sync completed successfully @@ -26170,7 +27133,7 @@ paths: type: array items: type: object - properties: &ref_581 + properties: &ref_582 id: type: string name: @@ -26181,7 +27144,7 @@ paths: type: string path: type: string - required: &ref_582 + required: &ref_583 - id - name - path @@ -26206,7 +27169,7 @@ paths: type: array items: type: object - properties: &ref_583 + properties: &ref_584 id: type: string summary: @@ -26214,7 +27177,7 @@ paths: primary: type: boolean default: false - required: &ref_584 + required: &ref_585 - id - summary /w/{workspace}/native_triggers/google/drive/files: @@ -26257,12 +27220,12 @@ paths: application/json: schema: type: object - properties: &ref_587 + properties: &ref_588 files: type: array items: type: object - properties: &ref_585 + properties: &ref_586 id: type: string name: @@ -26272,13 +27235,13 @@ paths: is_folder: type: boolean default: false - required: &ref_586 + required: &ref_587 - id - name - mime_type next_page_token: type: string - required: &ref_588 + required: &ref_589 - files /w/{workspace}/native_triggers/google/drive/shared_drives: get: @@ -26301,12 +27264,12 @@ paths: type: array items: type: object - properties: &ref_589 + properties: &ref_590 id: type: string name: type: string - required: &ref_590 + required: &ref_591 - id - name /w/{workspace}/native_triggers/github/repos: @@ -26330,7 +27293,7 @@ paths: type: array items: type: object - properties: &ref_591 + properties: &ref_592 full_name: type: string name: @@ -26339,7 +27302,7 @@ paths: type: string private: type: boolean - required: &ref_592 + required: &ref_593 - full_name - name - owner @@ -26356,7 +27319,7 @@ paths: required: true schema: type: string - enum: *ref_229 + enum: *ref_234 - name: workspace_id in: path required: true @@ -26405,7 +27368,7 @@ paths: application/json: schema: type: object - properties: &ref_454 + properties: &ref_456 mqtt_resource_path: type: string description: >- @@ -26415,16 +27378,16 @@ paths: type: array items: type: object - properties: &ref_236 + properties: &ref_241 qos: type: string - enum: &ref_453 + enum: &ref_455 - qos0 - qos1 - qos2 topic: type: string - required: &ref_237 + required: &ref_242 - qos - topic description: >- @@ -26438,7 +27401,7 @@ paths: nullable: true description: MQTT v3 specific configuration (clean_session) type: object - properties: &ref_238 + properties: &ref_243 clean_session: type: boolean v5_config: @@ -26447,7 +27410,7 @@ paths: MQTT v5 specific configuration (clean_start, topic_alias_maximum, session_expiry_interval) type: object - properties: &ref_239 + properties: &ref_244 clean_start: type: boolean topic_alias_maximum: @@ -26458,7 +27421,7 @@ paths: nullable: true description: MQTT protocol version ('v3' or 'v5') type: string - enum: &ref_240 + enum: &ref_245 - v3 - v5 path: @@ -26480,7 +27443,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -26491,7 +27454,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -26507,7 +27470,7 @@ paths: type: array items: type: string - required: &ref_455 + required: &ref_457 - path - script_path - is_flow @@ -26542,7 +27505,7 @@ paths: application/json: schema: type: object - properties: &ref_456 + properties: &ref_458 mqtt_resource_path: type: string description: >- @@ -26552,8 +27515,8 @@ paths: type: array items: type: object - properties: *ref_236 - required: *ref_237 + properties: *ref_241 + required: *ref_242 description: >- Array of MQTT topics to subscribe to, each with topic name and QoS level @@ -26565,19 +27528,19 @@ paths: nullable: true description: MQTT v3 specific configuration (clean_session) type: object - properties: *ref_238 + properties: *ref_243 v5_config: nullable: true description: >- MQTT v5 specific configuration (clean_start, topic_alias_maximum, session_expiry_interval) type: object - properties: *ref_239 + properties: *ref_244 client_version: nullable: true description: MQTT protocol version ('v3' or 'v5') type: string - enum: *ref_240 + enum: *ref_245 path: type: string description: >- @@ -26597,7 +27560,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -26608,7 +27571,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -26624,7 +27587,7 @@ paths: type: array items: type: string - required: &ref_457 + required: &ref_459 - path - script_path - is_flow @@ -26675,81 +27638,124 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: mqtt trigger deleted content: application/json: schema: - allOf: &ref_241 + allOf: + - allOf: &ref_246 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_247 + mqtt_resource_path: + type: string + description: >- + Path to the MQTT resource containing broker connection + configuration + subscribe_topics: + type: array + items: + type: object + properties: *ref_241 + required: *ref_242 + description: >- + Array of MQTT topics to subscribe to, each with topic + name and QoS level + v3_config: + nullable: true + description: MQTT v3 specific configuration (clean_session) + type: object + properties: *ref_243 + v5_config: + nullable: true + description: >- + MQTT v5 specific configuration (clean_start, + topic_alias_maximum, session_expiry_interval) + type: object + properties: *ref_244 + client_id: + type: string + nullable: true + description: MQTT client ID for this connection + client_version: + nullable: true + description: MQTT protocol version ('v3' or 'v5') + type: string + enum: *ref_245 + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error: + type: string + description: Last error message if the trigger failed + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_248 + - subscribe_topics + - mqtt_resource_path - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_242 - mqtt_resource_path: - type: string - description: >- - Path to the MQTT resource containing broker connection - configuration - subscribe_topics: - type: array - items: - type: object - properties: *ref_236 - required: *ref_237 - description: >- - Array of MQTT topics to subscribe to, each with topic name - and QoS level - v3_config: - nullable: true - description: MQTT v3 specific configuration (clean_session) - type: object - properties: *ref_238 - v5_config: - nullable: true - description: >- - MQTT v5 specific configuration (clean_start, - topic_alias_maximum, session_expiry_interval) - type: object - properties: *ref_239 - client_id: - type: string - nullable: true - description: MQTT client ID for this connection - client_version: - nullable: true - description: MQTT protocol version ('v3' or 'v5') - type: string - enum: *ref_240 - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error: - type: string - description: Last error message if the trigger failed - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_243 - - subscribe_topics - - mqtt_resource_path + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/mqtt_triggers/list: get: summary: list mqtt triggers @@ -26788,6 +27794,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: mqtt trigger list @@ -26796,10 +27812,10 @@ paths: schema: type: array items: - allOf: *ref_241 + allOf: *ref_246 type: object - properties: *ref_242 - required: *ref_243 + properties: *ref_247 + required: *ref_248 /w/{workspace}/mqtt_triggers/exists/{path}: get: summary: does mqtt trigger exists @@ -26848,7 +27864,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -26912,7 +27928,7 @@ paths: schema: type: object description: Data for creating or updating a Google Cloud Pub/Sub trigger. - properties: &ref_244 + properties: &ref_249 gcp_resource_path: type: string description: >- @@ -26920,7 +27936,7 @@ paths: credentials for authentication. subscription_mode: type: string - enum: &ref_249 + enum: &ref_254 - existing - create_update description: >- @@ -26938,7 +27954,7 @@ paths: description: Base URL for push delivery endpoint. delivery_type: type: string - enum: &ref_246 + enum: &ref_251 - push - pull description: >- @@ -26949,7 +27965,7 @@ paths: nullable: true type: object description: Configuration for push delivery mode. - properties: &ref_247 + properties: &ref_252 audience: type: string description: >- @@ -26960,7 +27976,7 @@ paths: description: >- If true, push messages will include OIDC authentication tokens. - required: &ref_248 + required: &ref_253 - authenticate - base_endpoint path: @@ -26982,7 +27998,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 auto_acknowledge_msg: type: boolean description: >- @@ -27009,7 +28025,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -27025,7 +28041,7 @@ paths: type: array items: type: string - required: &ref_245 + required: &ref_250 - path - script_path - is_flow @@ -27062,8 +28078,8 @@ paths: schema: type: object description: Data for creating or updating a Google Cloud Pub/Sub trigger. - properties: *ref_244 - required: *ref_245 + properties: *ref_249 + required: *ref_250 responses: '200': description: gcp trigger updated @@ -27108,83 +28124,127 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: gcp trigger deleted content: application/json: schema: - allOf: &ref_250 + allOf: + - allOf: &ref_255 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + description: >- + A Google Cloud Pub/Sub trigger that executes a script or + flow when messages are received. + properties: &ref_256 + gcp_resource_path: + type: string + description: >- + Path to the GCP resource containing service account + credentials for authentication. + topic_id: + type: string + description: Google Cloud Pub/Sub topic ID to subscribe to. + subscription_id: + type: string + description: Google Cloud Pub/Sub subscription ID. + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal use). + delivery_type: + type: string + enum: *ref_251 + description: >- + Delivery mode for messages. 'push' for HTTP push + delivery where messages are sent to a webhook + endpoint, 'pull' for polling where the trigger + actively fetches messages. + delivery_config: + nullable: true + type: object + description: Configuration for push delivery mode. + properties: *ref_252 + required: *ref_253 + subscription_mode: + type: string + enum: *ref_254 + description: >- + The mode of subscription. 'existing' means using an + existing GCP subscription, while 'create_update' + involves creating or updating a new subscription. + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal use). + error: + type: string + description: Last error message if the trigger failed. + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails. + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_257 + - gcp_resource_path + - topic_id + - subscription_id + - delivery_type + - subscription_mode - type: object - properties: *ref_214 - required: *ref_215 - type: object - description: >- - A Google Cloud Pub/Sub trigger that executes a script or flow - when messages are received. - properties: &ref_251 - gcp_resource_path: - type: string - description: >- - Path to the GCP resource containing service account - credentials for authentication. - topic_id: - type: string - description: Google Cloud Pub/Sub topic ID to subscribe to. - subscription_id: - type: string - description: Google Cloud Pub/Sub subscription ID. - server_id: - type: string - description: >- - ID of the server currently handling this trigger (internal - use). - delivery_type: - type: string - enum: *ref_246 - description: >- - Delivery mode for messages. 'push' for HTTP push delivery - where messages are sent to a webhook endpoint, 'pull' for - polling where the trigger actively fetches messages. - delivery_config: - nullable: true - type: object - description: Configuration for push delivery mode. - properties: *ref_247 - required: *ref_248 - subscription_mode: - type: string - enum: *ref_249 - description: >- - The mode of subscription. 'existing' means using an - existing GCP subscription, while 'create_update' involves - creating or updating a new subscription. - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal use). - error: - type: string - description: Last error message if the trigger failed. - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails. - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_252 - - gcp_resource_path - - topic_id - - subscription_id - - delivery_type - - subscription_mode + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/gcp_triggers/list: get: summary: list gcp triggers @@ -27223,6 +28283,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: gcp trigger list @@ -27231,13 +28301,13 @@ paths: schema: type: array items: - allOf: *ref_250 + allOf: *ref_255 type: object description: >- A Google Cloud Pub/Sub trigger that executes a script or flow when messages are received. - properties: *ref_251 - required: *ref_252 + properties: *ref_256 + required: *ref_257 /w/{workspace}/gcp_triggers/exists/{path}: get: summary: does gcp trigger exists @@ -27286,7 +28356,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -27353,10 +28423,10 @@ paths: application/json: schema: type: object - properties: &ref_460 + properties: &ref_462 subscription_id: type: string - required: &ref_461 + required: &ref_463 - subscription_id responses: '200': @@ -27411,10 +28481,10 @@ paths: application/json: schema: type: object - properties: &ref_458 + properties: &ref_460 topic_id: type: string - required: &ref_459 + required: &ref_461 - topic_id responses: '200': @@ -27443,12 +28513,12 @@ paths: schema: type: object description: Data for creating or updating an Azure Event Grid trigger. - properties: &ref_253 + properties: &ref_258 azure_resource_path: type: string azure_mode: type: string - enum: &ref_255 + enum: &ref_260 - basic_push - namespace_push - namespace_pull @@ -27480,7 +28550,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 error_handler_path: type: string error_handler_args: @@ -27490,7 +28560,7 @@ paths: retry: type: object description: Retry configuration for failed module executions - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string preserve_permissioned_as: @@ -27499,7 +28569,7 @@ paths: type: array items: type: string - required: &ref_254 + required: &ref_259 - path - script_path - is_flow @@ -27536,8 +28606,8 @@ paths: schema: type: object description: Data for creating or updating an Azure Event Grid trigger. - properties: *ref_253 - required: *ref_254 + properties: *ref_258 + required: *ref_259 responses: '200': description: azure trigger updated @@ -27582,65 +28652,110 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: azure trigger content: application/json: schema: - allOf: &ref_256 - - type: object - properties: *ref_214 - required: *ref_215 - type: object - description: >- - An Azure Event Grid trigger that executes a script or flow - when events arrive. - properties: &ref_257 - azure_resource_path: - type: string - azure_mode: - type: string - enum: *ref_255 - description: Azure Event Grid trigger mode. - scope_resource_id: - type: string + allOf: + - allOf: &ref_261 + - type: object + properties: *ref_219 + required: *ref_220 + type: object description: >- - ARM resource ID of the topic (basic) or namespace - (namespace modes). - topic_name: - type: string - nullable: true - description: Topic name within the namespace (namespace modes only). - subscription_name: - type: string - event_type_filters: - type: array - items: - type: string - nullable: true - server_id: - type: string - last_server_ping: - type: string - format: date-time - error: - type: string - error_handler_path: - type: string - error_handler_args: - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - retry: - type: object - description: Retry configuration for failed module executions - properties: *ref_197 - required: &ref_258 - - azure_resource_path - - azure_mode - - scope_resource_id - - subscription_name + An Azure Event Grid trigger that executes a script or flow + when events arrive. + properties: &ref_262 + azure_resource_path: + type: string + azure_mode: + type: string + enum: *ref_260 + description: Azure Event Grid trigger mode. + scope_resource_id: + type: string + description: >- + ARM resource ID of the topic (basic) or namespace + (namespace modes). + topic_name: + type: string + nullable: true + description: >- + Topic name within the namespace (namespace modes + only). + subscription_name: + type: string + event_type_filters: + type: array + items: + type: string + nullable: true + server_id: + type: string + last_server_ping: + type: string + format: date-time + error: + type: string + error_handler_path: + type: string + error_handler_args: + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + retry: + type: object + description: Retry configuration for failed module executions + properties: *ref_201 + required: &ref_263 + - azure_resource_path + - azure_mode + - scope_resource_id + - subscription_name + - type: object + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/azure_triggers/list: get: summary: list azure triggers @@ -27673,6 +28788,16 @@ paths: in: query schema: type: string + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: azure trigger list @@ -27681,13 +28806,13 @@ paths: schema: type: array items: - allOf: *ref_256 + allOf: *ref_261 type: object description: >- An Azure Event Grid trigger that executes a script or flow when events arrive. - properties: *ref_257 - required: *ref_258 + properties: *ref_262 + required: *ref_263 /w/{workspace}/azure_triggers/exists/{path}: get: summary: check whether an azure trigger exists @@ -27735,7 +28860,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -27767,10 +28892,10 @@ paths: application/json: schema: type: object - properties: &ref_464 + properties: &ref_466 azure_resource_path: type: string - required: &ref_465 + required: &ref_467 - azure_resource_path responses: '200': @@ -27800,10 +28925,10 @@ paths: application/json: schema: type: object - properties: &ref_466 + properties: &ref_468 scope_resource_id: type: string - required: &ref_467 + required: &ref_469 - scope_resource_id responses: '200': @@ -27835,12 +28960,12 @@ paths: application/json: schema: type: object - properties: &ref_468 + properties: &ref_470 scope_resource_id: type: string topic_name: type: string - required: &ref_469 + required: &ref_471 - scope_resource_id - topic_name responses: @@ -27873,10 +28998,10 @@ paths: application/json: schema: type: object - properties: &ref_462 + properties: &ref_464 azure_mode: type: string - enum: *ref_255 + enum: *ref_260 description: Azure Event Grid trigger mode. scope_resource_id: type: string @@ -27885,7 +29010,7 @@ paths: nullable: true subscription_name: type: string - required: &ref_463 + required: &ref_465 - azure_mode - scope_resource_id - subscription_name @@ -27921,7 +29046,7 @@ paths: items: type: object description: An ARM resource the service principal can see. - properties: &ref_259 + properties: &ref_264 id: type: string name: @@ -27930,7 +29055,7 @@ paths: type: string type: type: string - required: &ref_260 + required: &ref_265 - id - name - type @@ -27961,8 +29086,8 @@ paths: items: type: object description: An ARM resource the service principal can see. - properties: *ref_259 - required: *ref_260 + properties: *ref_264 + required: *ref_265 /w/{workspace}/postgres_triggers/postgres/version/{path}: get: summary: get postgres version @@ -28025,19 +29150,19 @@ paths: application/json: schema: type: object - properties: &ref_478 + properties: &ref_480 postgres_resource_path: type: string relations: type: array items: type: object - properties: &ref_262 + properties: &ref_267 schema_name: type: string table_to_track: type: array - items: &ref_476 + items: &ref_478 type: object properties: table_name: @@ -28050,14 +29175,14 @@ paths: type: string required: - table_name - required: &ref_263 + required: &ref_268 - schema_name - table_to_track language: type: string - enum: &ref_477 + enum: &ref_479 - Typescript - required: &ref_479 + required: &ref_481 - postgres_resource_path - relations - language @@ -28082,7 +29207,7 @@ paths: - name: id in: path required: true - schema: &ref_304 + schema: &ref_309 type: string responses: '200': @@ -28115,7 +29240,7 @@ paths: type: array items: type: object - properties: &ref_475 + properties: &ref_477 slot_name: type: string active: @@ -28142,7 +29267,7 @@ paths: application/json: schema: type: object - properties: &ref_261 + properties: &ref_266 name: type: string responses: @@ -28174,7 +29299,7 @@ paths: application/json: schema: type: object - properties: *ref_261 + properties: *ref_266 responses: '200': description: postgres replication slot deleted @@ -28225,7 +29350,7 @@ paths: in: path required: true description: The name of the publication - schema: &ref_264 + schema: &ref_269 type: string responses: '200': @@ -28234,18 +29359,18 @@ paths: application/json: schema: type: object - properties: &ref_265 + properties: &ref_270 table_to_track: type: array items: type: object - properties: *ref_262 - required: *ref_263 + properties: *ref_267 + required: *ref_268 transaction_to_track: type: array items: type: string - required: &ref_266 + required: &ref_271 - transaction_to_track /w/{workspace}/postgres_triggers/publication/create/{publication}/{path}: post: @@ -28266,7 +29391,7 @@ paths: in: path required: true description: The name of the publication - schema: *ref_264 + schema: *ref_269 requestBody: description: new publication for postgres required: true @@ -28274,8 +29399,8 @@ paths: application/json: schema: type: object - properties: *ref_265 - required: *ref_266 + properties: *ref_270 + required: *ref_271 responses: '201': description: publication created @@ -28302,7 +29427,7 @@ paths: in: path required: true description: The name of the publication - schema: *ref_264 + schema: *ref_269 requestBody: description: update publication for postgres required: true @@ -28310,8 +29435,8 @@ paths: application/json: schema: type: object - properties: *ref_265 - required: *ref_266 + properties: *ref_270 + required: *ref_271 responses: '201': description: publication updated @@ -28338,7 +29463,7 @@ paths: in: path required: true description: The name of the publication - schema: *ref_264 + schema: *ref_269 responses: '200': description: postgres publication deleted @@ -28364,7 +29489,7 @@ paths: application/json: schema: type: object - properties: &ref_480 + properties: &ref_482 replication_slot_name: type: string description: Name of the PostgreSQL logical replication slot to use @@ -28392,7 +29517,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 postgres_resource_path: type: string description: >- @@ -28403,8 +29528,8 @@ paths: Configuration for creating/managing the publication (tables, operations) type: object - properties: *ref_265 - required: *ref_266 + properties: *ref_270 + required: *ref_271 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -28415,7 +29540,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -28431,7 +29556,7 @@ paths: type: array items: type: string - required: &ref_481 + required: &ref_483 - path - script_path - is_flow @@ -28466,7 +29591,7 @@ paths: application/json: schema: type: object - properties: &ref_482 + properties: &ref_484 replication_slot_name: type: string description: Name of the PostgreSQL logical replication slot to use @@ -28494,7 +29619,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 postgres_resource_path: type: string description: >- @@ -28505,8 +29630,8 @@ paths: Configuration for creating/managing the publication (tables, operations) type: object - properties: *ref_265 - required: *ref_266 + properties: *ref_270 + required: *ref_271 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -28517,7 +29642,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -28533,7 +29658,7 @@ paths: type: array items: type: string - required: &ref_483 + required: &ref_485 - path - script_path - is_flow @@ -28585,60 +29710,103 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: get postgres trigger content: application/json: schema: - allOf: &ref_267 + allOf: + - allOf: &ref_272 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_273 + postgres_resource_path: + type: string + description: >- + Path to the PostgreSQL resource containing connection + configuration + publication_name: + type: string + description: >- + Name of the PostgreSQL publication to subscribe to for + change data capture + server_id: + type: string + description: >- + ID of the server currently handling this trigger + (internal) + replication_slot_name: + type: string + description: Name of the PostgreSQL logical replication slot to use + error: + type: string + description: Last error message if the trigger failed + last_server_ping: + type: string + format: date-time + description: Timestamp of last server heartbeat (internal) + error_handler_path: + type: string + description: >- + Path to a script or flow to run when the triggered job + fails + error_handler_args: + description: The arguments to pass to the script or flow + type: object + additionalProperties: true + retry: + description: Retry configuration for failed module executions + type: object + properties: *ref_201 + required: &ref_274 + - postgres_resource_path + - replication_slot_name + - publication_name - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_268 - postgres_resource_path: - type: string - description: >- - Path to the PostgreSQL resource containing connection - configuration - publication_name: - type: string - description: >- - Name of the PostgreSQL publication to subscribe to for - change data capture - server_id: - type: string - description: >- - ID of the server currently handling this trigger - (internal) - replication_slot_name: - type: string - description: Name of the PostgreSQL logical replication slot to use - error: - type: string - description: Last error message if the trigger failed - last_server_ping: - type: string - format: date-time - description: Timestamp of last server heartbeat (internal) - error_handler_path: - type: string - description: >- - Path to a script or flow to run when the triggered job - fails - error_handler_args: - description: The arguments to pass to the script or flow - type: object - additionalProperties: true - retry: - description: Retry configuration for failed module executions - type: object - properties: *ref_197 - required: &ref_269 - - postgres_resource_path - - replication_slot_name - - publication_name + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/postgres_triggers/list: get: summary: list postgres triggers @@ -28677,6 +29845,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: postgres trigger list @@ -28685,10 +29863,10 @@ paths: schema: type: array items: - allOf: *ref_267 + allOf: *ref_272 type: object - properties: *ref_268 - required: *ref_269 + properties: *ref_273 + required: *ref_274 /w/{workspace}/postgres_triggers/exists/{path}: get: summary: does postgres trigger exists @@ -28737,7 +29915,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -28800,7 +29978,7 @@ paths: application/json: schema: type: object - properties: &ref_492 + properties: &ref_494 path: type: string script_path: @@ -28820,11 +29998,11 @@ paths: retry: type: object description: Retry configuration for failed module executions - properties: *ref_197 + properties: *ref_201 mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 permissioned_as: type: string description: >- @@ -28840,7 +30018,7 @@ paths: type: array items: type: string - required: &ref_493 + required: &ref_495 - path - script_path - local_part @@ -28874,7 +30052,7 @@ paths: application/json: schema: type: object - properties: &ref_494 + properties: &ref_496 path: type: string script_path: @@ -28894,7 +30072,7 @@ paths: retry: type: object description: Retry configuration for failed module executions - properties: *ref_197 + properties: *ref_201 permissioned_as: type: string description: >- @@ -28910,7 +30088,7 @@ paths: type: array items: type: string - required: &ref_495 + required: &ref_497 - path - script_path - is_flow @@ -28958,34 +30136,77 @@ paths: in: path required: true schema: *ref_60 + - name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the + deployed payload. + schema: *ref_77 responses: '200': description: email trigger retrieved content: application/json: schema: - allOf: &ref_270 + allOf: + - allOf: &ref_275 + - type: object + properties: *ref_219 + required: *ref_220 + type: object + properties: &ref_276 + local_part: + type: string + workspaced_local_part: + type: boolean + error_handler_path: + type: string + error_handler_args: + type: object + description: The arguments to pass to the script or flow + additionalProperties: true + retry: + type: object + description: Retry configuration for failed module executions + properties: *ref_201 + required: &ref_277 + - local_part - type: object - properties: *ref_214 - required: *ref_215 - type: object - properties: &ref_271 - local_part: - type: string - workspaced_local_part: - type: boolean - error_handler_path: - type: string - error_handler_args: - type: object - description: The arguments to pass to the script or flow - additionalProperties: true - retry: - type: object - description: Retry configuration for failed module executions - properties: *ref_197 - required: &ref_272 - - local_part + description: > + Overlay fields added to every "get by path" response that + accepts + + the `get_draft` query parameter. The deployed payload is + sent + + untouched in the response body; the authed user's saved + draft + + for this path — whatever shape the editor wrote — is + attached + + as the sibling `draft` field when `get_draft=true` and a + draft + + exists. The frontend pairs the two to present diff / reset + / + + discard UI; the server never merges them. + + + When `no_deployed=true` there is no deployed row at this + path — + + the response body is a best-effort stand-in synthesized + from + + the draft, and only `draft` is canonical. Callers should + disable + + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 /w/{workspace}/email_triggers/list: get: summary: list email triggers @@ -29024,6 +30245,16 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 responses: '200': description: email trigger list @@ -29032,10 +30263,10 @@ paths: schema: type: array items: - allOf: *ref_270 + allOf: *ref_275 type: object - properties: *ref_271 - required: *ref_272 + properties: *ref_276 + required: *ref_277 /w/{workspace}/email_triggers/exists/{path}: get: summary: does email trigger exists @@ -29117,7 +30348,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 force: type: boolean description: > @@ -29147,9 +30378,9 @@ paths: type: array items: type: object - required: &ref_496 + required: &ref_498 - name - properties: &ref_497 + properties: &ref_499 name: type: string summary: @@ -29179,9 +30410,9 @@ paths: type: array items: type: object - required: &ref_274 + required: &ref_279 - name - properties: &ref_275 + properties: &ref_280 name: type: string summary: @@ -29200,14 +30431,14 @@ paths: type: array items: type: object - properties: &ref_498 + properties: &ref_500 workspace_id: type: string workspace_name: type: string role: type: string - required: &ref_499 + required: &ref_501 - name /groups/get/{name}: get: @@ -29219,7 +30450,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: instance group @@ -29227,8 +30458,8 @@ paths: application/json: schema: type: object - required: *ref_274 - properties: *ref_275 + required: *ref_279 + properties: *ref_280 /groups/create: post: summary: create instance group @@ -29266,7 +30497,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: update instance group required: true @@ -29302,7 +30533,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: instance group deleted @@ -29320,7 +30551,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: user to add to instance group required: true @@ -29350,7 +30581,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: user to remove from instance group required: true @@ -29385,7 +30616,7 @@ paths: type: array items: type: object - properties: &ref_276 + properties: &ref_281 name: type: string summary: @@ -29406,7 +30637,7 @@ paths: enum: - superadmin - devops - required: &ref_277 + required: &ref_282 - name /groups/overwrite: post: @@ -29423,8 +30654,8 @@ paths: type: array items: type: object - properties: *ref_276 - required: *ref_277 + properties: *ref_281 + required: *ref_282 responses: '200': description: success message @@ -29460,7 +30691,7 @@ paths: type: array items: type: object - properties: &ref_278 + properties: &ref_283 name: type: string summary: @@ -29473,7 +30704,7 @@ paths: type: object additionalProperties: type: boolean - required: &ref_279 + required: &ref_284 - name /w/{workspace}/groups/listnames: get: @@ -29546,7 +30777,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: updated group required: true @@ -29578,7 +30809,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: group deleted @@ -29600,7 +30831,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: group @@ -29608,8 +30839,8 @@ paths: application/json: schema: type: object - properties: *ref_278 - required: *ref_279 + properties: *ref_283 + required: *ref_284 /w/{workspace}/groups/adduser/{name}: post: summary: add user to group @@ -29624,7 +30855,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: added user to group required: true @@ -29656,7 +30887,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: added user to group required: true @@ -29688,7 +30919,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 - name: page description: which page to return (start at 1, default 1) in: query @@ -29747,7 +30978,7 @@ paths: type: array items: type: object - properties: &ref_281 + properties: &ref_286 name: type: string owners: @@ -29773,7 +31004,7 @@ paths: (relative to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: &ref_280 + items: &ref_285 type: object required: - path_glob @@ -29794,7 +31025,15 @@ paths: permissioned as. Must be `u/`, `g/`, or an email that exists in this workspace. - required: &ref_282 + labels: + type: array + items: + type: string + description: > + Labels set on the folder. Items inside the folder + inherit them, exposed as `inherited_labels` on scripts + and flows and stamped into job labels at run time. + required: &ref_287 - name - owners - extra_perms @@ -29861,7 +31100,11 @@ paths: to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: *ref_280 + items: *ref_285 + labels: + type: array + items: + type: string required: - name responses: @@ -29885,7 +31128,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: update folder required: true @@ -29911,7 +31154,11 @@ paths: to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: *ref_280 + items: *ref_285 + labels: + type: array + items: + type: string responses: '200': description: folder updated @@ -29933,7 +31180,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: folder deleted @@ -29955,7 +31202,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: folder @@ -29963,8 +31210,8 @@ paths: application/json: schema: type: object - properties: *ref_281 - required: *ref_282 + properties: *ref_286 + required: *ref_287 /w/{workspace}/folders/exists/{name}: get: summary: exists folder @@ -29979,7 +31226,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: folder exists @@ -30001,7 +31248,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: folder @@ -30043,7 +31290,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: owner user to folder required: true @@ -30077,7 +31324,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: added owner to folder required: true @@ -30113,7 +31360,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 - name: page description: which page to return (start at 1, default 1) in: query @@ -30177,7 +31424,7 @@ paths: type: array items: type: object - properties: &ref_500 + properties: &ref_502 worker: type: string worker_instance: @@ -30223,7 +31470,7 @@ paths: type: string native_mode: type: boolean - required: &ref_501 + required: &ref_503 - worker - worker_instance - ping_at @@ -30388,7 +31635,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: a config @@ -30397,12 +31644,12 @@ paths: schema: type: object nullable: true - properties: &ref_384 + properties: &ref_389 alerts: type: array items: type: object - properties: &ref_382 + properties: &ref_387 name: type: string tags_to_monitor: @@ -30415,7 +31662,7 @@ paths: type: integer alert_time_threshold_seconds: type: integer - required: &ref_383 + required: &ref_388 - name - tags_to_monitor - jobs_num_threshold @@ -30431,7 +31678,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 requestBody: description: worker group required: true @@ -30454,7 +31701,7 @@ paths: - name: name in: path required: true - schema: *ref_273 + schema: *ref_278 responses: '200': description: Delete config @@ -30477,12 +31724,12 @@ paths: type: array items: type: object - properties: &ref_547 + properties: &ref_548 name: type: string config: type: object - required: &ref_548 + required: &ref_549 - name /configs/list_autoscaling_events/{worker_group}: get: @@ -30513,7 +31760,7 @@ paths: type: array items: type: object - properties: &ref_551 + properties: &ref_552 id: type: integer format: int64 @@ -30580,7 +31827,7 @@ paths: type: string language: type: string - enum: *ref_94 + enum: *ref_99 required: - workspace_id - language @@ -30606,7 +31853,7 @@ paths: type: string language: type: string - enum: *ref_94 + enum: *ref_99 workspace_dep_names: type: array items: @@ -30946,7 +32193,7 @@ paths: properties: trigger_kind: type: string - enum: &ref_283 + enum: &ref_288 - webhook - http - websocket @@ -30992,11 +32239,11 @@ paths: required: true schema: type: string - enum: *ref_283 + enum: *ref_288 - name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 - name: path in: path required: true @@ -31018,7 +32265,7 @@ paths: - name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 - name: path in: path required: true @@ -31032,17 +32279,17 @@ paths: type: array items: type: object - properties: &ref_552 + properties: &ref_553 trigger_config: {} trigger_kind: type: string - enum: *ref_283 + enum: *ref_288 error: type: string last_server_ping: type: string format: date-time - required: &ref_553 + required: &ref_554 - trigger_kind /w/{workspace}/capture/list/{runnable_kind}/{path}: get: @@ -31058,7 +32305,7 @@ paths: - name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 - name: path in: path required: true @@ -31067,7 +32314,7 @@ paths: in: query schema: type: string - enum: *ref_283 + enum: *ref_288 - name: page description: which page to return (start at 1, default 1) in: query @@ -31085,10 +32332,10 @@ paths: type: array items: type: object - properties: &ref_284 + properties: &ref_289 trigger_kind: type: string - enum: *ref_283 + enum: *ref_288 main_args: {} preprocessor_args: {} id: @@ -31096,7 +32343,7 @@ paths: created_at: type: string format: date-time - required: &ref_285 + required: &ref_290 - trigger_kind - main_args - preprocessor_args @@ -31116,7 +32363,7 @@ paths: - name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 - name: path in: path required: true @@ -31161,8 +32408,8 @@ paths: application/json: schema: type: object - properties: *ref_284 - required: *ref_285 + properties: *ref_289 + required: *ref_290 delete: summary: delete a capture operationId: deleteCapture @@ -31254,13 +32501,13 @@ paths: schema: *ref_4 - name: runnable_id in: query - schema: &ref_286 + schema: &ref_291 type: string - name: runnable_type in: query - schema: &ref_287 + schema: &ref_292 type: string - enum: &ref_392 + enum: &ref_398 - ScriptHash - ScriptPath - FlowPath @@ -31277,7 +32524,7 @@ paths: filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: include_preview in: query schema: @@ -31291,7 +32538,7 @@ paths: type: array items: type: object - properties: &ref_288 + properties: &ref_293 id: type: string name: @@ -31305,7 +32552,7 @@ paths: type: boolean success: type: boolean - required: &ref_289 + required: &ref_294 - id - name - args @@ -31355,10 +32602,10 @@ paths: schema: *ref_4 - name: runnable_id in: query - schema: *ref_286 + schema: *ref_291 - name: runnable_type in: query - schema: *ref_287 + schema: *ref_292 - name: page description: which page to return (start at 1, default 1) in: query @@ -31376,8 +32623,8 @@ paths: type: array items: type: object - properties: *ref_288 - required: *ref_289 + properties: *ref_293 + required: *ref_294 /w/{workspace}/inputs/create: post: summary: Create an Input for future use in a script or flow @@ -31391,10 +32638,10 @@ paths: schema: *ref_4 - name: runnable_id in: query - schema: *ref_286 + schema: *ref_291 - name: runnable_type in: query - schema: *ref_287 + schema: *ref_292 requestBody: description: Input required: true @@ -31402,12 +32649,12 @@ paths: application/json: schema: type: object - properties: &ref_388 + properties: &ref_394 name: type: string args: type: object - required: &ref_389 + required: &ref_395 - name - args - created_by @@ -31437,14 +32684,14 @@ paths: application/json: schema: type: object - properties: &ref_390 + properties: &ref_396 id: type: string name: type: string is_public: type: boolean - required: &ref_391 + required: &ref_397 - id - name - is_public @@ -31470,7 +32717,7 @@ paths: - name: input in: path required: true - schema: &ref_312 + schema: &ref_317 type: string responses: '200': @@ -31503,7 +32750,7 @@ paths: properties: s3_resource: type: object - properties: &ref_290 + properties: &ref_295 bucket: type: string region: @@ -31518,7 +32765,7 @@ paths: type: string pathStyle: type: boolean - required: &ref_291 + required: &ref_296 - bucket - region - endPoint @@ -31596,8 +32843,8 @@ paths: properties: s3_resource: type: object - properties: *ref_290 - required: *ref_291 + properties: *ref_295 + required: *ref_296 responses: '200': description: Connection settings @@ -31618,10 +32865,10 @@ paths: type: boolean client_kwargs: type: object - properties: &ref_292 + properties: &ref_297 region_name: type: string - required: &ref_293 + required: &ref_298 - region_name required: - endpoint_url @@ -31676,8 +32923,8 @@ paths: type: boolean client_kwargs: type: object - properties: *ref_292 - required: *ref_293 + properties: *ref_297 + required: *ref_298 required: - endpoint_url - use_ssl @@ -31735,8 +32982,8 @@ paths: application/json: schema: type: object - properties: *ref_290 - required: *ref_291 + properties: *ref_295 + required: *ref_296 /w/{workspace}/job_helpers/test_connection: get: summary: Test connection to the workspace object storage @@ -31800,10 +33047,10 @@ paths: type: array items: type: object - properties: &ref_294 + properties: &ref_299 s3: type: string - required: &ref_295 + required: &ref_300 - s3 restricted_access: type: boolean @@ -31836,7 +33083,7 @@ paths: application/json: schema: type: object - properties: &ref_298 + properties: &ref_303 mime_type: type: string size_in_bytes: @@ -31900,7 +33147,7 @@ paths: application/json: schema: type: object - properties: &ref_296 + properties: &ref_301 msg: type: string content: @@ -31912,7 +33159,7 @@ paths: - Csv - Parquet - Unknown - required: &ref_297 + required: &ref_302 - content_type /w/{workspace}/job_helpers/list_git_repo_files: get: @@ -31960,8 +33207,8 @@ paths: type: array items: type: object - properties: *ref_294 - required: *ref_295 + properties: *ref_299 + required: *ref_300 restricted_access: type: boolean required: @@ -32020,8 +33267,8 @@ paths: application/json: schema: type: object - properties: *ref_296 - required: *ref_297 + properties: *ref_301 + required: *ref_302 /w/{workspace}/job_helpers/load_git_repo_file_metadata: get: summary: >- @@ -32052,7 +33299,7 @@ paths: application/json: schema: type: object - properties: *ref_298 + properties: *ref_303 /w/{workspace}/job_helpers/check_s3_folder_exists: get: summary: Check if S3 path exists and is a folder @@ -32503,7 +33750,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 requestBody: description: parameters for statistics retrieval required: true @@ -32532,46 +33779,46 @@ paths: type: array items: type: object - properties: &ref_533 + properties: &ref_534 id: type: string name: type: string - required: &ref_534 + required: &ref_535 - id scalar_metrics: type: array items: type: object - properties: &ref_535 + properties: &ref_536 metric_id: type: string value: type: number - required: &ref_536 + required: &ref_537 - id - value timeseries_metrics: type: array items: type: object - properties: &ref_537 + properties: &ref_538 metric_id: type: string values: type: array items: type: object - properties: &ref_539 + properties: &ref_540 timestamp: type: string format: date-time value: type: number - required: &ref_540 + required: &ref_541 - timestamp - value - required: &ref_538 + required: &ref_539 - id - values /w/{workspace}/job_metrics/set_progress/{id}: @@ -32588,7 +33835,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 requestBody: description: parameters for statistics retrieval required: true @@ -32622,7 +33869,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: job progress between 0 and 99 @@ -32640,11 +33887,11 @@ paths: - name: before description: filter on started before (inclusive) timestamp in: query - schema: *ref_299 + schema: *ref_304 - name: after description: filter on created after (exclusive) timestamp in: query - schema: *ref_300 + schema: *ref_305 - name: with_error in: query required: false @@ -32716,12 +33963,12 @@ paths: type: array items: type: object - properties: &ref_541 + properties: &ref_542 concurrency_key: type: string total_running: type: number - required: &ref_542 + required: &ref_543 - concurrency_key - total_running /concurrency_groups/prune/{concurrency_id}: @@ -32734,7 +33981,7 @@ paths: - name: concurrency_id in: path required: true - schema: &ref_314 + schema: &ref_319 type: string responses: '200': @@ -32754,7 +34001,7 @@ paths: - name: id in: path required: true - schema: *ref_172 + schema: *ref_176 responses: '200': description: concurrency key for given job @@ -32790,104 +34037,104 @@ paths: (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 - name: label description: >- filter by exact matching job label. Supports comma-separated list (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_174 + schema: *ref_178 - name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 - name: script_path_exact description: >- filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 - name: running description: filter on running jobs in: query - schema: *ref_163 + schema: *ref_167 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_164 + schema: *ref_168 - name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_184 + schema: *ref_188 - name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_185 + schema: *ref_189 - name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: *ref_186 + schema: *ref_190 - name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: *ref_187 + schema: *ref_191 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_171 + schema: *ref_175 - name: page description: which page to return (start at 1, default 1) in: query @@ -32903,7 +34150,7 @@ paths: (e.g. '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: *ref_188 + schema: *ref_192 - name: is_skipped description: is the job skipped in: query @@ -32924,6 +34171,19 @@ paths: in: query schema: type: boolean + - name: status + description: >- + filter on the exact completed job status. Unlike `success=true` + (which also matches `skipped`), `status=success` matches only + `success`. + in: query + schema: + type: string + enum: + - success + - failure + - canceled + - skipped - name: all_workspaces description: >- get jobs from all workspaces (only valid if request come from the @@ -32943,17 +34203,17 @@ paths: application/json: schema: type: object - properties: &ref_543 + properties: &ref_544 jobs: type: array items: - oneOf: *ref_193 - discriminator: *ref_194 + oneOf: *ref_197 + discriminator: *ref_198 obscured_jobs: type: array items: type: object - properties: &ref_393 + properties: &ref_399 typ: type: string started_at: @@ -32966,7 +34226,7 @@ paths: Obscured jobs omitted for security because of too specific filtering type: boolean - required: &ref_544 + required: &ref_545 - jobs - obscured_jobs /srch/w/{workspace}/index/search/job: @@ -33010,7 +34270,7 @@ paths: type: array items: type: object - properties: &ref_549 + properties: &ref_550 dancer: type: string hit_count: @@ -33089,7 +34349,7 @@ paths: type: array items: type: object - properties: &ref_550 + properties: &ref_551 dancer: type: string /srch/index/search/count_service_logs: @@ -33364,7 +34624,7 @@ paths: type: string kind: type: string - enum: *ref_301 + enum: *ref_306 usages: type: array items: @@ -33377,13 +34637,13 @@ paths: type: string kind: type: string - enum: &ref_303 + enum: &ref_308 - script - flow - job access_type: type: string - enum: &ref_302 + enum: &ref_307 - r - w - rw @@ -33393,7 +34653,7 @@ paths: description: The columns used (for tables) additionalProperties: type: string - enum: *ref_302 + enum: *ref_307 nullable: true created_at: type: string @@ -33466,7 +34726,7 @@ paths: type: string kind: type: string - enum: *ref_303 + enum: *ref_308 responses: '200': description: all assets used by the given usage paths, in the same order @@ -33486,10 +34746,10 @@ paths: type: string kind: type: string - enum: *ref_301 + enum: *ref_306 access_type: type: string - enum: *ref_302 + enum: *ref_307 nullable: true /w/{workspace}/assets/list_favorites: get: @@ -33537,13 +34797,13 @@ paths: type: array items: type: object - required: &ref_563 + required: &ref_564 - name - size_bytes - file_count - created_at - created_by - properties: &ref_564 + properties: &ref_565 name: type: string size_bytes: @@ -33658,13 +34918,13 @@ paths: type: array items: type: object - required: &ref_376 + required: &ref_381 - name - description - instructions - path - method - properties: &ref_377 + properties: &ref_382 name: type: string description: The tool name/operation ID @@ -33797,11 +35057,30 @@ components: in: cookie name: token parameters: + GetDraft: + name: get_draft + in: query + required: false + description: >- + When true, overlay the authed user's draft (if any) onto the deployed + payload. + schema: *ref_77 + IncludeDraftOnly: + name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: *ref_212 Id: name: id in: path required: true - schema: *ref_304 + schema: *ref_309 Key: name: key in: path @@ -33817,7 +35096,7 @@ components: in: path required: true description: The name of the publication - schema: *ref_264 + schema: *ref_269 VersionId: name: version in: path @@ -33828,7 +35107,7 @@ components: name: token in: path required: true - schema: *ref_305 + schema: *ref_310 AccountId: name: id in: path @@ -33843,17 +35122,17 @@ components: name: path in: path required: true - schema: *ref_92 + schema: *ref_97 ScriptHash: name: hash in: path required: true - schema: *ref_101 + schema: *ref_107 JobId: name: id in: path required: true - schema: *ref_172 + schema: *ref_176 Path: name: path in: path @@ -33863,22 +35142,22 @@ components: name: custom_path in: path required: true - schema: *ref_133 + schema: *ref_137 PathId: name: id in: path required: true - schema: *ref_79 + schema: *ref_84 PathVersion: name: version in: path required: true - schema: *ref_306 + schema: *ref_311 Name: name: name in: path required: true - schema: *ref_273 + schema: *ref_278 Page: name: page description: which page to return (start at 1, default 1) @@ -33897,12 +35176,12 @@ components: '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: *ref_188 + schema: *ref_192 OrderDesc: name: order_desc description: order by desc order (default true) in: query - schema: *ref_118 + schema: *ref_122 CreatedBy: name: created_by description: >- @@ -33910,7 +35189,7 @@ components: (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob') in: query - schema: *ref_119 + schema: *ref_123 Label: name: label description: >- @@ -33918,7 +35197,7 @@ components: 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_174 + schema: *ref_178 Worker: name: worker description: >- @@ -33926,26 +35205,26 @@ components: 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_156 + schema: *ref_160 ParentJob: name: parent_job description: >- The parent job that is at the origin and responsible for the execution of this script if any in: query - schema: *ref_110 + schema: *ref_114 WorkerTag: name: tag description: Override the tag to use in: query - schema: *ref_111 + schema: *ref_115 CacheTtl: name: cache_ttl description: >- Override the cache time to live (in seconds). Can not be used to disable caching, only override with a new cache ttl in: query - schema: *ref_112 + schema: *ref_116 NewJobId: name: job_id description: >- @@ -33953,7 +35232,7 @@ components: randomly using the ULID scheme. If a job id already exists in the queue or as a completed job, the request to create one will fail (Bad Request) in: query - schema: *ref_113 + schema: *ref_117 IncludeHeader: name: include_header description: > @@ -33963,19 +35242,19 @@ components: Header's key lowercased and '-'' replaced to '_' such that 'Content-Type' becomes the 'content_type' arg key in: query - schema: *ref_114 + schema: *ref_118 QueueLimit: name: queue_limit description: > The maximum size of the queue for which the request would get rejected if that job would push it above that limit in: query - schema: *ref_115 + schema: *ref_119 SkipPreprocessor: name: skip_preprocessor description: skip the preprocessor in: query - schema: *ref_116 + schema: *ref_120 Payload: name: payload description: > @@ -33984,7 +35263,7 @@ components: `encodeURIComponent(btoa(JSON.stringify({a: 2})))` in: query - schema: *ref_117 + schema: *ref_121 ScriptStartPath: name: script_path_start description: >- @@ -33992,12 +35271,12 @@ components: 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_158 + schema: *ref_162 SchedulePath: name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_159 + schema: *ref_163 TriggerPath: name: trigger_path description: >- @@ -34005,7 +35284,7 @@ components: 'f/trigger1,f/trigger2') and negation by prefixing all values with '!' (e.g. '!f/trigger1,!f/trigger2') in: query - schema: *ref_307 + schema: *ref_312 ScriptExactPath: name: script_path_exact description: >- @@ -34013,87 +35292,87 @@ components: (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_157 + schema: *ref_161 ScriptExactHash: name: script_hash description: mask to filter exact matching path in: query - schema: *ref_160 + schema: *ref_164 CreatedBefore: name: created_before description: filter on created before (inclusive) timestamp in: query - schema: *ref_182 + schema: *ref_186 CreatedAfter: name: created_after description: filter on created after (exclusive) timestamp in: query - schema: *ref_183 + schema: *ref_187 StartedBefore: name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_161 + schema: *ref_165 StartedAfter: name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_162 + schema: *ref_166 Before: name: before description: filter on started before (inclusive) timestamp in: query - schema: *ref_299 + schema: *ref_304 CompletedBefore: name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_184 + schema: *ref_188 CompletedAfter: name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_185 + schema: *ref_189 CreatedAfterQueue: name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: *ref_187 + schema: *ref_191 CreatedBeforeQueue: name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: *ref_186 + schema: *ref_190 Success: name: success description: filter on successful jobs in: query - schema: *ref_170 + schema: *ref_174 ScheduledForBeforeNow: name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_164 + schema: *ref_168 Suspended: name: suspended description: filter on suspended jobs in: query - schema: *ref_166 + schema: *ref_170 Running: name: running description: filter on running jobs in: query - schema: *ref_163 + schema: *ref_167 AllowWildcards: name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_171 + schema: *ref_175 ArgsFilter: name: args description: filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_167 + schema: *ref_171 Tag: name: tag description: >- @@ -34101,37 +35380,37 @@ components: 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_168 + schema: *ref_172 ResultFilter: name: result description: filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_169 + schema: *ref_173 After: name: after description: filter on created after (exclusive) timestamp in: query - schema: *ref_300 + schema: *ref_305 Username: name: username description: filter on exact username of user in: query - schema: *ref_308 + schema: *ref_313 Operation: name: operation description: filter on exact or prefix name of operation in: query - schema: *ref_309 + schema: *ref_314 ResourceName: name: resource description: filter on exact or prefix name of resource in: query - schema: *ref_310 + schema: *ref_315 ActionKind: name: action_kind description: filter on type of operation in: query - schema: *ref_311 + schema: *ref_316 JobKinds: name: job_kinds description: >- @@ -34139,53 +35418,76 @@ components: 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_165 + schema: *ref_169 RunnableId: name: runnable_id in: query - schema: *ref_286 + schema: *ref_291 RunnableTypeQuery: name: runnable_type in: query - schema: *ref_287 + schema: *ref_292 InputId: name: input in: path required: true - schema: *ref_312 + schema: *ref_317 GetStarted: name: get_started in: query - schema: *ref_313 + schema: *ref_318 ConcurrencyId: name: concurrency_id in: path required: true - schema: *ref_314 + schema: *ref_319 RunnableKind: name: runnable_kind in: path required: true - schema: *ref_132 + schema: *ref_136 schemas: + UserDraftOverlay: + type: object + description: | + Overlay fields added to every "get by path" response that accepts + the `get_draft` query parameter. The deployed payload is sent + untouched in the response body; the authed user's saved draft + for this path — whatever shape the editor wrote — is attached + as the sibling `draft` field when `get_draft=true` and a draft + exists. The frontend pairs the two to present diff / reset / + discard UI; the server never merges them. + + When `no_deployed=true` there is no deployed row at this path — + the response body is a best-effort stand-in synthesized from + the draft, and only `draft` is canonical. Callers should disable + "diff vs deployed" UI in that case. + properties: *ref_78 + required: *ref_79 + UserDraftItemKind: + type: string + description: | + Closed set of item kinds a user can autosave as a draft. Mirrors the + Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`. + enum: *ref_100 OpenFlow: type: object description: >- Top-level flow definition containing metadata, configuration, and the flow structure - properties: *ref_120 - required: *ref_121 + properties: *ref_124 + required: *ref_125 FlowValue: type: object description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: *ref_149 - required: *ref_150 + properties: *ref_153 + required: *ref_154 Retry: type: object description: Retry configuration for failed module executions - properties: *ref_197 + properties: *ref_201 StopAfterIf: type: object description: Early termination condition for a module @@ -34206,6 +35508,13 @@ components: with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised. + error_include_result: + type: boolean + description: >- + When stopping with an error (error_message set), embed the stopping + step's own result inside the raised error object (as error.result) + instead of discarding it. The top-level result stays { error }. + Defaults to false. required: - expr FlowModule: @@ -34222,18 +35531,18 @@ components: description: >- The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type - oneOf: *ref_88 - discriminator: *ref_89 + oneOf: *ref_93 + discriminator: *ref_94 stop_after_if: description: Early termination condition for a module type: object - properties: *ref_90 - required: *ref_91 + properties: *ref_95 + required: *ref_96 stop_after_all_iters_if: description: Early termination condition for a module type: object - properties: *ref_90 - required: *ref_91 + properties: *ref_95 + required: *ref_96 skip_if: type: object description: >- @@ -34252,8 +35561,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 cache_ttl: type: number description: Cache duration in seconds for this step's results @@ -34264,8 +35573,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 delete_after_secs: type: integer description: >- @@ -34308,8 +35617,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 self_approval_disabled: type: boolean description: If true, the user who started the flow cannot approve @@ -34328,7 +35637,7 @@ components: retry: description: Retry configuration for failed module executions type: object - properties: *ref_315 + properties: *ref_320 debouncing: description: Debounce configuration for this step (EE only) type: object @@ -34361,8 +35670,8 @@ components: description: >- Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_138 - discriminator: *ref_139 + oneOf: *ref_142 + discriminator: *ref_143 StaticTransform: type: object description: >- @@ -34419,7 +35728,7 @@ components: kind: type: string description: Supported AI provider types - enum: *ref_316 + enum: *ref_321 resource: type: string description: >- @@ -34439,16 +35748,16 @@ components: oneOf: - type: object description: No conversation memory/context - properties: *ref_317 - required: *ref_318 + properties: *ref_322 + required: *ref_323 - type: object description: Automatic context management - properties: *ref_319 - required: *ref_320 + properties: *ref_324 + required: *ref_325 - type: object description: Explicit message history - properties: *ref_321 - required: *ref_322 + properties: *ref_326 + required: *ref_327 discriminator: propertyName: kind mapping: @@ -34465,62 +35774,62 @@ components: Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms - properties: *ref_323 - required: *ref_324 + properties: *ref_328 + required: *ref_329 - type: object description: >- Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code - properties: *ref_325 - required: *ref_326 + properties: *ref_330 + required: *ref_331 - type: object description: >- Reference to an existing flow by path. Use this to call another flow as a subflow - properties: *ref_327 - required: *ref_328 + properties: *ref_332 + required: *ref_333 - type: object description: >- Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations - properties: *ref_329 - required: *ref_330 + properties: *ref_334 + required: *ref_335 - type: object description: >- Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination - properties: *ref_331 - required: *ref_332 + properties: *ref_336 + required: *ref_337 - type: object description: >- Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes - properties: *ref_333 - required: *ref_334 + properties: *ref_338 + required: *ref_339 - type: object description: >- Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently - properties: *ref_335 - required: *ref_336 + properties: *ref_340 + required: *ref_341 - type: object description: >- Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder - properties: *ref_337 - required: *ref_338 + properties: *ref_342 + required: *ref_343 - type: object description: >- AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task - properties: *ref_339 - required: *ref_340 + properties: *ref_344 + required: *ref_345 discriminator: propertyName: type mapping: @@ -34550,8 +35859,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 content: type: string description: The script source code. Should export a 'main' function @@ -34667,8 +35976,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 path: type: string description: Path to the script in the workspace (e.g., 'f/scripts/send_email') @@ -34705,8 +36014,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 path: type: string description: Path to the flow in the workspace (e.g., 'f/flows/process_user') @@ -34734,15 +36043,15 @@ components: items: type: object description: A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 iterator: description: >- Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 skip_failures: type: boolean description: >- @@ -34762,8 +36071,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 squash: type: boolean required: @@ -34786,8 +36095,8 @@ components: items: type: object description: A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 skip_failures: type: boolean description: >- @@ -34807,8 +36116,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 squash: type: boolean required: @@ -34846,8 +36155,8 @@ components: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 required: - modules - expr @@ -34857,8 +36166,8 @@ components: items: type: object description: A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 type: type: string enum: @@ -34896,8 +36205,8 @@ components: description: >- A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 required: - modules type: @@ -34926,16 +36235,16 @@ components: description: >- Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined - oneOf: *ref_341 - discriminator: *ref_342 + oneOf: *ref_346 + discriminator: *ref_347 output_type: allOf: - description: >- Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Output format type. @@ -34947,8 +36256,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: >- The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax. @@ -34958,8 +36267,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: >- System instructions that guide the AI's behavior, persona, and response style. Optional. @@ -34969,8 +36278,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Boolean. If true, stream the AI response incrementally. @@ -34980,16 +36289,16 @@ components: description: >- Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined - oneOf: *ref_343 - discriminator: *ref_344 + oneOf: *ref_348 + discriminator: *ref_349 output_schema: allOf: - description: >- Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape. @@ -35006,8 +36315,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Array of file references (images or PDFs) for the AI agent. @@ -35021,8 +36330,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Integer. Maximum number of tokens the AI will generate in its response. @@ -35035,8 +36344,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: | Float. Controls randomness/creativity of responses. Range: 0.0 to 2.0 (provider-dependent) @@ -35049,8 +36358,8 @@ components: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 description: > Number. Limits how many times the agent can loop through reasoning and tool use. @@ -35070,12 +36379,17 @@ components: description: >- A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool - properties: *ref_345 - required: *ref_346 + properties: *ref_350 + required: *ref_351 type: type: string enum: - aiagent + tag: + type: string + description: >- + Worker group tag for execution routing. If not set, the AI agent + step runs on the flow's tag (default `flow`) omit_output_from_conversation: type: boolean default: false @@ -35106,8 +36420,8 @@ components: - type FlowStatus: type: object - properties: *ref_175 - required: *ref_176 + properties: *ref_179 + required: *ref_180 FlowStatusModule: type: object properties: @@ -35329,8 +36643,8 @@ components: - type CiTestResult: type: object - properties: *ref_108 - required: *ref_109 + properties: *ref_112 + required: *ref_113 HealthStatusResponse: type: object description: Health status response (cached with 5s TTL) @@ -35344,75 +36658,75 @@ components: HealthChecks: type: object description: Detailed health checks - required: *ref_347 - properties: *ref_348 + required: *ref_352 + properties: *ref_353 DatabaseHealth: type: object description: Database health status - required: *ref_349 - properties: *ref_350 + required: *ref_354 + properties: *ref_355 PoolStats: type: object description: Database connection pool statistics - required: *ref_351 - properties: *ref_352 + required: *ref_356 + properties: *ref_357 WorkersHealth: type: object description: Workers health status - required: *ref_353 - properties: *ref_354 + required: *ref_358 + properties: *ref_359 QueueHealth: type: object description: Job queue status - required: *ref_355 - properties: *ref_356 + required: *ref_360 + properties: *ref_361 ReadinessHealth: type: object description: Server readiness status - required: *ref_357 - properties: *ref_358 + required: *ref_362 + properties: *ref_363 AutoInviteConfig: type: object description: Configuration for auto-inviting users to the workspace - properties: *ref_359 + properties: *ref_364 ErrorHandlerConfig: type: object description: Configuration for the workspace error handler - properties: *ref_360 + properties: *ref_365 SuccessHandlerConfig: type: object description: Configuration for the workspace success handler - properties: *ref_361 + properties: *ref_366 EditErrorHandler: description: >- Request body for editing the workspace error handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: *ref_362 + oneOf: *ref_367 EditErrorHandlerNew: type: object description: New grouped format for editing error handler - properties: *ref_363 + properties: *ref_368 EditErrorHandlerLegacy: type: object description: >- Legacy flat format for editing error handler (deprecated, use new format) - properties: *ref_364 + properties: *ref_369 EditSuccessHandler: description: >- Request body for editing the workspace success handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: *ref_365 + oneOf: *ref_370 EditSuccessHandlerNew: type: object description: New grouped format for editing success handler - properties: *ref_366 + properties: *ref_371 EditSuccessHandlerLegacy: type: object description: >- Legacy flat format for editing success handler (deprecated, use new format) - properties: *ref_367 + properties: *ref_372 VaultSettings: type: object required: *ref_27 @@ -35427,28 +36741,28 @@ components: properties: *ref_34 SecretMigrationFailure: type: object - required: *ref_368 - properties: *ref_369 + required: *ref_373 + properties: *ref_374 SecretMigrationReport: type: object required: *ref_29 properties: *ref_30 JwksResponse: type: object - required: *ref_370 - properties: *ref_371 + required: *ref_375 + properties: *ref_376 FlowConversation: type: object - required: *ref_372 - properties: *ref_373 + required: *ref_377 + properties: *ref_378 FlowConversationMessage: type: object - required: *ref_374 - properties: *ref_375 + required: *ref_379 + properties: *ref_380 EndpointTool: type: object - required: *ref_376 - properties: *ref_377 + required: *ref_381 + properties: *ref_382 AIProvider: type: string enum: *ref_51 @@ -35461,114 +36775,112 @@ components: required: *ref_44 AIProviderConfig: type: object - properties: *ref_378 - required: *ref_379 + properties: *ref_383 + required: *ref_384 AIConfig: type: object properties: *ref_50 InstanceAIProviderSummary: type: object - properties: *ref_380 - required: *ref_381 + properties: *ref_385 + required: *ref_386 InstanceAISummary: type: object properties: *ref_52 required: *ref_53 Alert: type: object - properties: *ref_382 - required: *ref_383 + properties: *ref_387 + required: *ref_388 Configs: type: object nullable: true - properties: *ref_384 + properties: *ref_389 WorkspaceDependencies: type: object - properties: *ref_97 - required: *ref_98 + properties: *ref_103 + required: *ref_104 NewWorkspaceDependencies: type: object - properties: *ref_385 - required: *ref_386 + properties: *ref_390 + required: *ref_391 Script: type: object - properties: *ref_99 - required: *ref_100 + properties: *ref_105 + required: *ref_106 NewScript: type: object - properties: *ref_104 - required: *ref_105 - NewScriptWithDraft: - allOf: *ref_387 + properties: *ref_392 + required: *ref_393 ScriptHistory: type: object - properties: *ref_106 - required: *ref_107 + properties: *ref_110 + required: *ref_111 ScriptArgs: type: object description: The arguments to pass to the script or flow additionalProperties: true Input: type: object - properties: *ref_288 - required: *ref_289 + properties: *ref_293 + required: *ref_294 CreateInput: type: object - properties: *ref_388 - required: *ref_389 + properties: *ref_394 + required: *ref_395 UpdateInput: type: object - properties: *ref_390 - required: *ref_391 + properties: *ref_396 + required: *ref_397 RunnableType: type: string - enum: *ref_392 + enum: *ref_398 QueuedJob: type: object - properties: *ref_191 - required: *ref_192 + properties: *ref_195 + required: *ref_196 CompletedJob: type: object - properties: *ref_189 - required: *ref_190 + properties: *ref_193 + required: *ref_194 ExportableCompletedJob: type: object description: Completed job with full data for export/import operations - properties: *ref_178 - required: *ref_179 + properties: *ref_182 + required: *ref_183 ExportableQueuedJob: type: object description: Queued job with full data for export/import operations - properties: *ref_180 - required: *ref_181 + properties: *ref_184 + required: *ref_185 ObscuredJob: type: object - properties: *ref_393 + properties: *ref_399 Job: - oneOf: *ref_193 - discriminator: *ref_194 + oneOf: *ref_197 + discriminator: *ref_198 User: type: object properties: *ref_35 required: *ref_36 UserSource: type: object - properties: *ref_394 - required: *ref_395 + properties: *ref_400 + required: *ref_401 UserUsage: type: object - properties: *ref_396 + properties: *ref_402 Login: type: object - properties: *ref_397 - required: *ref_398 + properties: *ref_403 + required: *ref_404 PasswordResetResponse: type: object properties: *ref_7 required: *ref_8 EditWorkspaceUser: type: object - properties: *ref_399 + properties: *ref_405 OffboardAffectedPaths: type: object properties: *ref_11 @@ -35578,64 +36890,64 @@ components: required: *ref_13 OffboardTokenInfo: type: object - properties: *ref_400 - required: *ref_401 + properties: *ref_406 + required: *ref_407 OffboardRequest: type: object - properties: *ref_402 - required: *ref_403 + properties: *ref_408 + required: *ref_409 OffboardResponse: type: object properties: *ref_14 OffboardSummary: type: object - properties: *ref_404 - required: *ref_405 + properties: *ref_410 + required: *ref_411 GlobalOffboardPreview: type: object - properties: *ref_406 - required: *ref_407 + properties: *ref_412 + required: *ref_413 WorkspaceOffboardPreview: type: object - properties: *ref_408 - required: *ref_409 + properties: *ref_414 + required: *ref_415 GlobalOffboardRequest: type: object - properties: *ref_410 + properties: *ref_416 WorkspaceReassignment: type: object - properties: *ref_411 - required: *ref_412 + properties: *ref_417 + required: *ref_418 TruncatedToken: type: object - properties: *ref_102 - required: *ref_103 + properties: *ref_108 + required: *ref_109 ExternalJwtToken: type: object - properties: *ref_413 - required: *ref_414 + properties: *ref_419 + required: *ref_420 NewToken: type: object - properties: *ref_415 + properties: *ref_421 NewTokenImpersonate: type: object - properties: *ref_416 - required: *ref_417 + properties: *ref_422 + required: *ref_423 ListableVariable: type: object properties: *ref_61 required: *ref_62 ContextualVariable: type: object - properties: *ref_418 - required: *ref_419 + properties: *ref_424 + required: *ref_425 CreateVariable: type: object - properties: *ref_420 - required: *ref_421 + properties: *ref_426 + required: *ref_427 EditVariable: type: object - properties: *ref_422 + properties: *ref_428 AuditLog: type: object properties: *ref_5 @@ -35772,63 +37084,99 @@ components: - has_preprocessor ScriptLang: type: string - enum: *ref_94 + enum: *ref_99 ScriptModule: type: object description: An additional module file associated with a script - properties: *ref_95 - required: *ref_96 + properties: *ref_101 + required: *ref_102 Preview: type: object - properties: *ref_141 - required: *ref_142 + properties: *ref_145 + required: *ref_146 PreviewInline: type: object - properties: *ref_423 - required: *ref_424 + properties: *ref_429 + required: *ref_430 InlineScriptArgs: type: object - properties: *ref_140 + properties: *ref_144 WorkflowTask: type: object - properties: *ref_425 - required: *ref_426 + properties: *ref_431 + required: *ref_432 WorkflowStatusRecord: type: object additionalProperties: type: object - properties: *ref_177 + properties: *ref_181 WorkflowStatus: type: object - properties: *ref_177 + properties: *ref_181 CreateResource: type: object - properties: *ref_427 - required: *ref_428 + properties: *ref_433 + required: *ref_434 EditResource: type: object - properties: *ref_429 + properties: *ref_435 Resource: type: object - properties: *ref_430 - required: *ref_431 + properties: + workspace_id: + type: string + path: + type: string + description: + type: string + resource_type: + type: string + value: {} + is_oauth: + type: boolean + extra_perms: + type: object + additionalProperties: + type: boolean + created_by: + type: string + edited_at: + type: string + format: date-time + labels: + type: array + items: + type: string + inherited_labels: + type: array + items: + type: string + description: > + Labels inherited from the parent folder, computed at read time. + Read-only — edit them on the folder. + ws_specific: + type: boolean + required: + - path + - resource_type + - is_oauth ListableResource: type: object - properties: *ref_432 - required: *ref_433 + properties: *ref_80 + required: *ref_81 ResourceType: type: object - properties: *ref_77 - required: *ref_78 + properties: *ref_82 + required: *ref_83 EditResourceType: type: object - properties: *ref_434 + properties: *ref_436 Schedule: type: object - properties: *ref_198 - required: *ref_199 + properties: *ref_202 + required: *ref_203 ScheduleWJobs: - allOf: *ref_435 + allOf: *ref_437 ErrorHandler: type: string enum: @@ -35837,122 +37185,122 @@ components: - teams - email NewSchedule: - type: object - properties: *ref_436 - required: *ref_437 - EditSchedule: type: object properties: *ref_438 required: *ref_439 + EditSchedule: + type: object + properties: *ref_440 + required: *ref_441 JobTriggerKind: description: job trigger kind (schedule, http, websocket...) type: string - enum: *ref_173 + enum: *ref_177 TriggerMode: description: job trigger mode type: string - enum: *ref_207 + enum: *ref_211 TriggerExtraProperty: type: object - properties: *ref_214 - required: *ref_215 + properties: *ref_219 + required: *ref_220 AuthenticationMethod: type: string - enum: *ref_206 + enum: *ref_210 RunnableKind: type: string - enum: *ref_200 + enum: *ref_204 OpenapiSpecFormat: type: string - enum: *ref_440 + enum: *ref_442 OpenapiHttpRouteFilters: - type: object - properties: *ref_441 - required: *ref_442 - WebhookFilters: type: object properties: *ref_443 required: *ref_444 - OpenapiV3Info: + WebhookFilters: type: object properties: *ref_445 required: *ref_446 - GenerateOpenapiSpec: - type: object - properties: *ref_201 - HttpMethod: - type: string - enum: *ref_204 - HttpRequestType: - type: string - enum: *ref_205 - HttpTrigger: - allOf: *ref_208 - type: object - properties: *ref_209 - required: *ref_210 - NewHttpTrigger: - type: object - properties: *ref_202 - required: *ref_203 - EditHttpTrigger: + OpenapiV3Info: type: object properties: *ref_447 required: *ref_448 - TriggersCount: + GenerateOpenapiSpec: type: object - properties: *ref_125 - WebsocketHeartbeat: + properties: *ref_205 + HttpMethod: + type: string + enum: *ref_208 + HttpRequestType: + type: string + enum: *ref_209 + HttpTrigger: + allOf: *ref_213 type: object - properties: *ref_212 - required: *ref_213 - WebsocketTrigger: - allOf: *ref_216 + properties: *ref_214 + required: *ref_215 + NewHttpTrigger: type: object - properties: *ref_217 - required: *ref_218 - NewWebsocketTrigger: + properties: *ref_206 + required: *ref_207 + EditHttpTrigger: type: object properties: *ref_449 required: *ref_450 - EditWebsocketTrigger: + TriggersCount: + type: object + properties: *ref_129 + WebsocketHeartbeat: + type: object + properties: *ref_217 + required: *ref_218 + WebsocketTrigger: + allOf: *ref_221 + type: object + properties: *ref_222 + required: *ref_223 + NewWebsocketTrigger: type: object properties: *ref_451 required: *ref_452 + EditWebsocketTrigger: + type: object + properties: *ref_453 + required: *ref_454 WebsocketTriggerInitialMessage: - anyOf: *ref_211 + anyOf: *ref_216 MqttQoS: type: string - enum: *ref_453 + enum: *ref_455 MqttV3Config: type: object - properties: *ref_238 + properties: *ref_243 MqttV5Config: type: object - properties: *ref_239 + properties: *ref_244 MqttSubscribeTopic: type: object - properties: *ref_236 - required: *ref_237 + properties: *ref_241 + required: *ref_242 MqttClientVersion: type: string - enum: *ref_240 + enum: *ref_245 MqttTrigger: - allOf: *ref_241 + allOf: *ref_246 type: object - properties: *ref_242 - required: *ref_243 + properties: *ref_247 + required: *ref_248 NewMqttTrigger: - type: object - properties: *ref_454 - required: *ref_455 - EditMqttTrigger: type: object properties: *ref_456 required: *ref_457 + EditMqttTrigger: + type: object + properties: *ref_458 + required: *ref_459 DeliveryType: type: string - enum: *ref_246 + enum: *ref_251 description: >- Delivery mode for messages. 'push' for HTTP push delivery where messages are sent to a webhook endpoint, 'pull' for polling where the trigger @@ -35960,19 +37308,19 @@ components: PushConfig: type: object description: Configuration for push delivery mode. - properties: *ref_247 - required: *ref_248 + properties: *ref_252 + required: *ref_253 GcpTrigger: - allOf: *ref_250 + allOf: *ref_255 type: object description: >- A Google Cloud Pub/Sub trigger that executes a script or flow when messages are received. - properties: *ref_251 - required: *ref_252 + properties: *ref_256 + required: *ref_257 SubscriptionMode: type: string - enum: *ref_249 + enum: *ref_254 description: >- The mode of subscription. 'existing' means using an existing GCP subscription, while 'create_update' involves creating or updating a new @@ -35980,68 +37328,68 @@ components: GcpTriggerData: type: object description: Data for creating or updating a Google Cloud Pub/Sub trigger. - properties: *ref_244 - required: *ref_245 + properties: *ref_249 + required: *ref_250 GetAllTopicSubscription: - type: object - properties: *ref_458 - required: *ref_459 - DeleteGcpSubscription: type: object properties: *ref_460 required: *ref_461 + DeleteGcpSubscription: + type: object + properties: *ref_462 + required: *ref_463 AzureMode: type: string - enum: *ref_255 + enum: *ref_260 description: Azure Event Grid trigger mode. AzureArmResource: type: object description: An ARM resource the service principal can see. - properties: *ref_259 - required: *ref_260 + properties: *ref_264 + required: *ref_265 AzureDeleteSubscription: type: object - properties: *ref_462 - required: *ref_463 + properties: *ref_464 + required: *ref_465 AzureTrigger: - allOf: *ref_256 + allOf: *ref_261 type: object description: >- An Azure Event Grid trigger that executes a script or flow when events arrive. - properties: *ref_257 - required: *ref_258 + properties: *ref_262 + required: *ref_263 AzureTriggerData: type: object description: Data for creating or updating an Azure Event Grid trigger. - properties: *ref_253 - required: *ref_254 + properties: *ref_258 + required: *ref_259 TestAzureConnection: - type: object - properties: *ref_464 - required: *ref_465 - AzureListTopics: type: object properties: *ref_466 required: *ref_467 - AzureListSubscriptions: + AzureListTopics: type: object properties: *ref_468 required: *ref_469 + AzureListSubscriptions: + type: object + properties: *ref_470 + required: *ref_471 AwsAuthResourceType: type: string - enum: *ref_225 + enum: *ref_230 SqsTrigger: - allOf: *ref_226 + allOf: *ref_231 type: object - properties: *ref_227 - required: *ref_228 + properties: *ref_232 + required: *ref_233 LoggedWizardStatus: type: string enum: *ref_21 CustomInstanceDbLogs: type: object - properties: *ref_470 + properties: *ref_472 CustomInstanceDbTag: type: string enum: *ref_22 @@ -36050,109 +37398,109 @@ components: required: *ref_23 properties: *ref_24 NewSqsTrigger: - type: object - properties: *ref_471 - required: *ref_472 - EditSqsTrigger: type: object properties: *ref_473 required: *ref_474 - Slot: - type: object - properties: *ref_261 - SlotList: + EditSqsTrigger: type: object properties: *ref_475 + required: *ref_476 + Slot: + type: object + properties: *ref_266 + SlotList: + type: object + properties: *ref_477 PublicationData: type: object - properties: *ref_265 - required: *ref_266 + properties: *ref_270 + required: *ref_271 TableToTrack: type: array - items: *ref_476 + items: *ref_478 Relations: type: object - properties: *ref_262 - required: *ref_263 + properties: *ref_267 + required: *ref_268 Language: type: string - enum: *ref_477 + enum: *ref_479 TemplateScript: - type: object - properties: *ref_478 - required: *ref_479 - PostgresTrigger: - allOf: *ref_267 - type: object - properties: *ref_268 - required: *ref_269 - NewPostgresTrigger: type: object properties: *ref_480 required: *ref_481 - EditPostgresTrigger: + PostgresTrigger: + allOf: *ref_272 + type: object + properties: *ref_273 + required: *ref_274 + NewPostgresTrigger: type: object properties: *ref_482 required: *ref_483 - KafkaTrigger: - allOf: *ref_219 - type: object - properties: *ref_220 - required: *ref_221 - NewKafkaTrigger: + EditPostgresTrigger: type: object properties: *ref_484 required: *ref_485 - EditKafkaTrigger: + KafkaTrigger: + allOf: *ref_224 + type: object + properties: *ref_225 + required: *ref_226 + NewKafkaTrigger: type: object properties: *ref_486 required: *ref_487 - NatsTrigger: - allOf: *ref_222 - type: object - properties: *ref_223 - required: *ref_224 - NewNatsTrigger: + EditKafkaTrigger: type: object properties: *ref_488 required: *ref_489 - EditNatsTrigger: + NatsTrigger: + allOf: *ref_227 + type: object + properties: *ref_228 + required: *ref_229 + NewNatsTrigger: type: object properties: *ref_490 required: *ref_491 - EmailTrigger: - allOf: *ref_270 - type: object - properties: *ref_271 - required: *ref_272 - NewEmailTrigger: + EditNatsTrigger: type: object properties: *ref_492 required: *ref_493 - EditEmailTrigger: + EmailTrigger: + allOf: *ref_275 + type: object + properties: *ref_276 + required: *ref_277 + NewEmailTrigger: type: object properties: *ref_494 required: *ref_495 + EditEmailTrigger: + type: object + properties: *ref_496 + required: *ref_497 Group: type: object - properties: *ref_278 - required: *ref_279 + properties: *ref_283 + required: *ref_284 InstanceGroup: type: object - required: *ref_496 - properties: *ref_497 + required: *ref_498 + properties: *ref_499 InstanceGroupWithWorkspaces: type: object - required: *ref_274 - properties: *ref_275 + required: *ref_279 + properties: *ref_280 WorkspaceInfo: type: object - properties: *ref_498 - required: *ref_499 + properties: *ref_500 + required: *ref_501 Folder: type: object - properties: *ref_281 - required: *ref_282 + properties: *ref_286 + required: *ref_287 FolderDefaultPermissionedAs: description: > Ordered list of rules applied at create-time when admins or @@ -36160,19 +37508,19 @@ components: `path_glob` matches the item path (relative to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: *ref_280 + items: *ref_285 WorkerPing: - type: object - properties: *ref_500 - required: *ref_501 - UserWorkspaceList: type: object properties: *ref_502 required: *ref_503 - CreateWorkspace: + UserWorkspaceList: type: object properties: *ref_504 required: *ref_505 + CreateWorkspace: + type: object + properties: *ref_506 + required: *ref_507 CreateWorkspaceFork: type: object properties: *ref_19 @@ -36183,15 +37531,15 @@ components: required: *ref_16 DependencyMap: type: object - properties: *ref_506 + properties: *ref_508 DependencyDependent: - type: object - properties: *ref_507 - required: *ref_508 - DependentsAmount: type: object properties: *ref_509 required: *ref_510 + DependentsAmount: + type: object + properties: *ref_511 + required: *ref_512 WorkspaceInvite: type: object properties: *ref_41 @@ -36201,56 +37549,54 @@ components: properties: *ref_39 required: *ref_40 Flow: - allOf: *ref_124 + allOf: *ref_128 ExtraPerms: type: object - additionalProperties: *ref_511 + additionalProperties: *ref_513 FlowMetadata: - type: object - properties: *ref_512 - required: *ref_513 - OpenFlowWPath: - allOf: *ref_126 - FlowPreview: - type: object - properties: *ref_152 - required: *ref_153 - RestartedFrom: - type: object - properties: *ref_151 - Policy: - type: object - properties: *ref_127 - ListableApp: type: object properties: *ref_514 required: *ref_515 - ScopeDefinition: + OpenFlowWPath: + allOf: *ref_130 + FlowPreview: + type: object + properties: *ref_156 + required: *ref_157 + RestartedFrom: + type: object + properties: *ref_155 + Policy: + type: object + properties: *ref_131 + ListableApp: type: object properties: *ref_516 required: *ref_517 - ScopeDomain: + ScopeDefinition: type: object properties: *ref_518 required: *ref_519 - ListableRawApp: + ScopeDomain: type: object properties: *ref_520 required: *ref_521 + ListableRawApp: + type: object + properties: *ref_522 + required: *ref_523 AppWithLastVersion: type: object - properties: *ref_128 - required: *ref_129 - AppWithLastVersionWDraft: - allOf: *ref_522 + properties: *ref_132 + required: *ref_133 AppHistory: type: object - properties: *ref_130 - required: *ref_131 + properties: *ref_134 + required: *ref_135 FlowVersion: type: object - properties: *ref_122 - required: *ref_123 + properties: *ref_126 + required: *ref_127 SlackToken: type: object properties: @@ -36274,11 +37620,11 @@ components: required: *ref_75 HubScriptKind: type: string - enum: *ref_93 + enum: *ref_98 PolarsClientKwargs: type: object - properties: *ref_292 - required: *ref_293 + properties: *ref_297 + required: *ref_298 LargeFileStorage: type: object properties: *ref_45 @@ -36292,35 +37638,35 @@ components: properties: *ref_47 DataTableSchema: type: object - required: *ref_523 - properties: *ref_524 + required: *ref_524 + properties: *ref_525 DataTableTables: type: object - required: *ref_525 - properties: *ref_526 + required: *ref_526 + properties: *ref_527 DataTableTableSchema: type: object - required: *ref_527 - properties: *ref_528 + required: *ref_528 + properties: *ref_529 DynamicInputData: type: object - properties: *ref_529 - required: *ref_530 + properties: *ref_530 + required: *ref_531 WindmillLargeFile: type: object - properties: *ref_294 - required: *ref_295 + properties: *ref_299 + required: *ref_300 WindmillFileMetadata: type: object - properties: *ref_298 + properties: *ref_303 WindmillFilePreview: type: object - properties: *ref_296 - required: *ref_297 + properties: *ref_301 + required: *ref_302 S3Resource: type: object - properties: *ref_290 - required: *ref_291 + properties: *ref_295 + required: *ref_296 WorkspaceGitSyncSettings: type: object properties: *ref_56 @@ -36332,48 +37678,48 @@ components: properties: *ref_59 S3PermissionRule: type: object - properties: *ref_531 - required: *ref_532 + properties: *ref_532 + required: *ref_533 GitRepositorySettings: type: object properties: *ref_57 required: *ref_58 MetricMetadata: type: object - properties: *ref_533 - required: *ref_534 + properties: *ref_534 + required: *ref_535 ScalarMetric: type: object - properties: *ref_535 - required: *ref_536 + properties: *ref_536 + required: *ref_537 TimeseriesMetric: type: object - properties: *ref_537 - required: *ref_538 + properties: *ref_538 + required: *ref_539 MetricDataPoint: type: object - properties: *ref_539 - required: *ref_540 + properties: *ref_540 + required: *ref_541 RawScriptForDependencies: type: object - properties: *ref_143 - required: *ref_144 + properties: *ref_147 + required: *ref_148 ConcurrencyGroup: type: object - properties: *ref_541 - required: *ref_542 + properties: *ref_542 + required: *ref_543 ExtendedJobs: type: object - properties: *ref_543 - required: *ref_544 + properties: *ref_544 + required: *ref_545 ExportedUser: type: object properties: *ref_9 required: *ref_10 GlobalSetting: type: object - properties: *ref_545 - required: *ref_546 + properties: *ref_546 + required: *ref_547 InstanceConfig: type: object description: >- @@ -36382,35 +37728,35 @@ components: properties: *ref_26 Config: type: object - properties: *ref_547 - required: *ref_548 + properties: *ref_548 + required: *ref_549 ExportedInstanceGroup: type: object - properties: *ref_276 - required: *ref_277 + properties: *ref_281 + required: *ref_282 JobSearchHit: type: object - properties: *ref_549 + properties: *ref_550 LogSearchHit: type: object - properties: *ref_550 + properties: *ref_551 AutoscalingEvent: type: object - properties: *ref_551 + properties: *ref_552 CriticalAlert: type: object properties: *ref_63 CaptureTriggerKind: type: string - enum: *ref_283 + enum: *ref_288 Capture: type: object - properties: *ref_284 - required: *ref_285 + properties: *ref_289 + required: *ref_290 CaptureConfig: type: object - properties: *ref_552 - required: *ref_553 + properties: *ref_553 + required: *ref_554 OperatorSettings: nullable: true type: object @@ -36418,16 +37764,16 @@ components: properties: *ref_38 WorkspaceComparison: type: object - required: *ref_554 - properties: *ref_555 + required: *ref_555 + properties: *ref_556 WorkspaceItemDiff: type: object - required: *ref_556 - properties: *ref_557 + required: *ref_557 + properties: *ref_558 CompareSummary: type: object - required: *ref_558 - properties: *ref_559 + required: *ref_559 + properties: *ref_560 TeamInfo: type: object required: @@ -36448,12 +37794,12 @@ components: description: List of channels within the team items: type: object - required: &ref_560 + required: &ref_561 - channel_id - channel_name - tenant_id - service_url - properties: &ref_561 + properties: &ref_562 channel_id: type: string description: The unique identifier of the channel @@ -36473,11 +37819,11 @@ components: https://smba.trafficmanager.net/amer/12345678-1234-1234-1234-123456789012/ ChannelInfo: type: object - required: *ref_560 - properties: *ref_561 + required: *ref_561 + properties: *ref_562 GithubInstallations: type: array - items: *ref_562 + items: *ref_563 WorkspaceGithubInstallation: type: object properties: @@ -36490,8 +37836,8 @@ components: - installation_id S3Object: type: object - properties: *ref_134 - required: *ref_135 + properties: *ref_138 + required: *ref_139 TeamsChannel: type: object required: @@ -36518,14 +37864,14 @@ components: minLength: 1 AssetUsageKind: type: string - enum: *ref_303 + enum: *ref_308 AssetUsageAccessType: type: string - enum: *ref_302 + enum: *ref_307 nullable: true AssetKind: type: string - enum: *ref_301 + enum: *ref_306 Asset: type: object properties: @@ -36533,26 +37879,26 @@ components: type: string kind: type: string - enum: *ref_301 + enum: *ref_306 required: - path - kind Volume: type: object - required: *ref_563 - properties: *ref_564 + required: *ref_564 + properties: *ref_565 ProtectionRuleset: type: object description: A workspace protection rule defining restrictions and bypass permissions - required: *ref_565 - properties: *ref_566 + required: *ref_566 + properties: *ref_567 ProtectionRules: type: array description: Configuration of protection restrictions items: *ref_64 ProtectionRuleKind: type: string - enum: *ref_567 + enum: *ref_568 RuleBypasserGroups: type: array description: Groups that can bypass this ruleset @@ -36563,12 +37909,12 @@ components: items: *ref_66 DeploymentRequestEligibleDeployer: type: object - required: *ref_568 - properties: *ref_569 + required: *ref_569 + properties: *ref_570 DeploymentRequestAssignee: type: object - required: *ref_570 - properties: *ref_571 + required: *ref_571 + properties: *ref_572 DeploymentRequestComment: type: object required: *ref_69 @@ -36583,27 +37929,27 @@ components: required: *ref_72 NativeServiceName: type: string - enum: *ref_229 + enum: *ref_234 NativeTrigger: type: object description: A native trigger stored in Windmill - properties: *ref_572 - required: *ref_573 + properties: *ref_573 + required: *ref_574 NativeTriggerWithExternal: type: object description: >- Full trigger response containing both Windmill data and external service data - properties: *ref_574 - required: *ref_575 + properties: *ref_575 + required: *ref_576 WorkspaceIntegrations: type: object - properties: *ref_576 - required: *ref_577 + properties: *ref_577 + required: *ref_578 WorkspaceOAuthConfig: type: object - properties: *ref_230 - required: *ref_231 + properties: *ref_235 + required: *ref_236 WebhookEvent: type: object properties: @@ -36614,7 +37960,7 @@ components: request_type: type: string description: The type of webhook request (define possible values here) - enum: &ref_578 + enum: &ref_579 - async - sync required: @@ -36623,21 +37969,21 @@ components: WebhookRequestType: type: string description: The type of webhook request (define possible values here) - enum: *ref_578 + enum: *ref_579 RedirectUri: type: object - properties: *ref_232 - required: *ref_233 + properties: *ref_237 + required: *ref_238 NativeTriggerData: type: object description: Data for creating or updating a native trigger - properties: *ref_234 - required: *ref_235 + properties: *ref_239 + required: *ref_240 CreateTriggerResponse: type: object description: Response returned when a native trigger is created - properties: *ref_579 - required: *ref_580 + properties: *ref_580 + required: *ref_581 SyncResult: type: object properties: @@ -36661,35 +38007,35 @@ components: - total_windmill NextCloudEventType: type: object - properties: *ref_581 - required: *ref_582 + properties: *ref_582 + required: *ref_583 GoogleCalendarEntry: type: object - properties: *ref_583 - required: *ref_584 + properties: *ref_584 + required: *ref_585 GoogleDriveFile: type: object - properties: *ref_585 - required: *ref_586 + properties: *ref_586 + required: *ref_587 GoogleDriveFilesResponse: type: object - properties: *ref_587 - required: *ref_588 + properties: *ref_588 + required: *ref_589 SharedDriveEntry: type: object - properties: *ref_589 - required: *ref_590 + properties: *ref_590 + required: *ref_591 GithubRepoEntry: type: object - properties: *ref_591 - required: *ref_592 + properties: *ref_592 + required: *ref_593 schemas-StaticTransform: type: object description: >- Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource' - properties: *ref_136 - required: *ref_137 + properties: *ref_140 + required: *ref_141 schemas-JavascriptTransform: type: object description: >- @@ -36697,48 +38043,48 @@ components: results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value - properties: *ref_84 - required: *ref_85 + properties: *ref_89 + required: *ref_90 schemas-AiTransform: type: object description: >- Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter. - properties: *ref_86 - required: *ref_87 + properties: *ref_91 + required: *ref_92 schemas-InputTransform: description: >- Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs - oneOf: *ref_80 - discriminator: *ref_81 + oneOf: *ref_85 + discriminator: *ref_86 schemas-RawScript: type: object description: >- Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms - properties: *ref_323 - required: *ref_324 + properties: *ref_328 + required: *ref_329 schemas-PathScript: type: object description: >- Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code - properties: *ref_325 - required: *ref_326 + properties: *ref_330 + required: *ref_331 schemas-PathFlow: type: object description: >- Reference to an existing flow by path. Use this to call another flow as a subflow - properties: *ref_327 - required: *ref_328 + properties: *ref_332 + required: *ref_333 schemas-FlowModule: type: object description: A single step in a flow. Can be a script, subflow, loop, or branch - properties: *ref_82 - required: *ref_83 + properties: *ref_87 + required: *ref_88 schemas-ForloopFlow: type: object description: >- @@ -36746,160 +38092,160 @@ components: 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations - properties: *ref_329 - required: *ref_330 + properties: *ref_334 + required: *ref_335 schemas-WhileloopFlow: type: object description: >- Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination - properties: *ref_331 - required: *ref_332 + properties: *ref_336 + required: *ref_337 schemas-BranchOne: type: object description: >- Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes - properties: *ref_333 - required: *ref_334 + properties: *ref_338 + required: *ref_339 schemas-BranchAll: type: object description: >- Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently - properties: *ref_335 - required: *ref_336 + properties: *ref_340 + required: *ref_341 schemas-Identity: type: object description: >- Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder - properties: *ref_337 - required: *ref_338 + properties: *ref_342 + required: *ref_343 AIProviderKind: type: string description: Supported AI provider types - enum: *ref_316 + enum: *ref_321 schemas-ProviderConfig: type: object description: >- Complete AI provider configuration with resource reference and model selection - properties: *ref_593 - required: *ref_594 + properties: *ref_594 + required: *ref_595 StaticProviderTransform: type: object description: Static provider configuration passed directly to the AI agent - properties: *ref_595 - required: *ref_596 + properties: *ref_596 + required: *ref_597 ProviderTransform: description: >- Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined - oneOf: *ref_341 - discriminator: *ref_342 + oneOf: *ref_346 + discriminator: *ref_347 MemoryOff: type: object description: No conversation memory/context - properties: *ref_317 - required: *ref_318 + properties: *ref_322 + required: *ref_323 MemoryAuto: type: object description: Automatic context management - properties: *ref_319 - required: *ref_320 + properties: *ref_324 + required: *ref_325 MemoryMessage: type: object description: A single message in conversation history - properties: *ref_597 - required: *ref_598 + properties: *ref_598 + required: *ref_599 MemoryManual: type: object description: Explicit message history - properties: *ref_321 - required: *ref_322 + properties: *ref_326 + required: *ref_327 schemas-MemoryConfig: description: Conversation memory configuration - oneOf: *ref_599 - discriminator: *ref_600 + oneOf: *ref_600 + discriminator: *ref_601 StaticMemoryTransform: type: object description: Static memory configuration passed directly to the AI agent - properties: *ref_601 - required: *ref_602 + properties: *ref_602 + required: *ref_603 MemoryTransform: description: >- Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined - oneOf: *ref_343 - discriminator: *ref_344 + oneOf: *ref_348 + discriminator: *ref_349 schemas-FlowModuleValue: description: >- The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type - oneOf: *ref_88 - discriminator: *ref_89 + oneOf: *ref_93 + discriminator: *ref_94 FlowModuleTool: description: >- A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module - allOf: *ref_603 + allOf: *ref_604 McpToolValue: type: object description: >- Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers - properties: *ref_604 - required: *ref_605 + properties: *ref_605 + required: *ref_606 WebsearchToolValue: type: object description: >- A tool implemented as a websearch tool. The AI can call this like any other websearch tool - properties: *ref_606 - required: *ref_607 + properties: *ref_607 + required: *ref_608 ToolValue: description: >- The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference - oneOf: *ref_608 - discriminator: *ref_609 + oneOf: *ref_609 + discriminator: *ref_610 AgentTool: type: object description: >- A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool - properties: *ref_345 - required: *ref_346 + properties: *ref_350 + required: *ref_351 schemas-AiAgent: type: object description: >- AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task - properties: *ref_339 - required: *ref_340 + properties: *ref_344 + required: *ref_345 schemas-StopAfterIf: type: object description: Early termination condition for a module - properties: *ref_90 - required: *ref_91 + properties: *ref_95 + required: *ref_96 RetryIf: type: object description: Conditional retry based on error or result - properties: *ref_195 - required: *ref_196 + properties: *ref_199 + required: *ref_200 schemas-Retry: type: object description: Retry configuration for failed module executions - properties: *ref_315 + properties: *ref_320 schemas-FlowNote: type: object description: A sticky note attached to a flow for documentation and annotation - properties: *ref_145 - required: *ref_146 + properties: *ref_149 + required: *ref_150 FlowGroup: type: object description: >- @@ -36908,16 +38254,16 @@ components: flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id. - properties: *ref_147 - required: *ref_148 + properties: *ref_151 + required: *ref_152 schemas-FlowValue: type: object description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: *ref_610 - required: *ref_611 + properties: *ref_611 + required: *ref_612 schemas-FlowStatusModule: type: object - properties: *ref_154 - required: *ref_155 + properties: *ref_158 + required: *ref_159 diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 535f5982d3..2980879e9f 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -5355,13 +5355,16 @@ paths: in: query schema: type: boolean + - $ref: "#/components/parameters/GetDraft" responses: "200": description: variable content: application/json: schema: - $ref: "#/components/schemas/ListableVariable" + allOf: + - $ref: "#/components/schemas/ListableVariable" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/variables/get_value/{path}: get: @@ -5445,6 +5448,15 @@ paths: required: false schema: type: string + - name: include_draft_only + description: | + When true, append per-user draft variables whose path has no + deployed variable. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. + in: query + required: false + schema: + type: boolean responses: "200": description: variable list @@ -6678,13 +6690,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: resource content: application/json: schema: - $ref: "#/components/schemas/Resource" + allOf: + - $ref: "#/components/schemas/ListableResource" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/resources/get_value_interpolated/{path}: get: @@ -6825,6 +6840,15 @@ paths: required: false schema: type: string + - name: include_draft_only + description: | + When true, append per-user draft resources whose path has + no deployed resource. Synthesized rows carry + `draft_only: true`. + in: query + required: false + schema: + type: boolean responses: "200": description: resource list @@ -7813,7 +7837,40 @@ paths: schema: type: array items: - $ref: "#/components/schemas/Script" + allOf: + - $ref: "#/components/schemas/Script" + - type: object + properties: + is_draft: + type: boolean + description: | + True when the authed user has a draft for this + script — either no deployed row exists at this + path (draft-only) or the user saved a per-user + draft on top of the deployed row. + draft_path: + type: string + description: | + User-typed path the editor has staged but not + yet deployed. Surfaced for draft-only rows so + the home list can render the meaningful name + instead of the autogenerated + `u/{user}/draft_{uuid}` URL path. Omitted + when unchanged. + draft_users: + description: | + Workspace users (including the authed user, and + the legacy NULL-email row if any) who have a + per-user draft at this path. Drives the home + page's user-avatar circles inside the Draft + badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true /w/{workspace}/scripts/list_paths: get: @@ -7833,43 +7890,43 @@ paths: items: type: string - /w/{workspace}/drafts/create: - post: - summary: create draft - operationId: createDraft + /w/{workspace}/drafts/list: + get: + summary: list every draft the current user has in this workspace, across all kinds + operationId: listDrafts tags: - draft parameters: - $ref: "#/components/parameters/WorkspaceId" - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - path: - type: string - typ: - type: string - enum: ["flow", "script", "app"] - value: {} - required: - - path - - typ - - enum responses: - "201": - description: draft created + "200": + description: the user's drafts content: - text/plain: + application/json: schema: - type: string + type: array + items: + type: object + properties: + kind: + $ref: "#/components/schemas/UserDraftItemKind" + path: + type: string + summary: + type: string + description: Best-effort, read from the draft JSON's `summary` field when the editor shape carries one. + draft_only: + type: boolean + description: No deployed counterpart exists at this path — the draft is the whole item. + created_at: + type: string + format: date-time + required: [kind, path, draft_only, created_at] - /w/{workspace}/drafts/delete/{kind}/{path}: - delete: - summary: delete draft - operationId: deleteDraft + /w/{workspace}/drafts/get/{kind}/{path}: + get: + summary: fetch a single draft's content by workspace username (or the legacy workspace-level row) + operationId: getDraftForUser tags: - draft parameters: @@ -7878,19 +7935,76 @@ paths: in: path required: true schema: - type: string - enum: - - script - - flow - - app + $ref: "#/components/schemas/UserDraftItemKind" - $ref: "#/components/parameters/ScriptPath" + - name: username + in: query + required: false + description: Workspace username of the draft owner. Omit to fetch the legacy workspace-level (NULL email) row. + schema: + type: string responses: "200": - description: draft deleted + description: draft content content: - text/plain: + application/json: schema: - type: string + type: object + properties: + value: {} + created_at: + type: string + format: date-time + required: [value, created_at] + "404": + description: no draft for that owner at that path + + /w/{workspace}/drafts/update/{kind}/{path}: + post: + summary: upsert (or clear) the current user's draft at a path + operationId: updateDraft + 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: + value: + nullable: true + description: Draft content to save. `null` (or omitted) signals a delete — the row is removed under the same conflict rules. + last_sync: + type: string + format: date-time + description: Server timestamp of the client's last known sync for this draft. Omit on first save. + force: + type: boolean + description: Skip the conflict check and overwrite the server copy. + responses: + "200": + description: save result + content: + application/json: + schema: + type: object + properties: + status: + type: string + enum: [saved, conflict] + current_timestamp: + type: string + format: date-time + required: [status, current_timestamp] /w/{workspace}/scripts/create: post: @@ -8269,13 +8383,16 @@ paths: in: query schema: type: boolean + - $ref: "#/components/parameters/GetDraft" responses: "200": description: script details content: application/json: schema: - $ref: "#/components/schemas/Script" + allOf: + - $ref: "#/components/schemas/Script" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/scripts/get_triggers_count/{path}: get: @@ -8313,23 +8430,6 @@ paths: items: $ref: "#/components/schemas/TruncatedToken" - /w/{workspace}/scripts/get/draft/{path}: - get: - summary: get script by path with draft - operationId: getScriptByPathWithDraft - tags: - - script - parameters: - - $ref: "#/components/parameters/WorkspaceId" - - $ref: "#/components/parameters/ScriptPath" - responses: - "200": - description: script details - content: - application/json: - schema: - $ref: "#/components/schemas/NewScriptWithDraft" - /w/{workspace}/scripts/history/p/{path}: get: summary: get history of a script by path @@ -9505,10 +9605,40 @@ paths: - $ref: "#/components/schemas/Flow" - type: object properties: - has_draft: - type: boolean draft_only: type: boolean + is_draft: + type: boolean + description: | + True when the authed user has a draft for this + flow — either no deployed row exists at this + path (draft-only) or the user saved a per-user + draft on top of the deployed row. + draft_path: + type: string + description: | + User-typed path the editor has staged but not + yet deployed. Sourced from the draft JSON's + `draft_path` field (the editor only writes it + when the typed path differs from the deployed + one). Lets the home list render the meaningful + name instead of the autogenerated + `u/{user}/draft_{uuid}` URL path. Omitted when + unchanged. + draft_users: + description: | + Workspace users (including the authed user, and + the legacy NULL-email row if any) who have a + per-user draft at this path. Drives the home + page's user-avatar circles inside the Draft + badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true /w/{workspace}/flows/history/p/{path}: get: @@ -9638,13 +9768,16 @@ paths: in: query schema: type: boolean + - $ref: "#/components/parameters/GetDraft" responses: "200": description: flow details content: application/json: schema: - $ref: "#/components/schemas/Flow" + allOf: + - $ref: "#/components/schemas/Flow" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/flows/deployment_status/p/{path}: get: @@ -9732,32 +9865,6 @@ paths: schema: type: string - /w/{workspace}/flows/get/draft/{path}: - get: - summary: get flow by path with draft - operationId: getFlowByPathWithDraft - tags: - - flow - parameters: - - $ref: "#/components/parameters/WorkspaceId" - - $ref: "#/components/parameters/ScriptPath" - responses: - "200": - description: flow details with draft - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/Flow" - - type: object - properties: - draft: - $ref: "#/components/schemas/Flow" - draft_created_at: - type: string - format: date-time - description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check. - /w/{workspace}/flows/exists/{path}: get: summary: exists flow by path @@ -9805,8 +9912,6 @@ paths: - $ref: "#/components/schemas/OpenFlowWPath" - type: object properties: - draft_only: - type: boolean deployment_message: type: string skip_draft_deletion: @@ -10068,6 +10173,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: All raw apps @@ -10341,8 +10447,6 @@ paths: type: string policy: $ref: "#/components/schemas/Policy" - draft_only: - type: boolean deployment_message: type: string custom_path: @@ -10396,8 +10500,6 @@ paths: type: string policy: $ref: "#/components/schemas/Policy" - draft_only: - type: boolean deployment_message: type: string custom_path: @@ -10459,13 +10561,24 @@ paths: in: query schema: type: boolean + - $ref: "#/components/parameters/GetDraft" + - name: raw_app + in: query + description: | + When no deployed app exists at this path and `get_draft` is set, + disambiguates which draft kind (`raw_app` or `app`) to look up. + Ignored when a deployed row exists. + schema: + type: boolean responses: "200": description: app details content: application/json: schema: - $ref: "#/components/schemas/AppWithLastVersion" + allOf: + - $ref: "#/components/schemas/AppWithLastVersion" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/apps/get/lite/{path}: get: @@ -10484,23 +10597,6 @@ paths: schema: $ref: "#/components/schemas/AppWithLastVersion" - /w/{workspace}/apps/get/draft/{path}: - get: - summary: get app by path with draft - operationId: getAppByPathWithDraft - tags: - - app - parameters: - - $ref: "#/components/parameters/WorkspaceId" - - $ref: "#/components/parameters/ScriptPath" - responses: - "200": - description: app details with draft - content: - application/json: - schema: - $ref: "#/components/schemas/AppWithLastVersionWDraft" - /w/{workspace}/apps/history/p/{path}: get: summary: get app history by path @@ -13657,13 +13753,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: schedule deleted content: application/json: schema: - $ref: "#/components/schemas/Schedule" + allOf: + - $ref: "#/components/schemas/Schedule" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/schedules/exists/{path}: get: @@ -13735,6 +13834,15 @@ paths: schema: type: string description: Filter by label + - name: include_draft_only + description: | + When true, append per-user draft schedules whose path has + no deployed schedule. Synthesized rows carry + `draft_only: true`. + in: query + required: false + schema: + type: boolean responses: "200": description: schedule list @@ -13946,13 +14054,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: http trigger deleted content: application/json: schema: - $ref: "#/components/schemas/HttpTrigger" + allOf: + - $ref: "#/components/schemas/HttpTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/http_triggers/list: get: @@ -13984,6 +14095,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: http trigger list @@ -14152,13 +14264,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: websocket trigger deleted content: application/json: schema: - $ref: "#/components/schemas/WebsocketTrigger" + allOf: + - $ref: "#/components/schemas/WebsocketTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/websocket_triggers/list: get: @@ -14190,6 +14305,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: websocket trigger list @@ -14357,13 +14473,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: kafka trigger deleted content: application/json: schema: - $ref: "#/components/schemas/KafkaTrigger" + allOf: + - $ref: "#/components/schemas/KafkaTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/kafka_triggers/list: get: @@ -14395,6 +14514,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: kafka trigger list @@ -14603,13 +14723,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: nats trigger deleted content: application/json: schema: - $ref: "#/components/schemas/NatsTrigger" + allOf: + - $ref: "#/components/schemas/NatsTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/nats_triggers/list: get: @@ -14641,6 +14764,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: nats trigger list @@ -14803,13 +14927,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: sqs trigger deleted content: application/json: schema: - $ref: "#/components/schemas/SqsTrigger" + allOf: + - $ref: "#/components/schemas/SqsTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/sqs_triggers/list: get: @@ -14841,6 +14968,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: sqs trigger list @@ -15296,6 +15424,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: native triggers list @@ -15596,13 +15725,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: mqtt trigger deleted content: application/json: schema: - $ref: "#/components/schemas/MqttTrigger" + allOf: + - $ref: "#/components/schemas/MqttTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/mqtt_triggers/list: get: @@ -15634,6 +15766,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: mqtt trigger list @@ -15796,13 +15929,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: gcp trigger deleted content: application/json: schema: - $ref: "#/components/schemas/GcpTrigger" + allOf: + - $ref: "#/components/schemas/GcpTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/gcp_triggers/list: get: @@ -15834,6 +15970,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: gcp trigger list @@ -16063,13 +16200,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: azure trigger content: application/json: schema: - $ref: "#/components/schemas/AzureTrigger" + allOf: + - $ref: "#/components/schemas/AzureTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/azure_triggers/list: get: @@ -16094,6 +16234,7 @@ paths: in: query schema: type: string + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: azure trigger list @@ -16606,13 +16747,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: get postgres trigger content: application/json: schema: - $ref: "#/components/schemas/PostgresTrigger" + allOf: + - $ref: "#/components/schemas/PostgresTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/postgres_triggers/list: get: @@ -16644,6 +16788,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: postgres trigger list @@ -16806,13 +16951,16 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/Path" + - $ref: "#/components/parameters/GetDraft" responses: "200": description: email trigger retrieved content: application/json: schema: - $ref: "#/components/schemas/EmailTrigger" + allOf: + - $ref: "#/components/schemas/EmailTrigger" + - $ref: "#/components/schemas/UserDraftOverlay" /w/{workspace}/email_triggers/list: get: @@ -16844,6 +16992,7 @@ paths: schema: type: string description: Filter by label + - $ref: "#/components/parameters/IncludeDraftOnly" responses: "200": description: email trigger list @@ -20569,6 +20718,25 @@ components: name: token parameters: + GetDraft: + name: get_draft + in: query + required: false + description: When true, overlay the authed user's draft (if any) onto the deployed payload. + schema: + type: boolean + IncludeDraftOnly: + name: include_draft_only + in: query + required: false + description: | + When true, append per-user draft rows whose path has no + deployed counterpart. Synthesized rows carry `draft_only: true` + so the home page can render a "Draft" badge. Gated to + non-operators + page 0 + no narrowing filters on the backend so + picker callers stay deployed-only and pagination stays clean. + schema: + type: boolean Id: name: id in: path @@ -21000,6 +21168,81 @@ components: # This is why it is better to inline each of schemas for better compat # Do not change next line. It is used by python-client for pre-processing # -- INLINE START -- + UserDraftOverlay: + type: object + description: | + Overlay fields added to every "get by path" response that accepts + the `get_draft` query parameter. The deployed payload is sent + untouched in the response body; the authed user's saved draft + for this path — whatever shape the editor wrote — is attached + as the sibling `draft` field when `get_draft=true` and a draft + exists. The frontend pairs the two to present diff / reset / + discard UI; the server never merges them. + + When `no_deployed=true` there is no deployed row at this path — + the response body is a best-effort stand-in synthesized from + the draft, and only `draft` is canonical. Callers should disable + "diff vs deployed" UI in that case. + properties: + is_draft: + type: boolean + draft_saved_at: + type: string + format: date-time + no_deployed: + type: boolean + draft: + type: object + additionalProperties: true + other_drafts_users: + description: | + Other workspace users (and the legacy NULL-email row, if any) + with a saved draft at the same path. Populated only on the + authed user's "get by path" responses for kinds the editor + surfaces a fork banner for (script, flow, app, raw_app). + Empty / omitted for kinds without that UI. + type: array + items: + type: object + properties: + username: + type: string + nullable: true + description: | + Workspace username of the draft owner. `null` represents + the legacy workspace-level (NULL-email) row. Emails never + leave the server. + required: [is_draft] + UserDraftItemKind: + type: string + description: | + Closed set of item kinds a user can autosave as a draft. Mirrors the + Postgres `DRAFT_KIND` enum and the backend `UserDraftItemKind`. + 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 OpenFlow: $ref: "../../openflow.openapi.yaml#/components/schemas/OpenFlow" FlowValue: @@ -21822,8 +22065,6 @@ components: type: boolean tag: type: string - has_draft: - type: boolean draft_only: type: boolean envs: @@ -21936,8 +22177,6 @@ components: enum: [script, failure, trigger, command, approval, preprocessor] tag: type: string - draft_only: - type: boolean envs: type: array items: @@ -22030,22 +22269,6 @@ components: - content - language - NewScriptWithDraft: - allOf: - - $ref: "#/components/schemas/NewScript" - - type: object - properties: - draft: - $ref: "#/components/schemas/NewScript" - draft_created_at: - type: string - format: date-time - description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check. - hash: - type: string - required: - - hash - ScriptHistory: type: object properties: @@ -23120,6 +23343,17 @@ components: format: date-time edited_by: type: string + draft_only: + description: | + True when this row is a per-user draft with no deployed + variable at the same path. Frontend renders a "Draft" badge. + type: boolean + is_draft: + description: | + True when the authed user has a per-user draft at this path + (over a deployed row or a synthesized draft-only row). + Frontend appends a `*` to the displayed name. + type: boolean required: - workspace_id - path @@ -23717,6 +23951,17 @@ components: Read-only — edit them on the folder. ws_specific: type: boolean + draft_only: + description: | + True when this row is a per-user draft with no deployed + resource at the same path. Frontend renders a "Draft" badge. + type: boolean + is_draft: + description: | + True when the authed user has a per-user draft at this path + (over a deployed row or a synthesized draft-only row). + Frontend appends a `*` to the displayed name. + type: boolean required: - path - resource_type @@ -23873,6 +24118,17 @@ components: items: type: string default: [] + draft_only: + description: | + True when this row is a per-user draft with no deployed + schedule at the same path. Frontend renders a "Draft" badge. + type: boolean + is_draft: + description: | + True when the authed user has a per-user draft at this path + (over a deployed row or a synthesized draft-only row). + Frontend appends a `*` to the displayed name. + type: boolean inherited_labels: type: array items: @@ -24194,6 +24450,19 @@ components: items: type: string default: [] + draft_only: + description: | + True when this row is a per-user draft with no deployed + trigger at the same path. Set by list endpoints when + `include_draft_only=true` synthesizes the row from the + draft. Frontend renders a "Draft" badge. + type: boolean + is_draft: + description: | + True when the authed user has a per-user draft at this path + (over a deployed row or a synthesized draft-only row). + Frontend appends a `*` to the displayed name. + type: boolean required: - path - script_path @@ -27030,6 +27299,34 @@ components: items: type: string default: [] + is_draft: + type: boolean + description: | + True when the authed user has a draft for this app — either no + deployed row exists at this path (draft-only) or the user has + saved a per-user draft on top of the deployed row. + draft_path: + type: string + description: | + User-typed path the editor has staged but not yet deployed. + Sourced from the draft JSON's `draft_path` field (the editor + only writes it when the typed path differs from the deployed + one). Lets the home list render the meaningful name instead of + the autogenerated `u/{user}/draft_{uuid}` URL path. Omitted + when unchanged. + draft_users: + description: | + Workspace users (including the authed user, and the legacy + NULL-email row if any) who have a per-user draft at this + path. Drives the home page's user-avatar circles inside the + Draft badge. Omitted when no drafts exist. + type: array + items: + type: object + properties: + username: + type: string + nullable: true inherited_labels: type: array items: @@ -27178,19 +27475,6 @@ components: - raw_app - AppWithLastVersionWDraft: - allOf: - - $ref: "#/components/schemas/AppWithLastVersion" - - type: object - properties: - draft_only: - type: boolean - draft: {} - draft_created_at: - type: string - format: date-time - description: Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check. - AppHistory: type: object properties: diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 4a245530e5..029f0e7d1f 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -12,7 +12,7 @@ use crate::{ db::{ApiAuthed, DB}, jobs::RunJobQuery, users::{require_owner_of_path, require_path_read_access_for_preview, OptAuthed}, - utils::{check_scopes, WithStarredInfoQuery}, + utils::check_scopes, webhook_util::{WebhookMessage, WebhookShared}, HTTP_CLIENT, }; @@ -58,6 +58,7 @@ use windmill_common::{ get_payload_tag_from_prefixed_path, resolve_delete_after_secs, schedule_job_deletion, JobPayload, RawCode, }, + user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay}, users::username_to_permissioned_as, utils::{ http_get_from_hub, not_found_if_none, paginate, query_elems_from_hub, require_admin, @@ -90,7 +91,6 @@ pub fn workspaced_service(raw_app_body_limit: usize) -> Router { .route("/list_search", get(list_search_apps)) .route("/get/p/{*path}", get(get_app)) .route("/get/lite/{*path}", get(get_app_lite)) - .route("/get/draft/{*path}", get(get_app_w_draft)) .route("/secret_of/{*path}", get(get_secret_id)) .route( "/secret_of_latest_version/{*path}", @@ -155,7 +155,9 @@ pub struct ListableApp { pub execution_mode: String, pub starred: bool, pub edited_at: Option>, - pub has_draft: bool, + /// `Some(true)` only on rows synthesised from the `draft` table; `None` for + /// deployed rows. See ListableScript in windmill-types/src/scripts.rs. + #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub draft_only: Option, #[sqlx(default)] @@ -165,6 +167,20 @@ pub struct ListableApp { pub raw_app: bool, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// True when the authed user has a draft for this app (draft-only or layered + /// over the deployed row). See ListableScript in windmill-types/src/scripts.rs. + #[serde(default, skip_serializing_if = "is_false")] + pub is_draft: bool, + /// User-typed staged path from the draft JSON's `draft_path`; `None` = unchanged. + /// See ListableScript in windmill-types/src/scripts.rs. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_path: Option, + /// Per-path draft owners driving the home-page avatar circles. + /// See ListableScript in windmill-types/src/scripts.rs. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_users: Option>>, /// Labels inherited from the parent folder, computed at read time. #[sqlx(default)] #[serde(default, skip_serializing_if = "Option::is_none")] @@ -214,20 +230,6 @@ pub struct AppWithLastVersionAndStarred { pub starred: Option, } -#[derive(Serialize, Deserialize, FromRow)] -pub struct AppWithLastVersionAndDraft { - #[sqlx(flatten)] - #[serde(flatten)] - pub app: AppWithLastVersion, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft: Option>>, - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_only: Option, - /// Timestamp at which the most recent DB draft was created. - #[serde(skip_serializing_if = "Option::is_none")] - pub draft_created_at: Option>, -} - #[derive(Serialize)] pub struct AppHistory { pub app_id: i64, @@ -306,7 +308,6 @@ pub struct CreateApp { pub summary: String, pub value: sqlx::types::Json>, pub policy: Policy, - pub draft_only: Option, pub deployment_message: Option, pub custom_path: Option, pub preserve_on_behalf_of: Option, @@ -370,6 +371,7 @@ async fn list_search_apps( async fn list_apps( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, Query(pagination): Query, Query(lq): Query, @@ -387,10 +389,15 @@ async fn list_apps( "app_version.created_at as edited_at", "app.extra_perms", "favorite.path IS NOT NULL as starred", - "draft.path IS NOT NULL as has_draft", - "draft_only", "app_version.raw_app", "app.labels", + "draft.path IS NOT NULL as is_draft", + // Per-path draft owners as a JSON array; see scripts.rs for the rationale + // (admins-workspace identity fallback, legacy NULL-email row). + "(SELECT json_agg(json_build_object('username', COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END)) ORDER BY COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) NULLS LAST) \ + FROM draft d \ + LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \ + WHERE d.workspace_id = app.workspace_id AND d.path = app.path AND d.typ = 'app') as draft_users", "folder_labels(app.workspace_id, app.path) as inherited_labels", ]) .left() @@ -400,15 +407,19 @@ async fn list_apps( .bind(&authed.username), ) .left() + // `app`/`raw_app` are separate draft kinds over one `app` table — match either + // for `is_draft`. DISTINCT in the subquery: a path with both kinds for the same + // user would otherwise fan the deployed row into two identical entries. + .join( + "(SELECT DISTINCT path, workspace_id FROM draft WHERE typ IN ('app', 'raw_app') AND email = ?) draft" + .bind(&authed.email), + ) + .on("draft.path = app.path AND draft.workspace_id = app.workspace_id") + .left() .join("app_version") .on( "app_version.id = versions[array_upper(versions, 1)]" ) - .left() - .join("draft") - .on( - "draft.path = app.path AND draft.workspace_id = app.workspace_id AND draft.typ = 'app'" - ) .order_desc("favorite.path IS NOT NULL") .order_by("app_version.created_at", true) .and_where("app.workspace_id = ?".bind(&w_id)) @@ -428,10 +439,6 @@ async fn list_apps( sqlb.and_where_eq("app.path", "?".bind(path_exact)); } - if !lq.include_draft_only.unwrap_or(false) || authed.is_operator { - sqlb.and_where("app.draft_only IS NOT TRUE"); - } - if let Some(label) = &lq.label { for l in label.split(',') { sqlb.and_where( @@ -451,12 +458,87 @@ async fn list_apps( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; - let rows = sqlx::query_as::<_, ListableApp>(&sql) + let mut rows = sqlx::query_as::<_, ListableApp>(&sql) .fetch_all(&mut *tx) .await?; tx.commit().await?; + // Append the authed user's `app`/`raw_app` drafts at paths with no deployed app; + // see scripts.rs. + if lq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lq.path_start.is_none() + && lq.path_exact.is_none() + && lq.label.is_none() + && !lq.starred_only.unwrap_or(false) + { + // DISTINCT ON (path), newest first: collapse a path holding both `app` and + // `raw_app` drafts to one row (the home list keyed by `type/path` would crash + // on duplicates). `(email IS NULL)` last keeps the owned row over the legacy one. + let draft_only_rows = sqlx::query!( + r#"SELECT DISTINCT ON (path) + path, + value as "value!: sqlx::types::Json>", + created_at, + typ::text as "typ!" + FROM draft + WHERE workspace_id = $1 + AND typ IN ('app', 'raw_app') + AND (email = $2 OR email IS NULL) + AND NOT EXISTS ( + SELECT 1 FROM app a + WHERE a.workspace_id = draft.workspace_id + AND a.path = draft.path + ) + ORDER BY path, (email IS NULL), created_at DESC"#, + &w_id, + &authed.email, + ) + .fetch_all(&db) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + // App/raw-app drafts are the bare editor value with no `path`, so the editor + // writes a separate `draft_path` only when it differs from deployed; see flows.rs. + let draft_path = v + .get("draft_path") + .and_then(|s| s.as_str()) + .filter(|s| !s.is_empty() && *s != row.path.as_str()) + .map(|s| s.to_string()); + rows.push(ListableApp { + id: 0, + workspace_id: w_id.clone(), + path: row.path, + summary: v + .get("summary") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(), + version: 0, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + execution_mode: String::new(), + starred: false, + edited_at: Some(row.created_at), + draft_only: Some(true), + deployment_msg: None, + raw_app: row.typ == "raw_app", + labels: None, + // No deployed row to inherit folder labels from. + inherited_labels: None, + is_draft: true, + draft_path, + // Synthesized rows are the authed user's own draft. + draft_users: Some(sqlx::types::Json(vec![DraftUserRef { + username: Some(authed.username.clone()), + }])), + }); + } + } + Ok(Json(rows)) } @@ -578,12 +660,25 @@ async fn get_raw_app_data( // Ok(Json(version)) // } +// Fields inlined rather than flattened (axum query bool quirk); see GetScriptByPathQuery in scripts.rs. +#[derive(Deserialize)] +struct GetAppQuery { + with_starred_info: Option, + #[serde(default)] + get_draft: bool, + /// Picks the draft kind for a draft-only lookup (`/apps_raw/...` → true). + /// Ignored when a deployed row exists — its own `raw_app` column wins. + #[serde(default)] + raw_app: Option, +} + async fn get_app( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, - Query(query): Query, -) -> JsonResult { + Query(query): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("apps:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; @@ -597,9 +692,9 @@ async fn get_app( JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)] LEFT JOIN favorite - ON favorite.favorite_kind = 'app' - AND favorite.workspace_id = app.workspace_id - AND favorite.path = app.path + ON favorite.favorite_kind = 'app' + AND favorite.workspace_id = app.workspace_id + AND favorite.path = app.path AND favorite.usr = $3 WHERE app.path = $1 AND app.workspace_id = $2", ) @@ -623,8 +718,27 @@ async fn get_app( }; tx.commit().await?; - let app = not_found_if_none(app_o, "App", path)?; - Ok(Json(app)) + // No deployed row + `get_draft`: fall back to the draft table; see scripts.rs. + // Draft kind comes from the deployed row's `raw_app` flag, or for a draft-only + // path from the caller's `raw_app` query param. + let kind = match &app_o { + Some(app) if app.app.raw_app => UserDraftItemKind::RawApp, + Some(_) => UserDraftItemKind::App, + None if query.raw_app.unwrap_or(false) => UserDraftItemKind::RawApp, + None => UserDraftItemKind::App, + }; + let overlay = overlay_or_draft_only( + &db, + &w_id, + &authed.email, + kind, + path, + query.get_draft, + app_o, + || windmill_common::error::Error::NotFound(format!("App not found at path {path}")), + ) + .await?; + Ok(Json(overlay)) } async fn get_app_lite( @@ -655,55 +769,6 @@ async fn get_app_lite( Ok(Json(app)) } -async fn get_app_w_draft( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { - let path = path.to_path(); - check_scopes(&authed, || format!("apps:read:{}", path))?; - let mut tx = user_db.begin(&authed).await?; - - let app_o = sqlx::query_as::<_, AppWithLastVersionAndDraft>( - r#" - SELECT - app.id, - app.path, - app.summary, - app.versions, - app.policy, - app.custom_path, - app.extra_perms, - app_version.value, - app_version.created_at, - app_version.created_by, - app.draft_only, - draft.value AS "draft", - draft.created_at AS "draft_created_at", - app_version.raw_app, - app.labels - FROM app - INNER JOIN app_version - ON app_version.id = app.versions[array_upper(app.versions, 1)] - LEFT JOIN draft - ON app.path = draft.path - AND draft.workspace_id = $2 - AND draft.typ = 'app' - WHERE app.path = $1 - AND app.workspace_id = $2 - "#, - ) - .bind(path.to_owned()) - .bind(&w_id) - .fetch_optional(&mut *tx) - .await?; - - tx.commit().await?; - - let app = not_found_if_none(app_o, "App", path)?; - Ok(Json(app)) -} - async fn get_app_history( authed: ApiAuthed, Extension(user_db): Extension, @@ -1374,25 +1439,27 @@ async fn create_app_internal<'a>( } } // CLI / git-sync deploys ask us to preserve any existing user draft at this - // path instead of wiping it as part of the deploy. + // path instead of wiping it as part of the deploy. Only wipe the deployer's + // own draft (plus the legacy NULL-email row); see scripts.rs. if !app.skip_draft_deletion.unwrap_or(false) { sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app') \ + AND (email = $3 OR email IS NULL)", &app.path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; } let id = sqlx::query_scalar!( "INSERT INTO app - (workspace_id, path, summary, policy, versions, draft_only, custom_path, labels) - VALUES ($1, $2, $3, $4, '{}', $5, $6, $7) RETURNING id", + (workspace_id, path, summary, policy, versions, custom_path, labels) + VALUES ($1, $2, $3, $4, '{}', $5, $6) RETURNING id", w_id, app.path, app.summary, json!(app.policy), - app.draft_only, app.custom_path .as_ref() .map(|s| if s.is_empty() { None } else { Some(s) }) @@ -1598,15 +1665,16 @@ async fn delete_app( .await?; let trash_drafts: Vec = sqlx::query_scalar( - "SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + "SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app')", ) .bind(path) .bind(&w_id) .fetch_all(&mut *tx) .await?; + // Both `app` and `raw_app` draft kinds back the same `app` table. sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app')", path, &w_id ) @@ -1827,7 +1895,6 @@ async fn update_app_internal<'a>( sqlb.and_where_eq("path", "?".bind(&path)); sqlb.and_where_eq("workspace_id", "?".bind(&w_id)); - sqlb.set("draft_only", "NULL"); if let Some(npath) = &ns.path { if npath != path { require_owner_of_path(&authed, path)?; @@ -2014,12 +2081,15 @@ async fn update_app_internal<'a>( } }; // CLI / git-sync deploys ask us to preserve any existing user draft at this - // path instead of wiping it as part of the deploy. + // path instead of wiping it as part of the deploy. Only wipe the deployer's + // own draft (plus the legacy NULL-email row) — see create_app_internal. if !ns.skip_draft_deletion.unwrap_or(false) { sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app') \ + AND (email = $3 OR email IS NULL)", path, - &w_id + &w_id, + &authed.email, ) .execute(&mut *tx) .await?; @@ -3281,18 +3351,6 @@ fn get_on_behalf_of(policy: &Policy) -> Result<(String, String)> { Ok((permissioned_as, email)) } -pub async fn require_is_writer(authed: &ApiAuthed, path: &str, w_id: &str, db: DB) -> Result<()> { - return crate::users::require_is_writer( - authed, - path, - w_id, - db, - "SELECT extra_perms FROM app WHERE path = $1 AND workspace_id = $2", - "app", - ) - .await; -} - async fn exists_app( Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index b0cd5f61b0..6ab1a296c5 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -6,133 +6,502 @@ * LICENSE-AGPL for a copy of the license. */ -use crate::{ - db::{ApiAuthed, DB}, - users::{maybe_refresh_folders, require_owner_of_path}, -}; +use crate::db::{ApiAuthed, DB}; use axum::{ extract::{Extension, Path}, - routing::{delete, post}, + routing::{get, post}, Json, Router, }; -use hyper::StatusCode; use serde::{Deserialize, Serialize}; -use windmill_common::{db::UserDB, error::Result, utils::StripPath}; +use windmill_common::{ + db::UserDB, + error::{Error, Result}, + user_drafts::{UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX}, + variables::{build_crypt, encrypt}, +}; pub fn workspaced_service() -> Router { Router::new() - .route("/create", post(create_draft)) - .route("/delete/{kind}/{*path}", delete(delete_draft)) + .route("/list", get(list_drafts)) + .route("/get/{kind}/{*path}", get(get_draft_for_user)) + .route("/update/{kind}/{*path}", post(update_draft)) } -#[derive(sqlx::Type, Serialize, Deserialize, Debug, PartialEq, Clone)] -#[sqlx(type_name = "DRAFT_TYPE", rename_all = "lowercase")] -#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))] -pub enum DraftType { - Script, - Flow, - App, -} - -#[derive(Deserialize, Serialize, Debug)] -pub struct Draft { +#[derive(Serialize, sqlx::FromRow)] +pub struct DraftListItem { + pub kind: UserDraftItemKind, pub path: String, - pub value: sqlx::types::Json>, - pub typ: DraftType, + /// Best-effort, read from the draft JSON's `summary` field when present. + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + /// No deployed counterpart exists at this path — the draft is the whole + /// item. Kinds without a per-path backing table report `true`. + pub draft_only: bool, + pub created_at: chrono::DateTime, } -pub async fn require_writer_of_path( - authed: &ApiAuthed, - path: &str, - w_id: &str, - db: DB, - kind: &DraftType, -) -> Result<()> { - if authed.is_admin { - return Ok(()); - } else if require_owner_of_path(authed, path).is_ok() { - return Ok(()); +/// Every draft the authed user has in this workspace, across all kinds — the +/// single source for the "Review & deploy drafts" page and the home-page +/// draft-count banner. One query over `draft`; `draft_only` is computed per +/// kind against the deployed table. +async fn list_drafts( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> 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 + // can still READ some collaborators' drafts via `/drafts/get`. + if authed.is_operator { + return Ok(Json(vec![])); + } + let rows = sqlx::query_as::<_, DraftListItem>(&list_drafts_query()) + .bind(&w_id) + .bind(&authed.email) + .fetch_all(&db) + .await?; + Ok(Json(rows)) +} + +/// 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 { + let mut case = String::from("CASE d.typ::text\n"); + for kind in UserDraftItemKind::ALL { + let Some(table) = kind.deployed_table() else { + continue; + }; + // `script` rows are soft-deleted — a deleted script counts as "not + // deployed". No other backing table has a `deleted` flag. + let extra = if matches!(kind, UserDraftItemKind::Script) { + " AND t.deleted = false" + } else { + "" + }; + case.push_str(&format!( + " WHEN '{}' THEN NOT EXISTS(SELECT 1 FROM {} t WHERE t.workspace_id = d.workspace_id AND t.path = d.path{})\n", + kind.as_str(), + table, + extra + )); + } + 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. + format!( + r#"SELECT DISTINCT ON (d.path, d.typ) + d.path, + d.typ AS kind, + d.created_at, + d.value ->> 'summary' AS summary, + {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)"# + ) +} + +#[derive(Deserialize, Debug)] +pub struct SaveDraftRequest { + /// Draft content to save. `null` (or omitted) signals a delete — the + /// row is removed under the same conflict rules as an upsert. + #[serde(default)] + pub value: Option>>, + /// Client's last known sync timestamp. When present and `force` is false, + /// the save is rejected if the server's `created_at` is more recent + /// (another writer moved the row forward). Omit on a first save. + #[serde(default)] + pub last_sync: Option>, + /// Skip the conflict check and unconditionally overwrite the server + /// copy. Use after the client has resolved the conflict locally. + #[serde(default)] + pub force: bool, +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum SaveDraftStatus { + Saved, + Conflict, +} + +#[derive(Serialize, Debug)] +pub struct SaveDraftResponse { + pub status: SaveDraftStatus, + /// On `saved`: when the change was applied (client remembers it as the + /// next `last_sync`). On `conflict`: the existing row's `created_at`. + pub current_timestamp: chrono::DateTime, +} + +/// Apply the current user's draft at (workspace, kind, path): non-null `value` +/// upserts, `null` (or omitted) deletes. Either way, when the existing row is +/// newer than `last_sync` (and `force` is false) the op is skipped and the +/// response is `status = conflict` + the server's current timestamp. +async fn update_draft( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>, + Json(req): Json, +) -> Result> { + let email = &authed.email; + let path = path.to_path(); + require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?; + + let applied_at = if let Some(value) = &req.value { + // Secret variable values must never sit in `draft.value` in plaintext + // (see `encrypt_secret_variable_value`). + let serialized = if kind == UserDraftItemKind::Variable { + encrypt_secret_variable_value(&db, &w_id, value.0.get()).await? + } else { + serde_json::to_string(value).unwrap() + }; + // Upsert. The conflict check rides on the DO UPDATE WHERE clause — + // when the row is newer than `last_sync`, RETURNING yields nothing. + sqlx::query_scalar!( + r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at) + VALUES ($1, $2, $3, $4, $5::text::json, now()) + ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL + DO UPDATE SET value = EXCLUDED.value, created_at = now() + WHERE $7::bool = true + OR $6::timestamptz IS NULL + OR draft.created_at <= $6::timestamptz + RETURNING created_at"#, + &w_id, + email, + path, + kind as UserDraftItemKind, + serialized, + req.last_sync, + req.force, + ) + .fetch_optional(&db) + .await? } else { - match kind { - DraftType::Script => crate::scripts::require_is_writer(authed, path, w_id, db).await, - DraftType::Flow => crate::flows::require_is_writer(authed, path, w_id, db).await, - DraftType::App => crate::apps::require_is_writer(authed, path, w_id, db).await, + // Delete, same conflict rule in the WHERE clause. Returns NULL when + // the row was too new (conflict) OR already absent (idempotent) — + // disambiguated below. + sqlx::query_scalar!( + r#"DELETE FROM draft + WHERE workspace_id = $1 + AND email = $2 + AND path = $3 + AND typ = $4 + AND ($6::bool = true + OR $5::timestamptz IS NULL + OR created_at <= $5::timestamptz) + RETURNING now() as "now!""#, + &w_id, + email, + path, + kind as UserDraftItemKind, + req.last_sync, + req.force, + ) + .fetch_optional(&db) + .await? + }; + + if let Some(ts) = applied_at { + return Ok(Json(SaveDraftResponse { + status: SaveDraftStatus::Saved, + current_timestamp: ts, + })); + } + + // No row affected: either the row was newer than `last_sync` (conflict), + // or it was a delete with no row present (idempotent ok). Distinguished + // by re-reading. + let existing = sqlx::query_scalar!( + r#"SELECT created_at FROM draft + WHERE workspace_id = $1 AND email = $2 AND path = $3 AND typ = $4"#, + &w_id, + email, + path, + kind as UserDraftItemKind, + ) + .fetch_optional(&db) + .await?; + + match existing { + Some(ts) => Ok(Json(SaveDraftResponse { + status: SaveDraftStatus::Conflict, + current_timestamp: ts, + })), + // Delete + nothing-was-there ⇒ report success with server's NOW(). + None => { + let now = sqlx::query_scalar!(r#"SELECT now() as "now!""#) + .fetch_one(&db) + .await?; + Ok(Json(SaveDraftResponse { + status: SaveDraftStatus::Saved, + current_timestamp: now, + })) } } } -async fn create_draft( +/// 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. +/// Already-marked values pass through untouched. Unexpected/malformed shapes +/// pass through unchanged — the draft store is schema-less by design. +async fn encrypt_secret_variable_value(db: &DB, w_id: &str, raw: &str) -> Result { + let Ok(mut v) = serde_json::from_str::(raw) else { + return Ok(raw.to_string()); + }; + let is_secret = v + .get("variable") + .and_then(|x| x.get("is_secret")) + .and_then(|x| x.as_bool()) + .unwrap_or(false); + if is_secret { + if let Some(serde_json::Value::String(s)) = + v.get_mut("variable").and_then(|x| x.get_mut("value")) + { + if !s.is_empty() && !s.starts_with(ENCRYPTED_DRAFT_PREFIX) { + let mc = build_crypt(db, w_id).await?; + *s = format!("{ENCRYPTED_DRAFT_PREFIX}{}", encrypt(&mc, s)); + } + } + } + Ok(v.to_string()) +} + +#[derive(Deserialize, Debug)] +pub struct GetDraftQuery { + /// Workspace username of the draft owner. Omit to fetch the legacy + /// NULL-email row, if any. Resolved to an email server-side — emails are + /// not part of the public draft API. + pub username: Option, +} + +#[derive(Serialize, Debug)] +pub struct DraftForUser { + pub value: sqlx::types::Json>, + pub created_at: chrono::DateTime, +} + +/// Fetch a specific user's (or the legacy NULL row's) draft content at a path. +/// Backs the "other users' drafts" banner in editors. The owner is identified +/// by workspace username so emails never reach the client. +async fn get_draft_for_user( authed: ApiAuthed, Extension(db): Extension, Extension(user_db): Extension, - Path(w_id): Path, - Json(draft): Json, -) -> Result<(StatusCode, String)> { - let authed = maybe_refresh_folders(&draft.path, &w_id, authed, &db).await; + Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>, + axum::extract::Query(query): axum::extract::Query, +) -> Result> { + let path = path.to_path(); + // Drawer kinds keep drafts private to their owner (see + // `shares_drafts_across_users`) — also what blocks reading another user's + // secret-variable `$encrypted:` ciphertext. + if !kind.shares_drafts_across_users() { + return Err(Error::NotFound( + "drafts for this item kind are private to their owner".to_string(), + )); + } + require_can_read_path(&authed, &user_db, &w_id, kind, path).await?; - let mut tx = user_db.begin(&authed).await?; + // Username -> email, scoped to the workspace. None signals "fetch the + // legacy NULL-email row" (distinct from a username with no draft, which + // 404s below). + let owner_email: Option = if let Some(username) = &query.username { + let email = sqlx::query_scalar!( + r#"SELECT email FROM usr WHERE workspace_id = $1 AND username = $2"#, + &w_id, + username, + ) + .fetch_optional(&db) + .await?; + match email { + Some(e) => Some(e), + // The `admins` workspace has no `usr` rows (username IS the email + // there), so accept it as the owner email directly. + None if w_id == "admins" => Some(username.clone()), + None => { + return Err(Error::NotFound(format!( + "no user with username {username} in workspace" + ))) + } + } + } else { + None + }; - require_writer_of_path(&authed, &draft.path, &w_id, db, &draft.typ).await?; - - sqlx::query!( - "INSERT INTO draft - (workspace_id, path, value, typ) - VALUES ($1, $2, $3::text::json, $4) - ON CONFLICT (workspace_id, path, typ) - DO UPDATE SET value = EXCLUDED.value, created_at = now()", + let row = sqlx::query_as!( + DraftForUser, + r#"SELECT value as "value!: sqlx::types::Json>", created_at + FROM draft + WHERE workspace_id = $1 + AND path = $2 + AND typ = $3 + AND email IS NOT DISTINCT FROM $4"#, &w_id, - draft.path, - //to preserve key orders - serde_json::to_string(&draft.value).unwrap(), - draft.typ as DraftType, + path, + kind as UserDraftItemKind, + owner_email, ) - .execute(&mut *tx) + .fetch_optional(&db) .await?; - tx.commit().await?; - - Ok((StatusCode::CREATED, format!("draft {} created", draft.path))) + row.map(Json).ok_or_else(|| { + Error::NotFound(format!( + "no draft for {} at {path}", + query.username.as_deref().unwrap_or("") + )) + }) } -async fn delete_draft( - authed: ApiAuthed, - Extension(user_db): Extension, - Path((w_id, kind, path)): Path<(String, DraftType, StripPath)>, -) -> Result { - let mut tx = user_db.begin(&authed).await?; - - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND typ = $2 AND workspace_id = $3", - path.to_path(), - kind as DraftType, - w_id - ) - .execute(&mut *tx) - .await?; - tx.commit().await?; - - Ok(format!("deleted draft")) +/// The deployed table RLS resolves item-level `extra_perms` against. +/// Delegates to `UserDraftItemKind::deployed_table()` (the shared single +/// source); `None` kinds fall through to the path-only access check. +fn table_for_kind(kind: UserDraftItemKind) -> Option<&'static str> { + kind.deployed_table() } -// async fn get_draft( -// authed: ApiAuthed, -// Extension(user_db): Extension, -// Path((w_id, path)): Path<(String, StripPath)>, -// ) -> JsonResult { -// let path = path.to_path(); -// let mut tx = user_db.begin(&authed).await?; +/// Resolves to `Ok(())` if `authed` may SAVE a draft at `path`. Operators are +/// rejected outright. Two layers: +/// 1. Claim-based namespace rules (admin, own `u/`, member `g/`, writable +/// `f/`) — mirror what RLS reads from the same JWT claims, and are the +/// ENTIRE check for draft-only paths (no deployed row for RLS to use). +/// 2. An RLS write-probe on the deployed row (`SELECT ... FOR UPDATE`) for +/// what the path can't answer, above all item-level extra_perms grants. +async fn require_can_write_path( + authed: &ApiAuthed, + db: &DB, + user_db: &UserDB, + w_id: &str, + kind: UserDraftItemKind, + path: &str, +) -> Result<()> { + if authed.is_admin { + return Ok(()); + } + // Operators are read-only and never WRITE drafts. Read access is + // deliberately asymmetric: `require_can_read_path` has no operator block, + // so an operator can still READ a draft they can read via `/drafts/get`, + // mirroring their read access to deployed content. Intended. + if authed.is_operator { + return Err(Error::NotAuthorized( + "operators cannot save drafts".to_string(), + )); + } + // Cheap claim-based namespace checks first: they evaluate the same JWT + // claims RLS reads, so the outcome matches the policies while sparing the + // autosave hot path a DB round-trip. They are also the ENTIRE check for + // draft-only paths (no deployed row for RLS) — without them any member + // could plant a draft in another user's `u/` namespace, surfaced to every + // reader of the path. `require_owner_of_path` covers admin / `u/{own}` / + // folder owner; group membership and the folder WRITE bit are layered on. + if windmill_api_auth::require_owner_of_path(authed, path).is_ok() { + return Ok(()); + } + let parts: Vec<&str> = path.splitn(3, '/').collect(); + if parts.len() >= 3 { + match parts[0] { + "g" if authed.groups.iter().any(|g| g == parts[1]) => return Ok(()), + "f" => { + let folder = parts[1]; + let has_write = |a: &ApiAuthed| { + a.folders + .iter() + .any(|(name, write, owner)| name == folder && (*write || *owner)) + }; + if has_write(authed) { + return Ok(()); + } + let refreshed = + windmill_api_auth::maybe_refresh_folders(path, w_id, authed.clone(), db).await; + if has_write(&refreshed) { + return Ok(()); + } + } + _ => {} + } + } + // Defer to RLS for what the path can't answer (item-level extra_perms + // grants). Postgres applies UPDATE policies to rows locked via `SELECT + // ... FOR UPDATE`, so a returned row means the canonical write policies + // would let this user UPDATE it — no rule re-implemented here. Draft-only + // paths have no row, so the namespace rules above were the whole check. + if let Some(table) = kind.deployed_table() { + // `table` is from the closed enum, never user input. LIMIT 1 keeps the + // probe to one row lock — `script` has a row per version at the path, + // and locking the whole history would serialize against deploys. + let query = format!( + "SELECT 1 FROM {table} WHERE path = $1 AND workspace_id = $2 LIMIT 1 FOR UPDATE" + ); + let mut tx = user_db.clone().begin(authed).await?; + let row = sqlx::query_scalar::<_, i32>(&query) + .bind(path) + .bind(w_id) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + if row.is_some() { + return Ok(()); + } + } + Err(Error::NotAuthorized(format!( + "you don't have write permission on {path}" + ))) +} -// let script_o = sqlx::query_as!( -// Draft, -// r#"SELECT path, value, typ as "typ: DraftType" FROM draft WHERE path = $1 AND workspace_id = $2"#, -// path, -// w_id -// ) -// .fetch_optional(&mut *tx) -// .await?; -// tx.commit().await?; - -// let draft = not_found_if_none(script_o, "draft", path)?; -// Ok(Json(draft)) -// } +/// Resolves to `Ok(())` if `authed` can read at `path`. Three layers: +/// 1. admin → always. +/// 2. Path-prefix match against own `u/{username}` or any folder in +/// `authed.folders` (the precomputed read set, with groups + direct +/// grants already factored in). +/// 3. RLS-aware `SELECT 1` against the backing table — covers item-level +/// extra_perms grants that bypass folder/owner checks. +/// Both "not readable" and "doesn't exist" return 404 — don't leak existence. +/// +/// Operators are deliberately NOT rejected here (unlike +/// `require_can_write_path`): read-only users keep their read access to +/// deployed content, so an operator can view a collaborator's draft for the +/// cross-user kinds they can already read, while never writing one. Drawer +/// kinds never reach this (`get_draft_for_user` rejects them up front). +async fn require_can_read_path( + authed: &ApiAuthed, + user_db: &UserDB, + w_id: &str, + kind: UserDraftItemKind, + path: &str, +) -> Result<()> { + if authed.is_admin { + return Ok(()); + } + let parts: Vec<&str> = path.splitn(3, '/').collect(); + if parts.len() >= 2 { + match parts[0] { + "u" if parts[1] == authed.username => return Ok(()), + "f" => { + let folder = parts[1]; + if authed.folders.iter().any(|(name, _, _)| name == folder) { + return Ok(()); + } + } + _ => {} + } + } + if let Some(table) = table_for_kind(kind) { + let mut tx = user_db.clone().begin(authed).await?; + let query = format!("SELECT 1 FROM {table} WHERE path = $1 AND workspace_id = $2 LIMIT 1"); + let row = sqlx::query_scalar::<_, i32>(&query) + .bind(path) + .bind(w_id) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + if row.is_some() { + return Ok(()); + } + } + Err(Error::NotFound(format!("no draft visible at {path}"))) +} diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 787e0ceb93..0f5f9c40ca 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -77,8 +77,8 @@ mod capture; mod concurrency_groups; mod db; mod db_health; - mod drafts; + #[cfg(feature = "private")] pub mod ee; pub mod ee_oss; @@ -553,8 +553,8 @@ pub async fn run_server( "/concurrency_groups", concurrency_groups::workspaced_service(), ) - .nest("/embeddings", embeddings::workspaced_service()) .nest("/drafts", drafts::workspaced_service()) + .nest("/embeddings", embeddings::workspaced_service()) .nest("/favorites", favorite::workspaced_service()) .nest("/flows", flows::workspaced_service()) .nest( diff --git a/backend/windmill-api/src/mcp/utils.rs b/backend/windmill-api/src/mcp/utils.rs index c7a1b291dd..4073758ba9 100644 --- a/backend/windmill-api/src/mcp/utils.rs +++ b/backend/windmill-api/src/mcp/utils.rs @@ -41,7 +41,6 @@ pub async fn get_item_schema( sqlb.and_where("o.path = ?".bind(&path)); sqlb.and_where("o.workspace_id = ?".bind(&workspace_id)); sqlb.and_where("o.archived = false"); - sqlb.and_where("o.draft_only IS NOT TRUE"); let sql = sqlb.sql().map_err(|e| { tracing::error!("failed to build sql: {}", e); ErrorData::internal_error(format!("failed to build sql: {}", e), None) @@ -147,8 +146,7 @@ pub async fn get_items sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen .bind(&authed.username)); } sqlb.and_where("o.workspace_id = ?".bind(&workspace_id)) - .and_where("o.archived = false") - .and_where("o.draft_only IS NOT TRUE"); + .and_where("o.archived = false"); if item_type == "script" { sqlb.and_where("o.auto_kind IS NULL"); diff --git a/backend/windmill-api/src/offboarding.rs b/backend/windmill-api/src/offboarding.rs index c929a85a84..3d5342e3a5 100644 --- a/backend/windmill-api/src/offboarding.rs +++ b/backend/windmill-api/src/offboarding.rs @@ -847,8 +847,8 @@ async fn offboard_user_from_workspace<'c>( let flows_reassigned = sqlx::query_scalar!( r#"WITH inserted AS ( INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs) - SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs) + SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs FROM flow WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3 RETURNING 1 diff --git a/backend/windmill-api/src/path_autocomplete.rs b/backend/windmill-api/src/path_autocomplete.rs index 56bfd62ce5..271e9497fe 100644 --- a/backend/windmill-api/src/path_autocomplete.rs +++ b/backend/windmill-api/src/path_autocomplete.rs @@ -70,9 +70,9 @@ async fn list_paths( let mut paths: Vec = sqlx::query_scalar!( r#" SELECT path AS "path!" FROM ( - (SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND archived = false AND deleted = false AND draft_only IS NOT true LIMIT 5000) + (SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND archived = false AND deleted = false LIMIT 5000) UNION - (SELECT path FROM flow WHERE workspace_id = $1 AND archived = false AND draft_only IS NOT true LIMIT 5000) + (SELECT path FROM flow WHERE workspace_id = $1 AND archived = false LIMIT 5000) UNION (SELECT path FROM app WHERE workspace_id = $1 LIMIT 5000) UNION diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index d53998355b..0da987e675 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -457,8 +457,8 @@ async fn update_username_in_workpsace<'c>( // ---- flows ---- sqlx::query!( r#"INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at) - SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at) + SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at FROM flow WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, new_username, diff --git a/backend/windmill-api/src/utils.rs b/backend/windmill-api/src/utils.rs index d63720d9a9..4f55877e62 100644 --- a/backend/windmill-api/src/utils.rs +++ b/backend/windmill-api/src/utils.rs @@ -13,7 +13,6 @@ pub use windmill_api_auth::{check_scopes, require_devops_role, require_super_adm #[cfg(feature = "private")] pub use windmill_common::usernames::generate_instance_wide_unique_username; -pub use windmill_common::utils::WithStarredInfoQuery; #[cfg(feature = "enterprise")] pub use windmill_alerting::{ diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index afb39e2cca..dae1be0614 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -367,7 +367,6 @@ where "edited_by", "permissioned_as", "archived", - "has_draft", "error", "last_server_ping", "server_id", @@ -650,7 +649,6 @@ pub(crate) async fn tarball_workspace( { let scripts = sqlx::query_as::<_, Script>(&format!( "SELECT {} FROM script as o WHERE workspace_id = $1 AND archived = false - AND (draft_only IS NULL OR draft_only = false) AND created_at = (select max(created_at) from script where path = o.path AND \ workspace_id = $1)", windmill_common::scripts::SCRIPT_COLUMNS, @@ -786,10 +784,10 @@ pub(crate) async fn tarball_workspace( { let flows = sqlx::query_as::<_, Flow>( - "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.draft_only, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by + "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] - WHERE flow.workspace_id = $1 AND flow.archived = false AND (flow.draft_only IS NULL OR flow.draft_only = false)", + WHERE flow.workspace_id = $1 AND flow.archived = false", ) .bind(&w_id) .fetch_all(&mut *tx) @@ -838,8 +836,7 @@ pub(crate) async fn tarball_workspace( "SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path, app.extra_perms, app_version.value, app_version.created_at, app_version.created_by, app_version.raw_app, app.labels from app, app_version - WHERE app.workspace_id = $1 AND app_version.id = app.versions[array_upper(app.versions, 1)] - AND (app.draft_only IS NULL OR app.draft_only = false)", + WHERE app.workspace_id = $1 AND app_version.id = app.versions[array_upper(app.versions, 1)]", ) .bind(&w_id) .fetch_all(&mut *tx) @@ -918,7 +915,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::http::HttpTrigger; let handler = HttpTrigger; - let http_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let http_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -948,7 +945,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::websocket::WebsocketTrigger; let handler = WebsocketTrigger; - let websocket_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let websocket_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -978,7 +975,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::kafka::KafkaTrigger; let handler = KafkaTrigger; - let kafka_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let kafka_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1008,7 +1005,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::sqs::SqsTrigger; let handler = SqsTrigger; - let sqs_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let sqs_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1038,7 +1035,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::gcp::GcpTrigger; let handler = GcpTrigger; - let gcp_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let gcp_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1068,7 +1065,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::azure::AzureTrigger; let handler = AzureTrigger; - let azure_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let azure_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1098,7 +1095,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::nats::NatsTrigger; let handler = NatsTrigger; - let nats_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let nats_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1128,7 +1125,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::postgres::PostgresTrigger; let handler = PostgresTrigger; - let postgres_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let postgres_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1158,7 +1155,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::mqtt::MqttTrigger; let handler = MqttTrigger; - let mqtt_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let mqtt_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, @@ -1188,7 +1185,7 @@ pub(crate) async fn tarball_workspace( { use crate::triggers::email::EmailTrigger; let handler = EmailTrigger; - let email_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; + let email_triggers = handler.list_triggers(&mut *tx, &w_id, None, None).await?; let parent_modes = fork_parent_trigger_modes( &db, ::TABLE_NAME, diff --git a/backend/windmill-common/Cargo.toml b/backend/windmill-common/Cargo.toml index 88a7706b88..7e30547d9b 100644 --- a/backend/windmill-common/Cargo.toml +++ b/backend/windmill-common/Cargo.toml @@ -38,6 +38,7 @@ anyhow.workspace = true serde.workspace = true serde_json.workspace = true serde_yml.workspace = true +erased-serde = "0.4" chrono.workspace = true chrono-tz.workspace = true hex.workspace = true diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index c12d242737..ba42f13d80 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -102,6 +102,7 @@ pub mod teams_oss; pub mod tracing_init; pub mod trashbin; pub mod triggers; +pub mod user_drafts; pub mod usernames; pub mod users; pub mod utils; diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index 366ed75e8e..0cbcb4a1c1 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -97,7 +97,6 @@ pub async fn prefetch_cached_script( language: script.language, kind: script.kind, tag: script.tag, - draft_only: script.draft_only, envs: script.envs, dedicated_worker: script.dedicated_worker, ws_error_handler_muted: script.ws_error_handler_muted, @@ -363,7 +362,6 @@ pub async fn clone_script<'c>( language: s.language, kind: Some(s.kind), tag: s.tag, - draft_only: s.draft_only, envs: s.envs, concurrency_settings: concurrency_settings.maybe_fallback( s.runnable_settings.concurrency_key, @@ -410,14 +408,14 @@ pub async fn clone_script<'c>( INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, \ created_by, schema, is_template, extra_perms, lock, language, kind, tag, \ - draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \ + envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \ dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, \ codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels) SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, \ content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, \ - draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \ + envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \ dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, \ codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels diff --git a/backend/windmill-common/src/user_drafts.rs b/backend/windmill-common/src/user_drafts.rs new file mode 100644 index 0000000000..e008ccc658 --- /dev/null +++ b/backend/windmill-common/src/user_drafts.rs @@ -0,0 +1,536 @@ +/* + * Author: Diego Imbert + * Copyright: Windmill Labs, Inc 2026 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! Shared types and helpers for the per-user `draft` table. Lives in +//! `windmill-common` so entity crates can use it without depending on the +//! top-level `windmill-api` crate. Keep it free of HTTP/axum concerns. + +// `DraftUserRef` lives in `windmill-types` (where the list-endpoint row +// structs `ListableScript`/`ListableFlow` declare `Vec` and +// can't reach `windmill-common` without a cycle). Re-exported here so draft +// handlers keep a single import path. +pub use windmill_types::user_drafts::DraftUserRef; + +use crate::db::DB; +use crate::error::Result; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Item kinds a user can have an autosaved draft on. Must stay in lockstep +/// with the frontend `USER_DRAFT_ITEM_KINDS` and the Postgres `DRAFT_KIND` +/// enum (adding a kind also needs an `ALTER TYPE ... ADD VALUE` migration). +/// `snake_case` is the shared wire/DB encoding (HTTP params, JSON, `draft.typ`). +#[derive(sqlx::Type, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[sqlx(type_name = "DRAFT_KIND", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum UserDraftItemKind { + Script, + Flow, + App, + RawApp, + Resource, + Variable, + TriggerSchedule, + TriggerWebhook, + TriggerDefaultEmail, + TriggerEmail, + TriggerHttp, + TriggerWebsocket, + TriggerPostgres, + TriggerKafka, + TriggerNats, + TriggerMqtt, + TriggerSqs, + TriggerGcp, + TriggerAzure, + TriggerPoll, + TriggerCli, + TriggerNextcloud, + TriggerGoogle, + TriggerGithub, +} + +impl UserDraftItemKind { + /// The snake_case wire/DB string, for interpolating into dynamically-built + /// SQL (`?::DRAFT_KIND` binds want a string). + pub fn as_str(&self) -> &'static str { + match self { + UserDraftItemKind::Script => "script", + UserDraftItemKind::Flow => "flow", + UserDraftItemKind::App => "app", + UserDraftItemKind::RawApp => "raw_app", + UserDraftItemKind::Resource => "resource", + UserDraftItemKind::Variable => "variable", + UserDraftItemKind::TriggerSchedule => "trigger_schedule", + UserDraftItemKind::TriggerWebhook => "trigger_webhook", + UserDraftItemKind::TriggerDefaultEmail => "trigger_default_email", + UserDraftItemKind::TriggerEmail => "trigger_email", + UserDraftItemKind::TriggerHttp => "trigger_http", + UserDraftItemKind::TriggerWebsocket => "trigger_websocket", + UserDraftItemKind::TriggerPostgres => "trigger_postgres", + UserDraftItemKind::TriggerKafka => "trigger_kafka", + UserDraftItemKind::TriggerNats => "trigger_nats", + UserDraftItemKind::TriggerMqtt => "trigger_mqtt", + UserDraftItemKind::TriggerSqs => "trigger_sqs", + UserDraftItemKind::TriggerGcp => "trigger_gcp", + UserDraftItemKind::TriggerAzure => "trigger_azure", + UserDraftItemKind::TriggerPoll => "trigger_poll", + UserDraftItemKind::TriggerCli => "trigger_cli", + UserDraftItemKind::TriggerNextcloud => "trigger_nextcloud", + UserDraftItemKind::TriggerGoogle => "trigger_google", + UserDraftItemKind::TriggerGithub => "trigger_github", + } + } + + /// Every variant, for code that must enumerate kinds (e.g. generating + /// the `draft_only` existence SQL). + pub const ALL: [UserDraftItemKind; 24] = [ + UserDraftItemKind::Script, + UserDraftItemKind::Flow, + UserDraftItemKind::App, + UserDraftItemKind::RawApp, + UserDraftItemKind::Resource, + UserDraftItemKind::Variable, + UserDraftItemKind::TriggerSchedule, + UserDraftItemKind::TriggerWebhook, + UserDraftItemKind::TriggerDefaultEmail, + UserDraftItemKind::TriggerEmail, + UserDraftItemKind::TriggerHttp, + UserDraftItemKind::TriggerWebsocket, + UserDraftItemKind::TriggerPostgres, + UserDraftItemKind::TriggerKafka, + UserDraftItemKind::TriggerNats, + UserDraftItemKind::TriggerMqtt, + UserDraftItemKind::TriggerSqs, + UserDraftItemKind::TriggerGcp, + UserDraftItemKind::TriggerAzure, + UserDraftItemKind::TriggerPoll, + UserDraftItemKind::TriggerCli, + UserDraftItemKind::TriggerNextcloud, + UserDraftItemKind::TriggerGoogle, + UserDraftItemKind::TriggerGithub, + ]; + + /// The deployed table backing this kind, keyed by `(workspace_id, path)`. + /// SINGLE SOURCE for both the draft access check (which table RLS resolves + /// item-level `extra_perms` against) and the `draft_only` existence check. + /// `None` for kinds with no per-path backing table (webhook is a property + /// of a script/flow row; native triggers are keyed by external_id, not + /// path) — callers treat that as "no deployed counterpart": `draft_only = + /// true` and a path-only access check. + pub fn deployed_table(&self) -> Option<&'static str> { + use UserDraftItemKind::*; + match self { + Script => Some("script"), + Flow => Some("flow"), + App | RawApp => Some("app"), + Resource => Some("resource"), + Variable => Some("variable"), + TriggerSchedule => Some("schedule"), + TriggerHttp => Some("http_trigger"), + TriggerWebsocket => Some("websocket_trigger"), + TriggerPostgres => Some("postgres_trigger"), + TriggerKafka => Some("kafka_trigger"), + TriggerNats => Some("nats_trigger"), + TriggerMqtt => Some("mqtt_trigger"), + TriggerSqs => Some("sqs_trigger"), + TriggerGcp => Some("gcp_trigger"), + TriggerAzure => Some("azure_trigger"), + TriggerEmail | TriggerDefaultEmail => Some("email_trigger"), + TriggerWebhook | TriggerPoll | TriggerCli | TriggerNextcloud | TriggerGoogle + | TriggerGithub => None, + } + } + + /// Whether OTHER users' drafts at a path are visible to a viewer (the + /// "others are editing" list, owner circles, and the `get_draft_for_user` + /// View JSON / Fork endpoint). Enabled only for the full-page editor items + /// which have the cross-user draft UI. Drawer items keep drafts private to + /// their owner: they have no such UI, and exposing a secret variable draft + /// would hand out the `$encrypted:` ciphertext, which a viewer could + /// launder into plaintext via a deploy. + pub fn shares_drafts_across_users(&self) -> bool { + use UserDraftItemKind::*; + matches!(self, Script | Flow | App | RawApp) + } +} + +/// Query-string flag accepted by every "get by path" route that supports +/// the draft overlay. `#[serde(flatten)]` into a route-specific query struct +/// when the route has other query fields. +#[derive(Debug, Deserialize, Default)] +pub struct WithDraftQuery { + /// When true, attach the authed user's draft (if any) as a separate + /// `draft` field. Defaults to false so non-editor callers see the + /// deployed shape unchanged. + #[serde(default)] + pub get_draft: bool, +} + +/// One row of `other_drafts_users`: a draft on the same path owned by +/// someone other than the authed user. `username` is `None` for the legacy +/// NULL-email row, surfaced in the frontend as a "Legacy draft" entry. +#[derive(Debug, Serialize)] +pub struct OtherDraftUser { + /// `None` represents a legacy workspace-level draft (no owner). + pub username: Option, +} + +/// Response wrapper: the deployed entity untouched plus the authed user's +/// draft (if any) as a sibling `draft` field, which the frontend pairs to +/// diff/restore/discard. The deployed and the draft are NEVER merged on the +/// server — the editor's saved shape can diverge arbitrarily, so any per-kind +/// translation lives in the frontend loader. `inner` is boxed-erased so a +/// possibly MB-scale deployed payload serializes in ONE pass (no +/// `serde_json::Value` round-trip) while keeping the struct non-generic. +#[derive(Serialize)] +pub struct WithDraftOverlay { + /// Deployed payload, flattened to the top level. + #[serde(flatten)] + pub inner: Box, + pub is_draft: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_saved_at: Option>, + /// True when no deployed row exists at this path: `inner` is only a + /// best-effort stand-in synthesized from the draft and only `draft` is + /// canonical. Frontend uses this to disable "diff/reset vs deployed" and + /// skip its deployed-shape parsing of `inner`. Omitted when false. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub no_deployed: bool, + /// The user's saved draft payload (whatever shape the editor wrote). + /// Present when `get_draft=true` and a draft exists. + #[serde(skip_serializing_if = "Option::is_none")] + pub draft: Option, + /// Other users with a draft on the same path (excludes the authed user). + /// Empty list is omitted to keep the common-case response lean. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub other_drafts_users: Vec, +} + +/// List every other user (and the legacy NULL-email row, if any) with a +/// draft at `(workspace, kind, path)`. Returns usernames only — emails never +/// leave the server. LEFT JOIN against `usr` so an orphaned draft (user +/// removed from the workspace) still surfaces with `username = None`. The +/// authed user is excluded via `email <> authed_email`; the legacy row +/// matches because `email IS NULL` fails that comparison. +async fn fetch_other_drafts_users( + db: &DB, + w_id: &str, + authed_email: &str, + kind: UserDraftItemKind, + path: &str, +) -> Result> { + // The `admins` workspace has no `usr` rows (username IS the email there), + // so fall back to `d.email` when the join misses, else a real teammate's + // draft renders as a phantom "Legacy draft". The genuine NULL-email legacy + // row keeps `username = None` (its `d.email` is NULL, so the CASE yields NULL). + let rows = sqlx::query_as!( + OtherDraftUser, + r#"SELECT COALESCE(u.username, CASE WHEN d.workspace_id = 'admins' THEN d.email END) as "username?" + FROM draft d + LEFT JOIN usr u + ON u.workspace_id = d.workspace_id + AND u.email = d.email + WHERE d.workspace_id = $1 + AND d.path = $2 + AND d.typ = $3 + AND (d.email IS NULL OR d.email <> $4) + ORDER BY d.email NULLS LAST"#, + w_id, + path, + kind as UserDraftItemKind, + authed_email, + ) + .fetch_all(db) + .await?; + Ok(rows) +} + +/// If `get_draft` is true AND the authed user has a draft for +/// `(workspace, kind, path)`, attach it as `draft`. `deployed` is always +/// serialized into `inner` untouched. +pub async fn maybe_overlay_draft( + db: &DB, + w_id: &str, + email: &str, + kind: UserDraftItemKind, + path: &str, + get_draft: bool, + deployed: T, +) -> Result +where + T: serde::Serialize + Send + 'static, +{ + // Non-editor callers (worker/CLI reads of possibly MB-scale flows/apps) + // pass `get_draft = false` and render no overlay, so skip the `usr` join. + if !get_draft { + return Ok(WithDraftOverlay { + inner: Box::new(deployed), + is_draft: false, + draft_saved_at: None, + no_deployed: false, + draft: None, + other_drafts_users: Vec::new(), + }); + } + + // Independent of the authed user's OWN draft: reset-to-deployed reloads + // still need to know who else is editing this path. Only the cross-user + // kinds surface it (see `shares_drafts_across_users`). + let other_drafts_users = if kind.shares_drafts_across_users() { + fetch_other_drafts_users(db, w_id, email, kind, path).await? + } else { + Vec::new() + }; + + // Prefer the user's OWN per-user draft, falling back to the legacy + // NULL-email workspace draft. `NULLS LAST` + `LIMIT 1` drops the legacy + // row when an owned one exists. + let row = sqlx::query!( + r#"SELECT value as "value!: sqlx::types::Json>", + created_at + FROM draft + WHERE workspace_id = $1 + AND (email = $2 OR email IS NULL) + AND path = $3 + AND typ = $4 + ORDER BY email NULLS LAST + LIMIT 1"#, + w_id, + email, + path, + kind as UserDraftItemKind, + ) + .fetch_optional(db) + .await?; + + let Some(row) = row else { + return Ok(WithDraftOverlay { + inner: Box::new(deployed), + is_draft: false, + draft_saved_at: None, + no_deployed: false, + draft: None, + other_drafts_users, + }); + }; + + let draft_json: serde_json::Value = serde_json::from_str(row.value.0.get())?; + + Ok(WithDraftOverlay { + inner: Box::new(deployed), + is_draft: true, + draft_saved_at: Some(row.created_at), + no_deployed: false, + draft: Some(draft_json), + other_drafts_users, + }) +} + +/// One row of a "draft-only" list synthesis: a draft at `path` with no +/// deployed counterpart. `value` is the editor's saved JSON (each handler +/// maps it into its own `Listable*` shape). +#[derive(sqlx::FromRow)] +pub struct DraftOnlyListRow { + pub path: String, + pub value: sqlx::types::Json>, + pub created_at: DateTime, +} + +/// Fetch the authed user's draft rows at paths with NO deployed counterpart, +/// for synthesizing draft-only entries into a list response. Absence is +/// checked against `kind.deployed_table()` (the shared single source). +/// Returns empty for kinds with no path-keyed table. Callers keep their own +/// gating (`include_draft_only`, page 0, no filters) and row mapping. +pub async fn fetch_draft_only_list_rows( + db: &DB, + w_id: &str, + email: &str, + kind: UserDraftItemKind, +) -> Result> { + let Some(table) = kind.deployed_table() else { + return Ok(Vec::new()); + }; + // `table` is from the closed `deployed_table()` enum, never user input. + // `(email = $3 OR email IS NULL)` surfaces the user's own draft-only rows + // AND the legacy NULL-email rows; `DISTINCT ON (path)` with `email IS NULL` + // last collapses a path that has both to the owned row. + let sql = format!( + "SELECT DISTINCT ON (path) path, value, created_at FROM draft \ + WHERE workspace_id = $1 AND typ = $2::text::DRAFT_KIND \ + AND (email = $3 OR email IS NULL) \ + AND NOT EXISTS (SELECT 1 FROM {table} t \ + WHERE t.workspace_id = draft.workspace_id AND t.path = draft.path) \ + ORDER BY path, (email IS NULL)" + ); + let rows = sqlx::query_as::<_, DraftOnlyListRow>(&sql) + .bind(w_id) + .bind(kind.as_str()) + .bind(email) + .fetch_all(db) + .await?; + Ok(rows) +} + +/// The get-by-path draft choreography, shared by every entity's "get by path" +/// route. Given the deployed entity as an `Option` (caller maps its own "not +/// found" to `None`): +/// - `Some(deployed)` → overlay the authed user's draft (if `get_draft`). +/// - `None` + `get_draft` → draft-only response (`no_deployed = true`) when +/// a draft exists, else the caller's 404 via `not_found`. +/// - `None` without `get_draft` → the caller's 404. +pub async fn overlay_or_draft_only( + db: &DB, + w_id: &str, + email: &str, + kind: UserDraftItemKind, + path: &str, + get_draft: bool, + deployed: Option, + not_found: impl FnOnce() -> crate::error::Error, +) -> Result { + match deployed { + Some(deployed) => { + maybe_overlay_draft(db, w_id, email, kind, path, get_draft, deployed).await + } + None if get_draft => fetch_draft_only(db, w_id, email, kind, path) + .await? + .ok_or_else(not_found), + None => Err(not_found()), + } +} + +/// Delete EVERY user's draft (and the legacy NULL-email row) at a path+kind. +/// Use when the item is DELETED outright: it's gone for everyone, so leaving +/// teammates' drafts behind would orphan them forever. Discarding one's OWN +/// draft while the item lives on goes through `update_draft` with `value: null`. +/// Idempotent on the no-draft case. +pub async fn delete_all_drafts_for_path( + db: &DB, + w_id: &str, + kind: UserDraftItemKind, + path: &str, +) -> Result<()> { + sqlx::query!( + r#"DELETE FROM draft + WHERE workspace_id = $1 + AND path = $2 + AND typ = $3"#, + w_id, + path, + kind as UserDraftItemKind, + ) + .execute(db) + .await?; + Ok(()) +} + +/// Discard the deploying user's OWN draft (plus the legacy NULL-email row) +/// for a path+kind, leaving teammates' drafts intact. Use on RENAME: the +/// item moved, so the draft at the old path is orphaned (no FK to cascade). +/// Teammates keep theirs and get the StaleDraftModal on their next reload. +/// Idempotent on the no-draft case. +pub async fn delete_own_draft_for_path( + db: &DB, + w_id: &str, + kind: UserDraftItemKind, + path: &str, + email: &str, +) -> Result<()> { + sqlx::query!( + r#"DELETE FROM draft + WHERE workspace_id = $1 + AND path = $2 + AND typ = $3 + AND (email = $4 OR email IS NULL)"#, + w_id, + path, + kind as UserDraftItemKind, + email, + ) + .execute(db) + .await?; + Ok(()) +} + +/// Fetch the authed user's draft as a standalone payload, for "get by path" +/// routes when no deployed row exists but a draft might. Returns it as a +/// `WithDraftOverlay` with `inner` and `draft` both set to the same JSON and +/// `no_deployed = true`. Callers must have established no deployed row exists; +/// `Ok(None)` when there's also no draft (caller should 404). +/// +/// The draft JSON is expected to be an object (so `serde(flatten)` on `inner` +/// works); a non-object draft renders with no fields flattened. +pub async fn fetch_draft_only( + db: &DB, + w_id: &str, + email: &str, + kind: UserDraftItemKind, + path: &str, +) -> Result> { + // Own draft first, legacy NULL-email row as fallback (see `maybe_overlay_draft`). + let row = sqlx::query!( + r#"SELECT value as "value!: sqlx::types::Json>", + created_at + FROM draft + WHERE workspace_id = $1 + AND (email = $2 OR email IS NULL) + AND path = $3 + AND typ = $4 + ORDER BY email NULLS LAST + LIMIT 1"#, + w_id, + email, + path, + kind as UserDraftItemKind, + ) + .fetch_optional(db) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + + let draft_json: serde_json::Value = serde_json::from_str(row.value.0.get())?; + let other_drafts_users = if kind.shares_drafts_across_users() { + fetch_other_drafts_users(db, w_id, email, kind, path).await? + } else { + Vec::new() + }; + Ok(Some(WithDraftOverlay { + // Best-effort stand-in for the missing deployed — same JSON as `draft`. + inner: Box::new(draft_json.clone()), + is_draft: true, + draft_saved_at: Some(row.created_at), + no_deployed: true, + draft: Some(draft_json), + other_drafts_users, + })) +} + +/// Marker prefix for draft secret values encrypted at rest with the workspace +/// crypt key (`build_crypt`). Written by `update_draft` for secret variables; +/// resolved back to plaintext by the variable deploy endpoints. +pub const ENCRYPTED_DRAFT_PREFIX: &str = "$encrypted:"; + +fn draft_decrypt_error() -> crate::error::Error { + crate::error::Error::BadRequest( + "An encrypted draft secret could not be decrypted (the workspace encryption key may \ + have changed since the draft was saved). Reset the field and re-enter the secret." + .to_string(), + ) +} + +/// Decrypt a `$encrypted:`-marked draft value back to plaintext with the +/// workspace crypt key. Fails with a user-facing 400 when it doesn't decrypt +/// (e.g. the workspace key was rotated after the draft save). +pub async fn decrypt_draft_secret_value(db: &DB, w_id: &str, value: &str) -> Result { + let encrypted = value.strip_prefix(ENCRYPTED_DRAFT_PREFIX).unwrap_or(value); + let mc = crate::variables::build_crypt(db, w_id).await?; + crate::variables::decrypt(&mc, encrypted.to_string()).map_err(|_| draft_decrypt_error()) +} diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index faabd27142..0d42b95a5a 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -59,6 +59,19 @@ pub struct ListableVariable { pub edited_at: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub edited_by: Option, + /// True when this row is a per-user draft with no deployed variable + /// at the same path. Surfaced by `include_draft_only` so the frontend + /// can render a "Draft" badge and the editor can open from the draft + /// alone. `None`/omitted on rows fetched from the `variable` table. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub draft_only: Option, + /// True when the authed user has a per-user draft at this path — + /// layered over a deployed variable or a synthesized draft-only row. + /// Drives the `*` suffix on the variables page. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub is_draft: Option, } #[derive(Serialize, Deserialize, sqlx::FromRow)] diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 85bb906715..44db0a1b68 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -43,6 +43,11 @@ use windmill_common::{ db::{DbWithOptAuthed, UserDB}, error::{self, Error, JsonResult, Result}, get_database_url, + user_drafts::{ + delete_all_drafts_for_path, delete_own_draft_for_path, fetch_draft_only, + fetch_draft_only_list_rows, maybe_overlay_draft, UserDraftItemKind, WithDraftOverlay, + WithDraftQuery, + }, utils::{not_found_if_none, paginate, require_admin, Pagination, StripPath}, variables, worker::{CLOUD_HOSTED, WINDMILL_DIR}, @@ -153,6 +158,15 @@ pub struct ListableResource { pub inherited_labels: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub ws_specific: Option, + /// `Some(true)` only on synthesized draft-only rows; `None` on deployed rows. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub draft_only: Option, + /// True when the authed user has a per-user draft at this path (drives the + /// `*` suffix on the resources page). + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub is_draft: Option, } #[derive(Deserialize)] @@ -185,6 +199,9 @@ pub struct ListResourceQuery { pub value: Option, pub broad_filter: Option, pub label: Option, + /// When true, append per-user draft-only rows; picker callers leave it off + /// to stay deployed-only. See list synthesis in scripts.rs. + pub include_draft_only: Option, } #[derive(Serialize, FromRow)] @@ -252,6 +269,7 @@ async fn list_resources( Query(lq): Query, Query(pagination): Query, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, ) -> JsonResult> { let (per_page, offset) = paginate(pagination); @@ -276,6 +294,13 @@ async fn list_resources( "folder_labels(resource.workspace_id, resource.path) as inherited_labels", "ws_specific.path IS NOT NULL as ws_specific", ]) + // Scalar EXISTS flags the authed user's per-user draft without fanning rows out. + .field( + &"EXISTS(SELECT 1 FROM draft WHERE draft.workspace_id = resource.workspace_id \ + AND draft.path = resource.path AND draft.typ = 'resource' \ + AND draft.email = ?) as is_draft" + .bind(&authed.email), + ) .left() .join("variable") .on("variable.path = resource.path AND variable.workspace_id = resource.workspace_id") @@ -352,7 +377,7 @@ async fn list_resources( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let allowed = build_scope_path_predicate(&authed, "resources", "read"); - let rows = sqlx::query_as::<_, ListableResource>(&sql) + let mut rows = sqlx::query_as::<_, ListableResource>(&sql) .fetch_all(&mut *tx) .await? .into_iter() @@ -361,6 +386,101 @@ async fn list_resources( tx.commit().await?; + // Append the authed user's draft-only resources; see scripts.rs. + // `resource_type` / `resource_type_exclude` are deliberately NOT in the bail-out + // list (the resources page always passes `resource_type_exclude`); they're applied + // per-row below against the draft JSON's `resource_type` instead. + if lq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lq.path_start.is_none() + && lq.path.is_none() + && lq.description.is_none() + && lq.value.is_none() + && lq.broad_filter.is_none() + && lq.label.is_none() + { + let rt_filter: Option> = lq + .resource_type + .as_deref() + .map(|s| s.split(',').map(str::trim).collect()); + let rt_exclude: Option> = lq + .resource_type_exclude + .as_deref() + .map(|s| s.split(',').map(str::trim).collect()); + let draft_only_rows = + fetch_draft_only_list_rows(&db, &w_id, &authed.email, UserDraftItemKind::Resource) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + // ResourceEditor's `ResourceState`: { path, description, args, labels?, wsSpecific, resource_type? } + let path = v + .get("path") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + if path.is_empty() || !allowed(&path) { + continue; + } + let description = v + .get("description") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()); + let value = v.get("args").cloned(); + let resource_type = v + .get("resource_type") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + // Mirror the deployed query's resource_type narrowing for the + // synthesized rows (see the gate comment above). + if let Some(ref rts) = rt_filter { + if !rts.contains(&resource_type.as_str()) { + continue; + } + } + if let Some(ref excl) = rt_exclude { + if excl.contains(&resource_type.as_str()) { + continue; + } + } + let labels = v.get("labels").and_then(|x| { + x.as_array().map(|arr| { + arr.iter() + .filter_map(|s| s.as_str().map(|s| s.to_string())) + .collect::>() + }) + }); + let ws_specific = v.get("wsSpecific").and_then(|x| x.as_bool()); + + rows.push(ListableResource { + workspace_id: w_id.clone(), + path, + value, + description, + resource_type, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + created_by: None, + edited_at: Some(row.created_at), + is_linked: None, + is_refreshed: None, + is_oauth: None, + is_expired: None, + refresh_error: None, + account: None, + labels, + // No deployed row to inherit folder labels from. + inherited_labels: None, + ws_specific, + draft_only: Some(true), + // Synthesized rows are the authed user's draft. + is_draft: Some(true), + }); + } + } + Ok(Json(rows)) } @@ -369,13 +489,15 @@ async fn get_resource( Extension(user_db): Extension, Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { + Query(q): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("resources:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; let resource_o = sqlx::query_as!( ListableResource, + // `null::bool` columns align with the struct fields; deployed rows are never draft-only. "SELECT resource.workspace_id, resource.path, resource.value, resource.description, resource.resource_type, resource.extra_perms, resource.created_by, resource.edited_at, resource.labels, @@ -385,7 +507,9 @@ async fn get_resource( variable.path IS NOT NULL as is_linked, variable.is_oauth as \"is_oauth?\", variable.account, - ws_specific.path IS NOT NULL as ws_specific + ws_specific.path IS NOT NULL as ws_specific, + null::bool as draft_only, + null::bool as is_draft FROM resource LEFT JOIN variable ON variable.path = resource.path AND variable.workspace_id = $2 LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2 @@ -397,11 +521,30 @@ async fn get_resource( .fetch_optional(&mut *tx) .await?; tx.commit().await?; + if resource_o.is_none() && q.get_draft { + // No deployed row + `get_draft`: synthesize the response from the draft + // alone (`no_deployed = true`); see scripts.rs. + if let Some(overlay) = + fetch_draft_only(&db, &w_id, &authed.email, UserDraftItemKind::Resource, path).await? + { + return Ok(Json(overlay)); + } + } if resource_o.is_none() { explain_resource_perm_error(&path, &w_id, &db, &authed).await?; } let resource = not_found_if_none(resource_o, "Resource", path)?; - Ok(Json(resource)) + let overlay = maybe_overlay_draft( + &db, + &w_id, + &authed.email, + UserDraftItemKind::Resource, + path, + q.get_draft, + resource, + ) + .await?; + Ok(Json(overlay)) } async fn exists_resource( @@ -1168,6 +1311,13 @@ async fn delete_resource( .await?; tx.commit().await?; + // Resource gone for everyone: wipe ALL users' drafts at this path (and any linked + // variables cascaded into) so teammates' drafts don't orphan. Idempotent on no-draft. + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Resource, path).await?; + for var_path in &deleted_linked_variables { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Variable, var_path).await?; + } + handle_deployment_metadata( &authed.email, &authed.username, @@ -1437,6 +1587,14 @@ async fn delete_resources_bulk( tx.commit().await?; + // Wipe ALL users' drafts at these paths (and linked variables); see delete_resource. + for path in &deleted_paths { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Resource, path).await?; + } + for var_path in &linked_var_paths { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Variable, var_path).await?; + } + try_join_all(deleted_paths.iter().map(|path| { handle_deployment_metadata( &authed.email, @@ -1664,6 +1822,28 @@ async fn update_resource( // Detect if this was a rename operation let old_path_if_renamed = if npath != path { Some(path) } else { None }; + // On rename the draft at the OLD path orphans (no SQL FK); clear the deployer's + // own (+ legacy NULL) there, teammates keep theirs (StaleDraftModal). The linked + // variable renames alongside the resource, so its old-path draft orphans too. + if let Some(old_path) = old_path_if_renamed { + delete_own_draft_for_path( + &db, + &w_id, + UserDraftItemKind::Resource, + old_path, + &authed.email, + ) + .await?; + delete_own_draft_for_path( + &db, + &w_id, + UserDraftItemKind::Variable, + old_path, + &authed.email, + ) + .await?; + } + handle_deployment_metadata( &authed.email, &authed.username, diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index 347415f27e..72ed20cb71 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -35,6 +35,11 @@ use windmill_common::{ db::{DbWithOptAuthed, UserDB}, error::{Error, JsonResult, Result}, scripts::ScriptHash, + user_drafts::{ + decrypt_draft_secret_value, delete_all_drafts_for_path, delete_own_draft_for_path, + fetch_draft_only, fetch_draft_only_list_rows, maybe_overlay_draft, UserDraftItemKind, + WithDraftOverlay, ENCRYPTED_DRAFT_PREFIX, + }, utils::{not_found_if_none, paginate, Pagination, StripPath, WarnAfterExt}, variables::{ build_crypt, get_reserved_variables, ContextualVariable, CreateVariable, ListableVariable, @@ -109,11 +114,15 @@ struct ListVariableQuery { pub value: Option, pub broad_filter: Option, pub label: Option, + /// When true, append per-user draft-only rows; picker callers leave it off + /// to stay deployed-only. See list synthesis in scripts.rs. + pub include_draft_only: Option, } async fn list_variables( authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(w_id): Path, Query(lq): Query, Query(pagination): Query, @@ -143,6 +152,13 @@ async fn list_variables( "variable.edited_at", "variable.edited_by", ]) + // Scalar EXISTS flags the authed user's per-user draft; see resources.rs. + .field( + &"EXISTS(SELECT 1 FROM draft WHERE draft.workspace_id = variable.workspace_id \ + AND draft.path = variable.path AND draft.typ = 'variable' \ + AND draft.email = ?) as is_draft" + .bind(&authed.email), + ) .left() .join("account") .on("variable.account = account.id AND account.workspace_id = ?".bind(&w_id)) @@ -199,7 +215,7 @@ async fn list_variables( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let allowed = build_scope_path_predicate(&authed, "variables", "read"); - let rows = sqlx::query_as::<_, ListableVariable>(&sql) + let mut rows = sqlx::query_as::<_, ListableVariable>(&sql) .fetch_all(&mut *tx) .await? .into_iter() @@ -207,13 +223,102 @@ async fn list_variables( .collect::>(); tx.commit().await?; + + // Append the authed user's draft-only variables; see scripts.rs. + if lq.include_draft_only.unwrap_or(false) + && !authed.is_operator + && offset == 0 + && lq.path_start.is_none() + && lq.path.is_none() + && lq.description.is_none() + && lq.value.is_none() + && lq.broad_filter.is_none() + && lq.label.is_none() + { + let draft_only_rows = + fetch_draft_only_list_rows(&db, &w_id, &authed.email, UserDraftItemKind::Variable) + .await?; + + for row in draft_only_rows { + let v: serde_json::Value = + serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); + // VariableEditor's `VariableState`: { path, variable: { value, is_secret, description }, labels?, wsSpecific } + let path = v + .get("path") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + if path.is_empty() || !allowed(&path) { + continue; + } + let variable = v + .get("variable") + .cloned() + .unwrap_or(serde_json::Value::Null); + let is_secret = variable + .get("is_secret") + .and_then(|x| x.as_bool()) + .unwrap_or(false); + let description = variable + .get("description") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + // Secret variables never expose their value in the list response, even from a draft. + let value = if is_secret { + None + } else { + variable + .get("value") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()) + }; + let labels = v.get("labels").and_then(|x| { + x.as_array().map(|arr| { + arr.iter() + .filter_map(|s| s.as_str().map(|s| s.to_string())) + .collect::>() + }) + }); + let ws_specific = v.get("wsSpecific").and_then(|x| x.as_bool()); + + rows.push(ListableVariable { + workspace_id: w_id.clone(), + path, + value, + is_secret, + description, + extra_perms: serde_json::Value::Object(serde_json::Map::new()), + account: None, + is_oauth: None, + is_expired: None, + is_refreshed: None, + refresh_error: None, + is_linked: None, + expires_at: None, + labels, + // No deployed row to inherit folder labels from. + inherited_labels: None, + ws_specific, + edited_at: Some(row.created_at), + edited_by: None, + draft_only: Some(true), + // Synthesized rows are the authed user's draft. + is_draft: Some(true), + }); + } + } + Ok(Json(rows)) } +// `get_draft` inlined rather than flattened (axum query bool quirk); see GetScriptByPathQuery in scripts.rs. #[derive(Deserialize)] struct GetVariableQuery { decrypt_secret: Option, include_encrypted: Option, + #[serde(default)] + get_draft: bool, } async fn get_variable( @@ -222,7 +327,7 @@ async fn get_variable( Extension(db): Extension, Query(q): Query, Path((w_id, path)): Path<(String, StripPath)>, -) -> JsonResult { +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("variables:read:{}", path))?; @@ -252,6 +357,17 @@ async fn get_variable( let variable = if let Some(variable) = variable_o { variable + } else if q.get_draft { + // No deployed row + `get_draft`: fall back to the draft (see scripts.rs). + // Drop the user_db tx first since `fetch_draft_only` runs on `db`. + tx.commit().await?; + if let Some(overlay) = + fetch_draft_only(&db, &w_id, &authed.email, UserDraftItemKind::Variable, path).await? + { + return Ok(Json(overlay)); + } + explain_variable_perm_error(&path, &w_id, &db).await?; + unreachable!() } else { explain_variable_perm_error(&path, &w_id, &db).await?; unreachable!() @@ -310,7 +426,17 @@ async fn get_variable( variable }; - Ok(Json(r)) + let overlay = maybe_overlay_draft( + &db, + &w_id, + &authed.email, + UserDraftItemKind::Variable, + path, + q.get_draft, + r, + ) + .await?; + Ok(Json(overlay)) } #[derive(Deserialize)] @@ -449,8 +575,15 @@ async fn create_variable( check_path_conflict(&db, &w_id, &variable.path).await?; let value = if variable.is_secret && !already_encrypted.unwrap_or(false) { + // A restored draft sends the `$encrypted:` marker as-is; decrypt it back + // (validating against the workspace key) before the secret backend re-stores it. + let plain = if variable.value.starts_with(ENCRYPTED_DRAFT_PREFIX) { + decrypt_draft_secret_value(&db, &w_id, &variable.value).await? + } else { + variable.value.clone() + }; // Use secret backend for encryption (supports both DB and Vault) - store_secret_value(&db, &w_id, &variable.path, &variable.value).await? + store_secret_value(&db, &w_id, &variable.path, &plain).await? } else { variable.value }; @@ -647,6 +780,13 @@ async fn delete_variable( tx.commit().await?; + // Variable gone for everyone: wipe ALL users' drafts at this path (see resources.rs). + // Resource included because variables cascade-delete the linked resource at the same path. + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Variable, path).await?; + if deleted_linked_resource.is_some() { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Resource, path).await?; + } + // If variable was a secret, also delete from Vault backend (if configured) if is_secret { delete_secret_from_backend(&db, &w_id, path).await?; @@ -785,12 +925,12 @@ async fn delete_variables_bulk( ) .execute(&mut *tx) .await?; - sqlx::query!( - "DELETE FROM resource WHERE path = ANY($1) AND workspace_id = $2", + let deleted_resource_paths = sqlx::query_scalar!( + "DELETE FROM resource WHERE path = ANY($1) AND workspace_id = $2 RETURNING path", &deleted_paths, w_id ) - .execute(&mut *tx) + .fetch_all(&mut *tx) .await?; sqlx::query!( @@ -814,6 +954,14 @@ async fn delete_variables_bulk( tx.commit().await?; + // Wipe ALL users' drafts at these paths (and linked resources); see delete_variable. + for path in &deleted_paths { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Variable, path).await?; + } + for path in &deleted_resource_paths { + delete_all_drafts_for_path(&db, &w_id, UserDraftItemKind::Resource, path).await?; + } + // Delete secrets from Vault backend (if configured) for path in &secret_paths { if deleted_paths.contains(path) { @@ -918,9 +1066,15 @@ async fn update_variable( }; let value = if is_secret && !already_encrypted.unwrap_or(false) { + // Decrypt a restored draft's `$encrypted:` marker before re-storing; see create_variable. + let plain = if nvalue.starts_with(ENCRYPTED_DRAFT_PREFIX) { + decrypt_draft_secret_value(&db, &w_id, &nvalue).await? + } else { + nvalue + }; // Use secret backend for encryption (supports both DB and Vault) // Store at target_path (new path if renaming, otherwise current path) - store_secret_value(&db, &w_id, target_path, &nvalue).await? + store_secret_value(&db, &w_id, target_path, &plain).await? } else { nvalue }; @@ -1171,6 +1325,27 @@ async fn update_variable( // Detect if this was a rename operation let old_path_if_renamed = if npath != path { Some(path) } else { None }; + // On rename the old-path draft orphans (see resources.rs); the linked resource + // renames alongside the variable, so its old-path draft orphans too. + if let Some(old_path) = old_path_if_renamed { + delete_own_draft_for_path( + &db, + &w_id, + UserDraftItemKind::Variable, + old_path, + &authed.email, + ) + .await?; + delete_own_draft_for_path( + &db, + &w_id, + UserDraftItemKind::Resource, + old_path, + &authed.email, + ) + .await?; + } + handle_deployment_metadata( &authed.email, &authed.username, diff --git a/backend/windmill-test-utils/src/lib.rs b/backend/windmill-test-utils/src/lib.rs index ae2de880a4..c9c019c6e8 100644 --- a/backend/windmill-test-utils/src/lib.rs +++ b/backend/windmill-test-utils/src/lib.rs @@ -773,7 +773,6 @@ pub async fn assert_lockfile( cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: None, @@ -796,6 +795,7 @@ pub async fn assert_lockfile( on_behalf_of_email: None, assets: vec![], modules: None, + draft_only: None, }, ) .await @@ -871,7 +871,6 @@ pub async fn run_deployed_relative_imports( cache_ttl: None, dedicated_worker: None, description: "".to_string(), - draft_only: None, envs: vec![], is_template: None, kind: None, @@ -894,6 +893,7 @@ pub async fn run_deployed_relative_imports( on_behalf_of_email: None, assets: vec![], modules: None, + draft_only: None, }, ) .await diff --git a/backend/windmill-trigger-azure/src/handler_oss.rs b/backend/windmill-trigger-azure/src/handler_oss.rs index e56c76a2fd..30257203b6 100644 --- a/backend/windmill-trigger-azure/src/handler_oss.rs +++ b/backend/windmill-trigger-azure/src/handler_oss.rs @@ -26,6 +26,7 @@ impl TriggerCrud for AzureTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerAzure; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/azure_triggers"; diff --git a/backend/windmill-trigger-email/src/handler_oss.rs b/backend/windmill-trigger-email/src/handler_oss.rs index b6cac94cc2..fccbde4b96 100644 --- a/backend/windmill-trigger-email/src/handler_oss.rs +++ b/backend/windmill-trigger-email/src/handler_oss.rs @@ -29,6 +29,7 @@ impl TriggerCrud for EmailTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerEmail; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/email_triggers"; diff --git a/backend/windmill-trigger-gcp/src/handler_oss.rs b/backend/windmill-trigger-gcp/src/handler_oss.rs index 1cacf7f594..c7e444f5ac 100644 --- a/backend/windmill-trigger-gcp/src/handler_oss.rs +++ b/backend/windmill-trigger-gcp/src/handler_oss.rs @@ -26,6 +26,7 @@ impl TriggerCrud for GcpTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerGcp; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/gcp_triggers"; diff --git a/backend/windmill-trigger-http/src/handler.rs b/backend/windmill-trigger-http/src/handler.rs index 74fd748f55..7b617e1b8d 100644 --- a/backend/windmill-trigger-http/src/handler.rs +++ b/backend/windmill-trigger-http/src/handler.rs @@ -373,6 +373,7 @@ impl TriggerCrud for HttpTrigger { const TABLE_NAME: &'static str = "http_trigger"; const TRIGGER_TYPE: &'static str = "http"; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerHttp; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/http_triggers"; diff --git a/backend/windmill-trigger-kafka/src/handler_oss.rs b/backend/windmill-trigger-kafka/src/handler_oss.rs index 57e786b0ea..0445664ddb 100644 --- a/backend/windmill-trigger-kafka/src/handler_oss.rs +++ b/backend/windmill-trigger-kafka/src/handler_oss.rs @@ -29,6 +29,7 @@ impl TriggerCrud for KafkaTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerKafka; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/kafka_triggers"; diff --git a/backend/windmill-trigger-mqtt/src/handler.rs b/backend/windmill-trigger-mqtt/src/handler.rs index 49fb701241..f1a6b6ff9d 100644 --- a/backend/windmill-trigger-mqtt/src/handler.rs +++ b/backend/windmill-trigger-mqtt/src/handler.rs @@ -25,6 +25,7 @@ impl TriggerCrud for MqttTrigger { const TABLE_NAME: &'static str = "mqtt_trigger"; const TRIGGER_TYPE: &'static str = "mqtt"; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerMqtt; const SUPPORTS_SERVER_STATE: bool = true; const SUPPORTS_TEST_CONNECTION: bool = true; const ROUTE_PREFIX: &'static str = "/mqtt_triggers"; diff --git a/backend/windmill-trigger-nats/src/handler_oss.rs b/backend/windmill-trigger-nats/src/handler_oss.rs index 226bd653fd..b377774ee3 100644 --- a/backend/windmill-trigger-nats/src/handler_oss.rs +++ b/backend/windmill-trigger-nats/src/handler_oss.rs @@ -29,6 +29,7 @@ impl TriggerCrud for NatsTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerNats; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/nats_triggers"; diff --git a/backend/windmill-trigger-postgres/src/handler.rs b/backend/windmill-trigger-postgres/src/handler.rs index dc2f4776fd..10a26e0066 100644 --- a/backend/windmill-trigger-postgres/src/handler.rs +++ b/backend/windmill-trigger-postgres/src/handler.rs @@ -46,6 +46,7 @@ impl TriggerCrud for PostgresTrigger { const TABLE_NAME: &'static str = "postgres_trigger"; const TRIGGER_TYPE: &'static str = "postgres"; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerPostgres; const SUPPORTS_SERVER_STATE: bool = true; const SUPPORTS_TEST_CONNECTION: bool = true; const ROUTE_PREFIX: &'static str = "/postgres_triggers"; diff --git a/backend/windmill-trigger-sqs/src/handler_oss.rs b/backend/windmill-trigger-sqs/src/handler_oss.rs index fc72159e24..0f54a548fe 100644 --- a/backend/windmill-trigger-sqs/src/handler_oss.rs +++ b/backend/windmill-trigger-sqs/src/handler_oss.rs @@ -26,6 +26,7 @@ impl TriggerCrud for SqsTrigger { const TABLE_NAME: &'static str = ""; const TRIGGER_TYPE: &'static str = ""; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerSqs; const SUPPORTS_SERVER_STATE: bool = false; const SUPPORTS_TEST_CONNECTION: bool = false; const ROUTE_PREFIX: &'static str = "/sqs_triggers"; diff --git a/backend/windmill-trigger-websocket/src/handler.rs b/backend/windmill-trigger-websocket/src/handler.rs index df8bccaaa6..dd6b9f4363 100644 --- a/backend/windmill-trigger-websocket/src/handler.rs +++ b/backend/windmill-trigger-websocket/src/handler.rs @@ -28,6 +28,7 @@ impl TriggerCrud for WebsocketTrigger { const TABLE_NAME: &'static str = "websocket_trigger"; const TRIGGER_TYPE: &'static str = "websocket"; + const DRAFT_KIND: windmill_common::user_drafts::UserDraftItemKind = windmill_common::user_drafts::UserDraftItemKind::TriggerWebsocket; const SUPPORTS_SERVER_STATE: bool = true; const SUPPORTS_TEST_CONNECTION: bool = true; const ROUTE_PREFIX: &'static str = "/websocket_triggers"; diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 3591c88931..4f3d381569 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -16,6 +16,10 @@ use windmill_api_auth::{check_scopes, ApiAuthed}; use windmill_common::{ db::UserDB, error::{Error, JsonResult, Result}, + user_drafts::{ + delete_all_drafts_for_path, delete_own_draft_for_path, fetch_draft_only_list_rows, + overlay_or_draft_only, UserDraftItemKind, WithDraftOverlay, WithDraftQuery, + }, utils::{paginate, Pagination, StripPath}, worker::CLOUD_HOSTED, DB, @@ -60,7 +64,10 @@ pub trait TriggerCrud: Send + Sync + 'static { + for<'r> FromRow<'r, sqlx::postgres::PgRow> + Send + Sync - + Unpin; + + Unpin + // `'static` so the deployed trigger can be boxed into + // `WithDraftOverlay`'s erased-serde inner (it's an owned row). + + 'static; type TriggerConfig: Debug + DeserializeOwned @@ -77,6 +84,9 @@ pub trait TriggerCrud: Send + Sync + 'static { /// constant set by each trigger impl — it is never user-controllable. const TABLE_NAME: &'static str; const TRIGGER_TYPE: &'static str; + /// `UserDraftItemKind` for this trigger's per-user `draft` rows. Required (no + /// default) so a trigger that forgets it is a compile error, not a runtime panic. + const DRAFT_KIND: UserDraftItemKind; const SUPPORTS_SERVER_STATE: bool; const SUPPORTS_TEST_CONNECTION: bool; const ROUTE_PREFIX: &'static str; @@ -127,6 +137,11 @@ pub trait TriggerCrud: Send + Sync + 'static { &Self::ROUTE_PREFIX[1..] } + /// Accessor for `DRAFT_KIND` used at the draft-lookup call sites. + fn user_draft_item_kind() -> UserDraftItemKind { + Self::DRAFT_KIND + } + async fn create_trigger( &self, db: &DB, @@ -349,11 +364,14 @@ pub trait TriggerCrud: Send + Sync + 'static { count } + /// `authed_email = Some` adds the per-user `is_draft` flag (scalar EXISTS); + /// `None` (e.g. workspace export) leaves it omitted. async fn list_triggers( &self, tx: &mut PgConnection, workspace_id: &str, query: Option<&StandardTriggerQuery>, + authed_email: Option<&str>, ) -> Result> { let mut fields = vec![ "workspace_id", @@ -381,6 +399,19 @@ pub trait TriggerCrud: Send + Sync + 'static { .order_by("edited_at", true) .and_where("workspace_id = ?".bind(&workspace_id)); + if let Some(email) = authed_email { + // SAFETY: interpolated TABLE_NAME and draft kind are compile-time constants; email is bound. + sqlb.field( + &format!( + "EXISTS(SELECT 1 FROM draft WHERE draft.workspace_id = {t}.workspace_id \ + AND draft.path = {t}.path AND draft.typ = '{k}' AND draft.email = ?) as is_draft", + t = Self::TABLE_NAME, + k = Self::user_draft_item_kind().as_str(), + ) + .bind(&email), + ); + } + if let Some(query) = query { let (per_page, offset) = paginate(Pagination { per_page: query.per_page, page: query.page }); @@ -563,15 +594,74 @@ async fn list_triggers( Extension(handler): Extension>, authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path(workspace_id): Path, Query(query): Query, ) -> JsonResult> { let mut tx = user_db.begin(&authed).await?; - let triggers = handler - .list_triggers(&mut *tx, &workspace_id, Some(&query)) + let mut triggers = handler + .list_triggers(&mut *tx, &workspace_id, Some(&query), Some(&authed.email)) .await?; tx.commit().await?; + // Append the authed user's draft-only triggers of this kind; see scripts.rs. + // Best-effort: the editor's TriggerData shape overlaps T::Trigger but a per-kind + // config can deviate, so drop a row on deserialize failure rather than fail the list. + if query.include_draft_only.unwrap_or(false) + && !authed.is_operator + && query.page.unwrap_or(0) == 0 + && query.path.is_none() + && query.is_flow.is_none() + && query.path_start.is_none() + && query.label.is_none() + { + let draft_only_rows = fetch_draft_only_list_rows( + &db, + &workspace_id, + &authed.email, + T::user_draft_item_kind(), + ) + .await?; + + for row in draft_only_rows { + let created_at = row.created_at; + let v: serde_json::Value = match serde_json::from_str(row.value.0.get()) { + Ok(v) => v, + Err(_) => continue, + }; + let serde_json::Value::Object(mut map) = v else { + continue; + }; + // Fill operational fields the editor draft omits so the merged JSON matches + // `Trigger`'s flattened shape (mode derived from `enabled`). + map.insert( + "workspace_id".into(), + serde_json::Value::String(workspace_id.clone()), + ); + map.insert("edited_by".into(), serde_json::Value::String(String::new())); + if let Ok(at) = serde_json::to_value(&created_at) { + map.insert("edited_at".into(), at); + } + map.entry("permissioned_as") + .or_insert(serde_json::Value::String(String::new())); + map.entry("extra_perms").or_insert(serde_json::Value::Null); + if !map.contains_key("mode") { + let enabled = map.get("enabled").and_then(|x| x.as_bool()).unwrap_or(true); + map.insert( + "mode".into(), + serde_json::Value::String(if enabled { "enabled" } else { "disabled" }.into()), + ); + } + map.insert("draft_only".into(), serde_json::Value::Bool(true)); + // Synthesized rows are the authed user's draft. + map.insert("is_draft".into(), serde_json::Value::Bool(true)); + match serde_json::from_value::(serde_json::Value::Object(map)) { + Ok(t) => triggers.push(t), + Err(_) => continue, + } + } + } + Ok(Json(triggers)) } @@ -579,21 +669,41 @@ async fn get_trigger( Extension(handler): Extension>, authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path((workspace_id, path)): Path<(String, StripPath)>, -) -> JsonResult { + Query(q): Query, +) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || { format!("{}:read:{}", T::scope_domain_name(), &path) })?; let mut tx = user_db.begin(&authed).await?; - let trigger = handler + let trigger_res = handler .get_trigger_by_path(&mut *tx, &workspace_id, path) - .await?; - + .await; tx.commit().await?; - Ok(Json(trigger)) + // Map "no deployed trigger" to `None` and let the shared choreography + // handle the draft overlay / draft-only fallback / 404. + let deployed = match trigger_res { + Ok(t) => Some(t), + Err(Error::NotFound(_)) => None, + Err(e) => return Err(e), + }; + + let overlay = overlay_or_draft_only( + &db, + &workspace_id, + &authed.email, + T::user_draft_item_kind(), + path, + q.get_draft, + deployed, + || Error::NotFound(format!("Trigger not found at path: {}", path)), + ) + .await?; + Ok(Json(overlay)) } async fn update_trigger( @@ -717,6 +827,19 @@ async fn update_trigger( tx.commit().await?; + // On rename the old-path draft orphans (no SQL FK); clear the deployer's own + // (+ legacy NULL) there, teammates keep theirs (StaleDraftModal). See scripts.rs. + if path != new_path { + delete_own_draft_for_path( + &db, + &workspace_id, + T::user_draft_item_kind(), + path, + &authed.email, + ) + .await?; + } + Ok(format!("Trigger '{}' updated", path)) } @@ -724,6 +847,7 @@ async fn delete_trigger( Extension(handler): Extension>, authed: ApiAuthed, Extension(user_db): Extension, + Extension(db): Extension, Path((workspace_id, path)): Path<(String, StripPath)>, ) -> Result { let path = path.to_path(); @@ -781,6 +905,9 @@ async fn delete_trigger( tx.commit().await?; + // Trigger gone for everyone: wipe ALL users' drafts at this path; see scripts.rs. + delete_all_drafts_for_path(&db, &workspace_id, T::user_draft_item_kind(), path).await?; + Ok(format!("Trigger '{}' deleted", path)) } diff --git a/backend/windmill-trigger/src/types.rs b/backend/windmill-trigger/src/types.rs index 8b42c5b1f9..77d90bea20 100644 --- a/backend/windmill-trigger/src/types.rs +++ b/backend/windmill-trigger/src/types.rs @@ -27,6 +27,10 @@ pub struct StandardTriggerQuery { pub is_flow: Option, pub path_start: Option, pub label: Option, + /// When true, append per-user draft rows whose path has no + /// deployed trigger of this kind. Same gate as scripts/flows/apps: + /// non-operators, offset 0, no narrowing filters. + pub include_draft_only: Option, } #[derive(Debug, FromRow, Clone, Serialize, Deserialize)] @@ -42,6 +46,22 @@ pub struct BaseTrigger { pub extra_perms: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// True when this row is a per-user draft with no deployed trigger + /// at the same path. Set by `list_triggers` when the response + /// includes synthesized draft-only rows (gated on + /// `include_draft_only`). Always `None`/omitted on deployed rows + /// fetched from the trigger table. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub draft_only: Option, + /// True when the authed user has a per-user draft at this path — + /// either layered over a deployed trigger (EXISTS subquery in the + /// list SQL) or a synthesized draft-only row. Drives the `*` suffix + /// on the trigger list pages. `None`/omitted for callers that list + /// without an authed context (e.g. workspace export). + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub is_draft: Option, } #[derive(Debug, FromRow, Clone, Serialize, Deserialize)] @@ -192,6 +212,7 @@ impl Default for StandardTriggerQuery { path_start: None, is_flow: None, label: None, + include_draft_only: None, } } } @@ -260,6 +281,7 @@ mod tests { is_flow: None, path_start: None, label: None, + include_draft_only: None, }; assert_eq!(q.offset(), 100); assert_eq!(q.limit(), 50); @@ -274,6 +296,7 @@ mod tests { is_flow: None, path_start: None, label: None, + include_draft_only: None, }; assert_eq!(q.offset(), 0); assert_eq!(q.limit(), 100); diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index 14e2a14fe6..6346fe9cce 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -30,8 +30,6 @@ pub struct Flow { pub schema: Option, pub extra_perms: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] - pub draft_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub dedicated_worker: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tag: Option, @@ -83,7 +81,9 @@ pub struct ListableFlow { pub archived: bool, pub extra_perms: serde_json::Value, pub starred: bool, - pub has_draft: bool, + /// `Some(true)` only on synthesised draft-only rows; `None` on deployed rows. + /// See ListableScript in scripts.rs. + #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub draft_only: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -93,6 +93,20 @@ pub struct ListableFlow { pub deployment_msg: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// True when the authed user has a draft for this flow (draft-only or layered + /// over the deployed row). See ListableScript in scripts.rs. + #[serde(default)] + pub is_draft: bool, + /// User-typed staged path from the draft JSON's `draft_path`; `None` = unchanged. + /// See ListableScript in scripts.rs. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_path: Option, + /// Per-path draft owners driving the home-page avatar circles. + /// See ListableScript in scripts.rs. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_users: Option>>, /// Labels inherited from the parent folder, computed at read time. #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] @@ -107,7 +121,6 @@ pub struct NewFlow { #[serde(deserialize_with = "validate_flow_value")] pub value: Box, pub schema: Option, - pub draft_only: Option, pub tag: Option, pub dedicated_worker: Option, pub timeout: Option, diff --git a/backend/windmill-types/src/lib.rs b/backend/windmill-types/src/lib.rs index 4d9b96c2af..9144d61f89 100644 --- a/backend/windmill-types/src/lib.rs +++ b/backend/windmill-types/src/lib.rs @@ -19,6 +19,8 @@ pub mod schedule; pub mod scripts; #[cfg(not(target_arch = "wasm32"))] pub mod triggers; +#[cfg(not(target_arch = "wasm32"))] +pub mod user_drafts; /// Duplicated from windmill-common::worker::to_raw_value. /// windmill-types cannot depend on windmill-common (it would be circular). diff --git a/backend/windmill-types/src/s3.rs b/backend/windmill-types/src/s3.rs index 0ab1794d7e..5a7634043c 100644 --- a/backend/windmill-types/src/s3.rs +++ b/backend/windmill-types/src/s3.rs @@ -364,13 +364,11 @@ mod tests { assert_eq!(deserialized, S3Permission::READ | S3Permission::WRITE); // Unknown permissions are silently ignored - let deserialized: S3Permission = - serde_json::from_str("\"read,unknown,delete\"").unwrap(); + let deserialized: S3Permission = serde_json::from_str("\"read,unknown,delete\"").unwrap(); assert_eq!(deserialized, S3Permission::READ | S3Permission::DELETE); // All four permissions - let all: S3Permission = - serde_json::from_str("\"read,write,delete,list\"").unwrap(); + let all: S3Permission = serde_json::from_str("\"read,write,delete,list\"").unwrap(); assert_eq!( all, S3Permission::READ | S3Permission::WRITE | S3Permission::DELETE | S3Permission::LIST @@ -409,20 +407,15 @@ mod tests { ); // Region set, endpoint empty → use region - let with_region = S3Resource { - region: "ap-southeast-1".to_string(), - ..resource.clone() - }; + let with_region = S3Resource { region: "ap-southeast-1".to_string(), ..resource.clone() }; assert_eq!( with_region.endpoint_with_region_fallback(Some("ignored".to_string())), "s3.ap-southeast-1.amazonaws.com" ); // Endpoint set → return as-is - let with_endpoint = S3Resource { - endpoint: "custom.s3.endpoint.com".to_string(), - ..resource.clone() - }; + let with_endpoint = + S3Resource { endpoint: "custom.s3.endpoint.com".to_string(), ..resource.clone() }; assert_eq!( with_endpoint.endpoint_with_region_fallback(Some("ignored".to_string())), "custom.s3.endpoint.com" @@ -431,10 +424,8 @@ mod tests { #[test] fn test_lfs_methods_filesystem() { - let rules = vec![S3PermissionRule { - pattern: "**/*.csv".to_string(), - allow: S3Permission::READ, - }]; + let rules = + vec![S3PermissionRule { pattern: "**/*.csv".to_string(), allow: S3Permission::READ }]; let lfs = LargeFileStorage::FilesystemStorage(FilesystemStorage { root_path: "/data/workspace".to_string(), public_resource: Some(true), diff --git a/backend/windmill-types/src/scripts.rs b/backend/windmill-types/src/scripts.rs index a777d69790..5e5c7f313e 100644 --- a/backend/windmill-types/src/scripts.rs +++ b/backend/windmill-types/src/scripts.rs @@ -323,7 +323,7 @@ pub fn id_to_codebase_info(id: &str) -> CodebaseInfo { pub const SCRIPT_COLUMNS: &str = concat!( "workspace_id, hash, path, parent_hashes, summary, description, content, ", "created_by, created_at, archived, schema, deleted, is_template, extra_perms, ", - "lock, lock_error_logs, language, kind, tag, draft_only, envs, ", + "lock, lock_error_logs, language, kind, tag, envs, ", "dedicated_worker, ws_error_handler_muted, priority, cache_ttl, cache_ignore_s3_path, ", "timeout, delete_after_use, delete_after_secs, restart_unless_cancelled, ", "visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, ", @@ -355,8 +355,6 @@ pub struct Script { pub kind: ScriptKind, pub tag: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub draft_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub envs: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub dedicated_worker: Option, @@ -449,8 +447,10 @@ pub struct ListableScript { pub tag: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub has_draft: Option, + /// `Some(true)` only on rows synthesised from the `draft` table (never-deployed + /// items the user owns a draft for); `None` on deployed rows. Kept on the public + /// response so consumers checking `draft_only === true` keep working. + #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub draft_only: Option, pub has_deploy_errors: bool, @@ -465,6 +465,22 @@ pub struct ListableScript { pub kind: ScriptKind, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// `true` when this entry is the authed user's draft — draft-only, or a deployed + /// row the user has saved a draft on top of. Distinguishes user state from team state. + #[serde(skip_serializing_if = "is_false")] + pub is_draft: bool, + /// User-typed staged path, so the home list shows a meaningful name over the + /// autogenerated `u/{user}/draft_{uuid}`. Sourced from the draft JSON: scripts use + /// `value.path` (the Path widget binds `script.path`); flows/apps/raw apps use an + /// explicit `value.draft_path` written only when it differs from deployed. `None` = unchanged. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_path: Option, + /// Per-path draft owners (`{ username }`, `None` for the legacy NULL-email row), + /// driving the home-page avatar circles. `None` when no drafts; never an empty array. + #[sqlx(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_users: Option>>, /// Labels inherited from the parent folder, computed at read time. #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] @@ -516,7 +532,6 @@ pub struct NewScript { pub language: ScriptLang, pub kind: Option, pub tag: Option, - pub draft_only: Option, pub envs: Option>, #[serde(flatten)] pub concurrency_settings: ConcurrencySettings, @@ -574,7 +589,6 @@ impl Hash for NewScript { self.language.hash(state); self.kind.hash(state); self.tag.hash(state); - self.draft_only.hash(state); self.envs.hash(state); self.concurrency_settings.hash(state); self.debouncing_settings.hash(state); diff --git a/backend/windmill-types/src/user_drafts.rs b/backend/windmill-types/src/user_drafts.rs new file mode 100644 index 0000000000..30740fc561 --- /dev/null +++ b/backend/windmill-types/src/user_drafts.rs @@ -0,0 +1,18 @@ +//! Shared types for the per-user draft surface. +//! +//! Mirrors the `OtherDraftUser` type in `windmill-common::user_drafts` — +//! kept here so `windmill-types` row structs (ListableScript / ListableFlow / +//! ListableApp) can expose a typed `draft_users` field without taking a +//! dependency on `windmill-common`. The two structs serialize identically, +//! so the frontend doesn't notice. + +use serde::{Deserialize, Serialize}; + +/// One workspace user (or the legacy NULL-email row) with a per-user draft +/// at a given path. Used by the home-page list endpoints to feed the +/// avatar-circles inside the Draft badge. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DraftUserRef { + /// `None` represents a legacy workspace-level draft (no owner). + pub username: Option, +} diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 1071331822..d7f61151e0 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6952,6 +6952,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' azure_resource_path: type: string azure_mode: @@ -7037,6 +7057,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' local_part: type: string workspaced_local_part: @@ -7102,6 +7142,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' gcp_resource_path: type: string description: Path to the GCP resource containing service account credentials for @@ -7203,6 +7263,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' route_path: type: string description: The URL route path that will trigger this endpoint (e.g., 'api/myendpoint'). @@ -7337,6 +7417,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' kafka_resource_path: type: string description: Path to the Kafka resource containing connection configuration @@ -7440,6 +7540,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' mqtt_resource_path: type: string description: Path to the MQTT resource containing broker connection configuration @@ -7534,6 +7654,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' nats_resource_path: type: string description: Path to the NATS resource containing connection configuration @@ -7615,6 +7755,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' postgres_resource_path: type: string description: Path to the PostgreSQL resource containing connection configuration @@ -7797,6 +7957,22 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + schedule at the same path. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' inherited_labels: type: array items: @@ -7828,6 +8004,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' queue_url: type: string description: The full URL of the AWS SQS queue to poll for messages @@ -7908,6 +8104,26 @@ properties: type: array items: type: string + draft_only: + type: boolean + description: 'True when this row is a per-user draft with no deployed + + trigger at the same path. Set by list endpoints when + + \`include_draft_only=true\` synthesizes the row from the + + draft. Frontend renders a "Draft" badge. + + ' + is_draft: + type: boolean + description: 'True when the authed user has a per-user draft at this path + + (over a deployed row or a synthesized draft-only row). + + Frontend appends a \`*\` to the displayed name. + + ' url: type: string description: The WebSocket URL to connect to (can be a static URL or computed diff --git a/frontend/src/lib/coalescingRunner.svelte.ts b/frontend/src/lib/coalescingRunner.svelte.ts new file mode 100644 index 0000000000..0f687d4829 --- /dev/null +++ b/frontend/src/lib/coalescingRunner.svelte.ts @@ -0,0 +1,118 @@ +/** + * Per-key coalescing async runner: at most one task running and one pending per + * key. `submit` runs `fn` now if idle, else REPLACES the pending task (the + * displaced one never runs); the pending task starts when the running one + * settles. Collapses bursts of "save latest" to the in-flight call plus the + * most recent. Keys are independent. + */ +import { SvelteSet } from 'svelte/reactivity' + +export type CoalescingTask = () => T | Promise + +/** Rejection reason for a `submitAndWait` (or `cancel`-dropped) task discarded + * before it ran. Only awaiters see it, not fire-and-forget `submit` callers. */ +export class CoalescingDisplacedError extends Error { + constructor() { + super('coalescingRunner: pending task displaced before it could run') + this.name = 'CoalescingDisplacedError' + } +} + +export type CoalescingKeyedRunner = { + /** Fire-and-forget schedule per the policy above. */ + submit(key: string, fn: CoalescingTask): void + /** Like `submit`, but the promise resolves/rejects with `fn`'s outcome, or + * rejects with `CoalescingDisplacedError` if dropped before running. */ + submitAndWait(key: string, fn: CoalescingTask): Promise + /** Drop the pending task for `key` (returns whether there was one). Can't + * abort an in-flight task. A dropped `submitAndWait` rejects with + * `CoalescingDisplacedError`. */ + cancel(key: string): boolean + /** Reactively whether `key`'s chain is running (SvelteSet-backed). */ + isRunning(key: string): boolean +} + +type PendingTask = { + fn: CoalescingTask + resolve?: (value: unknown) => void + reject?: (reason: unknown) => void +} + +type Entry = { pending: PendingTask | undefined } + +/** + * @example + * runner.submit('k', f) // runs immediately + * runner.submit('k', g) // f running: g pending + * runner.submit('k', h) // g discarded, h pending; h runs once f settles + */ +export function createCoalescingKeyedRunner(): CoalescingKeyedRunner { + const state = new Map() + // Reactive mirror of keys with a running chain, kept in lock-step with + // `state` (SvelteSet for per-key `isRunning` subscriptions). + const runningKeys = new SvelteSet() + + async function chain(key: string, first: PendingTask): Promise { + let current: PendingTask | undefined = first + while (current) { + try { + const result = await current.fn() + current.resolve?.(result) + } catch (e) { + // Don't kill the chain on failure — later submissions must + // still run. Awaiters get the error via their promise; + // fire-and-forget callers get a console.error. + if (current.reject) current.reject(e) + else console.error('coalescingRunner: task failed', e) + } + const entry = state.get(key)! + current = entry.pending + entry.pending = undefined + } + state.delete(key) + runningKeys.delete(key) + } + + /** Set `task` pending for `key`, displacing (and rejecting) any prior + * pending. If the key is idle, start the chain. */ + function setOrDisplace(key: string, task: PendingTask): void { + const entry = state.get(key) + if (entry) { + entry.pending?.reject?.(new CoalescingDisplacedError()) + entry.pending = task + return + } + state.set(key, { pending: undefined }) + runningKeys.add(key) + void chain(key, task) + } + + function submit(key: string, fn: CoalescingTask): void { + setOrDisplace(key, { fn }) + } + + function submitAndWait(key: string, fn: CoalescingTask): Promise { + return new Promise((resolve, reject) => { + setOrDisplace(key, { + fn: fn as CoalescingTask, + resolve: resolve as (v: unknown) => void, + reject + }) + }) + } + + function cancel(key: string): boolean { + const entry = state.get(key) + if (!entry?.pending) return false + const dropped = entry.pending + entry.pending = undefined + dropped.reject?.(new CoalescingDisplacedError()) + return true + } + + function isRunning(key: string): boolean { + return runningKeys.has(key) + } + + return { submit, submitAndWait, cancel, isRunning } +} diff --git a/frontend/src/lib/coalescingRunner.test.ts b/frontend/src/lib/coalescingRunner.test.ts new file mode 100644 index 0000000000..49cca4688e --- /dev/null +++ b/frontend/src/lib/coalescingRunner.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi } from 'vitest' +import { createCoalescingKeyedRunner, CoalescingDisplacedError } from './coalescingRunner.svelte' + +/** A promise plus its resolve/reject, so a test can decide exactly when a + * task settles. */ +function deferred() { + let resolve!: (v: T) => void + let reject!: (e: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +describe('createCoalescingKeyedRunner', () => { + it('runs a submitted task immediately when the key is idle', () => { + const runner = createCoalescingKeyedRunner() + const fn = vi.fn(() => Promise.resolve()) + runner.submit('k', fn) + expect(fn).toHaveBeenCalledTimes(1) + expect(runner.isRunning('k')).toBe(true) + }) + + it('clears isRunning once the in-flight task settles', async () => { + const runner = createCoalescingKeyedRunner() + const d = deferred() + runner.submit('k', () => d.promise) + expect(runner.isRunning('k')).toBe(true) + d.resolve() + await d.promise + await Promise.resolve() + expect(runner.isRunning('k')).toBe(false) + }) + + it('coalesces a burst down to in-flight + latest-pending', async () => { + const runner = createCoalescingKeyedRunner() + const d = deferred() + const f = vi.fn(() => d.promise) + const g = vi.fn(() => Promise.resolve()) + const h = vi.fn(() => Promise.resolve()) + + runner.submit('k', f) // runs now + runner.submit('k', g) // pending + runner.submit('k', h) // displaces g; h is the only pending + + expect(f).toHaveBeenCalledTimes(1) + expect(g).not.toHaveBeenCalled() + expect(h).not.toHaveBeenCalled() + + d.resolve() + await d.promise + await Promise.resolve() + await Promise.resolve() + + expect(g).not.toHaveBeenCalled() // displaced — never ran + expect(h).toHaveBeenCalledTimes(1) + expect(runner.isRunning('k')).toBe(false) + }) + + it('keeps different keys independent', () => { + const runner = createCoalescingKeyedRunner() + const a = vi.fn(() => new Promise(() => {})) + const b = vi.fn(() => new Promise(() => {})) + runner.submit('a', a) + runner.submit('b', b) // different key → runs immediately too + expect(a).toHaveBeenCalledTimes(1) + expect(b).toHaveBeenCalledTimes(1) + expect(runner.isRunning('a')).toBe(true) + expect(runner.isRunning('b')).toBe(true) + }) + + it('submitAndWait resolves with the task result', async () => { + const runner = createCoalescingKeyedRunner() + await expect(runner.submitAndWait('k', () => Promise.resolve(42))).resolves.toBe(42) + }) + + it('submitAndWait rejects with the task error', async () => { + const runner = createCoalescingKeyedRunner() + await expect( + runner.submitAndWait('k', () => Promise.reject(new Error('boom'))) + ).rejects.toThrow('boom') + }) + + it('rejects a displaced submitAndWait with CoalescingDisplacedError', async () => { + const runner = createCoalescingKeyedRunner() + const d = deferred() + runner.submit('k', () => d.promise) // hold the key busy + const dropped = runner.submitAndWait('k', () => Promise.resolve('a')) // pending + runner.submit('k', () => Promise.resolve('b')) // displaces it + await expect(dropped).rejects.toBeInstanceOf(CoalescingDisplacedError) + d.resolve() + }) + + it('cancel drops the pending task and rejects its awaiter', async () => { + const runner = createCoalescingKeyedRunner() + const d = deferred() + const pending = vi.fn(() => Promise.resolve()) + runner.submit('k', () => d.promise) // in flight + const awaited = runner.submitAndWait('k', pending) // pending + + expect(runner.cancel('k')).toBe(true) + await expect(awaited).rejects.toBeInstanceOf(CoalescingDisplacedError) + + d.resolve() + await d.promise + await Promise.resolve() + expect(pending).not.toHaveBeenCalled() // cancelled before it could run + }) + + it('cancel returns false when there is no pending task', () => { + const runner = createCoalescingKeyedRunner() + expect(runner.cancel('k')).toBe(false) + runner.submit('k', () => new Promise(() => {})) // running, nothing pending + expect(runner.cancel('k')).toBe(false) + }) + + it('does not abort the in-flight task on cancel', async () => { + const runner = createCoalescingKeyedRunner() + const d = deferred() + const inflight = vi.fn(() => d.promise) + runner.submit('k', inflight) + runner.cancel('k') // only affects pending; nothing pending here + expect(runner.isRunning('k')).toBe(true) + d.resolve() + await d.promise + await Promise.resolve() + expect(inflight).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/lib/components/AutosaveIndicator.svelte b/frontend/src/lib/components/AutosaveIndicator.svelte new file mode 100644 index 0000000000..168f68ebd7 --- /dev/null +++ b/frontend/src/lib/components/AutosaveIndicator.svelte @@ -0,0 +1,339 @@ + + +
+ {#if flashActive} + + {#key flashKey} + + {/key} + {/if} + + {#snippet trigger()} +
+ {#if syncState === 'saving' || syncState === 'pending'} + + {:else if syncState === 'failed'} + + {:else if !autosaveEnabled} + + + {:else} + + {/if} +
+ {/snippet} + + {#snippet content()} +
+ {#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} +
+ {/snippet} +
+ {#if label} + {label} + {/if} +
+ + diff --git a/frontend/src/lib/components/CompareDrafts.svelte b/frontend/src/lib/components/CompareDrafts.svelte index 386007317c..264efb73a0 100644 --- a/frontend/src/lib/components/CompareDrafts.svelte +++ b/frontend/src/lib/components/CompareDrafts.svelte @@ -13,6 +13,7 @@ import { sendUserToast } from '$lib/toast' import { getDraftDiffValues, deployDraft, discardDraft } from '$lib/utils_draft_deploy' import { type DraftItem } from '$lib/workspaceDrafts.svelte' + import type { Kind as LayoutKind } from '$lib/utils_deployable' interface Props { currentWorkspaceId: string @@ -52,7 +53,11 @@ }: Props = $props() type Row = { - kind: DraftItem['kind'] + /** The deploy layout's `Kind` naming (`http_trigger`, `schedule`, + * ...) — the layout reads `item.kind` for the row icon. Our + * `UserDraftItemKind` naming lives in `draftKind`. */ + kind: LayoutKind + draftKind: DraftItem['kind'] path: string summary?: string draft_only: boolean @@ -63,11 +68,43 @@ return `${kind}:${path}` } + // UserDraftItemKind → the deploy layout's Kind union (drives row icons). + // Trigger kinds swap the prefix to a suffix; kinds the layout doesn't + // know (webhook, native triggers) borrow the generic 'trigger' icon. + function toLayoutKind(kind: DraftItem['kind']): LayoutKind { + if (kind === 'trigger_schedule') return 'schedule' + if (kind === 'trigger_default_email') return 'email_trigger' + if (kind.startsWith('trigger_')) { + const candidate = `${kind.slice('trigger_'.length)}_trigger` + const known = [ + 'http_trigger', + 'websocket_trigger', + 'kafka_trigger', + 'nats_trigger', + 'postgres_trigger', + 'mqtt_trigger', + 'sqs_trigger', + 'gcp_trigger', + 'azure_trigger', + 'email_trigger' + ] + return (known.includes(candidate) ? candidate : 'trigger') as LayoutKind + } + 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. - const items: Row[] = $derived(draftItems.map((d) => ({ ...d, key: getItemKey(d.kind, d.path) }))) + const items: Row[] = $derived( + draftItems.map((d) => ({ + ...d, + key: getItemKey(d.kind, d.path), + kind: toLayoutKind(d.kind), + draftKind: d.kind + })) + ) // 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 @@ -84,13 +121,22 @@ if (summaryCache[item.key]) return summaryCache[item.key] = { loading: true } try { - const r = (await (item.kind === 'script' - ? ScriptService.getScriptByPathWithDraft({ workspace: currentWorkspaceId, path: item.path }) - : item.kind === 'flow' - ? FlowService.getFlowByPathWithDraft({ workspace: currentWorkspaceId, path: item.path }) - : AppService.getAppByPathWithDraft({ + const r = (await (item.draftKind === 'script' + ? ScriptService.getScriptByPath({ + workspace: currentWorkspaceId, + path: item.path, + getDraft: true + }) + : item.draftKind === 'flow' + ? FlowService.getFlowByPath({ workspace: currentWorkspaceId, - path: item.path + path: item.path, + getDraft: true + }) + : AppService.getAppByPath({ + workspace: currentWorkspaceId, + path: item.path, + getDraft: true }))) as any summaryCache[item.key] = { deployed: r.summary, @@ -107,7 +153,12 @@ const current = items untrack(() => { for (const item of current) { - if (!item.draft_only && !item.raw_app && !summaryCache[item.key]) { + if ( + !item.draft_only && + !item.raw_app && + ['script', 'flow', 'app'].includes(item.draftKind) && + !summaryCache[item.key] + ) { void fetchDraftSummary(item) } } @@ -192,10 +243,10 @@ async function showDiff(item: Row) { if (!diffDrawer) return const reqId = ++diffRequestId - isFlow = item.kind === 'flow' + isFlow = item.draftKind === 'flow' diffDrawer.openDrawer() const { deployed, draft } = await getDraftDiffValues( - item.kind, + item.draftKind, item.path, currentWorkspaceId, item.draft_only @@ -220,7 +271,7 @@ for (const item of toDeploy) { deploymentStatus[item.key] = { status: 'loading' } const res = await deployDraft( - item.kind, + item.draftKind, item.path, currentWorkspaceId, item.draft_only, @@ -249,7 +300,7 @@ const item = discardTarget discardTarget = undefined if (!item) return - const res = await discardDraft(item.kind, item.path, currentWorkspaceId, item.draft_only) + const res = await discardDraft(item.draftKind, item.path, currentWorkspaceId, item.draft_only) if (res.success) { sendUserToast(item.draft_only ? `Deleted ${item.path}` : `Discarded draft of ${item.path}`) // discardDraft invalidated the Draft list; refresh the fork comparison. @@ -260,13 +311,43 @@ } // Editor URL for a draft item, scoped to the current workspace. Raw apps live - // under a different editor route, so map their kind accordingly. + // under a different editor route, so map their kind accordingly. Kinds whose + // editor is a drawer on a list page (variables, resources, schedules, + // triggers) link to that page with the item path as the hash anchor. + const LIST_PAGE_FOR_KIND: Partial> = { + variable: '/variables', + resource: '/resources', + trigger_schedule: '/schedules', + trigger_http: '/routes', + trigger_websocket: '/websocket_triggers', + trigger_postgres: '/postgres_triggers', + trigger_kafka: '/kafka_triggers', + trigger_nats: '/nats_triggers', + trigger_mqtt: '/mqtt_triggers', + trigger_sqs: '/sqs_triggers', + trigger_gcp: '/gcp_triggers', + trigger_azure: '/azure_triggers', + trigger_email: '/email_triggers' + } function draftEditUrl(d: Row): string | undefined { + const listPage = LIST_PAGE_FOR_KIND[d.draftKind] + if (listPage) { + return `${listPage}?workspace=${encodeURIComponent(currentWorkspaceId)}#${d.path}` + } return editUrlFor( - { kind: d.raw_app ? 'raw_app' : d.kind, path: d.path } as unknown as WorkspaceItemDiff, + { kind: d.raw_app ? 'raw_app' : d.draftKind, path: d.path } as unknown as WorkspaceItemDiff, currentWorkspaceId ) } + + // Human label for the kind badge on each row — without it a variable + // draft and a script draft at the same path are indistinguishable. + function kindLabel(kind: Row['draftKind']): string { + if (kind === 'raw_app') return 'app' + if (kind === 'trigger_schedule') return 'schedule' + if (kind.startsWith('trigger_')) return `${kind.slice('trigger_'.length)} trigger` + return kind + }
@@ -335,6 +416,7 @@ {#snippet itemActions(item)} {@const draftItem = item as unknown as Row} + {kindLabel(draftItem.draftKind)} {#if draftItem.draft_only} New {/if} diff --git a/frontend/src/lib/components/ContentSearchInner.svelte b/frontend/src/lib/components/ContentSearchInner.svelte index 8b20855193..d3030254ad 100644 --- a/frontend/src/lib/components/ContentSearchInner.svelte +++ b/frontend/src/lib/components/ContentSearchInner.svelte @@ -363,11 +363,7 @@ > Open - {/snippet} diff --git a/frontend/src/lib/components/DiffDrawer.svelte b/frontend/src/lib/components/DiffDrawer.svelte index 0f89cfc3dd..58e245d74e 100644 --- a/frontend/src/lib/components/DiffDrawer.svelte +++ b/frontend/src/lib/components/DiffDrawer.svelte @@ -87,7 +87,7 @@ | { mode: 'normal' deployed: Value - draft: Value | undefined + draft?: Value | undefined current: Value defaultDiffType?: 'deployed' | 'draft' button?: { text: string; onClick: () => void } diff --git a/frontend/src/lib/components/DraftBadge.svelte b/frontend/src/lib/components/DraftBadge.svelte index a5bcf73df5..99c5af259b 100644 --- a/frontend/src/lib/components/DraftBadge.svelte +++ b/frontend/src/lib/components/DraftBadge.svelte @@ -1,29 +1,282 @@ -{#if has_draft} - {#if draft_only} - - {#snippet text()} - Never deployed and is only a draft - {/snippet} - Draft only - - {:else} - - {#snippet text()} - Is deployed and has a draft - {/snippet} - +Draft - - {/if} +{#if showBadge} + + {#snippet trigger()} + + {#if orderedUsers.length > 0} + + + {#each visibleUsers as u, i (i)} + + {initials(u)} + + {/each} + {#if overflowCount > 0} + + +{overflowCount} + + {/if} + + {/if} + {draft_only ? 'Draft only' : 'Draft'} + + {/snippet} + {#snippet content()} +
+

+ {#if draft_users.length > 0} + {draft_only ? 'Never deployed — only a draft exists.' : 'Deployed with drafts pending.'} + {:else if draft_only} + Never deployed and is only a draft + {:else} + Is deployed and has a draft + {/if} + + {#if onlyOwnDraft} +
+ Only you can see this {kindLabel}. + {/if} +

+ {#if draft_users.length > 0} +
    + {#each orderedUsers as u, i (i)} + {@const isSelf = !!currentUsername && u.username === currentUsername} +
  • + + {initials(u)} + + + {fullLabel(u)}{isSelf ? ' (you)' : ''} + + {#if actionsEnabled && !isSelf} + + + {#if !$userStore?.operator} + + {/if} + {/if} +
  • + {/each} +
+ {/if} +
+ {/snippet} +
{/if} + + + {#snippet headerRight()} + + {/snippet} +
+
{JSON.stringify(jsonValue ?? {}, null, 2)}
+
+
diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index 698e799aad..74b7a8e8ec 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -136,7 +136,17 @@ lineNumbersMinChars?: number files?: Record | undefined extraLib?: string | undefined + /** Trailing debounce window (ms) on Monaco's onDidChangeModelContent. + * Each keystroke schedules (or reschedules) an `updateCode` call this + * far in the future. */ changeTimeout?: number + /** Hard ceiling (ms) on how long `updateCode` can be deferred while + * the user is typing continuously — measured from the FIRST + * keystroke of the burst (the leading fire). Without this cap, + * uninterrupted typing would hold the bindable `code` prop stale + * indefinitely and downstream consumers (autosave, lint, live + * preview) would never see the latest text. */ + maxChangeTimeout?: number loadAsync?: boolean key?: string | undefined class?: string | undefined @@ -177,6 +187,7 @@ files = {}, extraLib = undefined, changeTimeout = 500, + maxChangeTimeout = 1000, loadAsync = false, key = undefined, class: clazz = undefined, @@ -417,6 +428,22 @@ dispatch('change', ncode) } + /** Force-materialize the latest Monaco content into the bindable + * `code` prop right now, bypassing the trailing debounce. Use for + * explicit "save now" shortcuts (Ctrl/Cmd+S) — without this, anything + * the user typed within the last `changeTimeout` ms is still sitting + * in Monaco's buffer and downstream consumers (autosave, lint) won't + * see it. Clears the chain state so the next keystroke after this + * flush is a fresh leading fire. */ + export function flushPendingChanges(): void { + if (timeoutModel !== undefined) { + clearTimeout(timeoutModel) + timeoutModel = undefined + } + changeChainStart = undefined + updateCode() + } + export function append(code: string): void { if (editor) { const lineCount = editor.getModel()?.getLineCount() || 0 @@ -1327,6 +1354,10 @@ } let timeoutModel: number | undefined = undefined + /** Wall-clock start (ms) of the current debounce chain. Reset whenever + * the trailing fire lands — so a typing burst → pause → typing burst + * gets a fresh leading fire instead of inheriting the previous cap. */ + let changeChainStart: number | undefined = undefined async function loadMonaco() { setMonacoTypescriptOptions() console.log('path', uri) @@ -1433,10 +1464,29 @@ let ataModel: number | undefined = undefined editor?.onDidChangeModelContent((event) => { - timeoutModel && clearTimeout(timeoutModel) - timeoutModel = setTimeout(() => { + // Leading fire on the first keystroke of a burst: every + // downstream consumer (autosave's 1.5s debouncer, the + // `bind:code` chain, change listeners) sees text within the + // same tick instead of after `changeTimeout` ms of silence. + // Subsequent keystrokes within the burst are trailing-only + // (debounced by `changeTimeout`), with a hard ceiling at + // `chainStart + maxChangeTimeout` so continuous typing still + // materializes at least once per `maxChangeTimeout` window. + const now = Date.now() + if (changeChainStart === undefined) { updateCode() - }, changeTimeout) + changeChainStart = now + } + timeoutModel && clearTimeout(timeoutModel) + const fireAt = Math.min(now + changeTimeout, changeChainStart + maxChangeTimeout) + timeoutModel = setTimeout( + () => { + updateCode() + timeoutModel = undefined + changeChainStart = undefined + }, + Math.max(0, fireAt - now) + ) ataModel && clearTimeout(ataModel) ataModel = setTimeout(() => { @@ -1479,6 +1529,12 @@ editor?.addCommand(KeyMod.CtrlCmd | KeyCode.KeyS, function () { updateCode() shouldBindKey && format && format() + // Monaco swallows the keydown (addCommand prevents default and + // stops propagation), so page-level Ctrl/Cmd+S handlers never + // see it. Re-broadcast as a window event so editors that flush + // a draft on the shortcut (raw apps) can react regardless of + // which Monaco has focus. + window.dispatchEvent(new CustomEvent('wm-monaco-save-shortcut')) }) editor?.addCommand(KeyMod.CtrlCmd | KeyCode.Enter, function () { @@ -1788,6 +1844,7 @@ resultCollectionCompletor && resultCollectionCompletor.dispose() preprocessorCompletor && preprocessorCompletor.dispose() timeoutModel && clearTimeout(timeoutModel) + changeChainStart = undefined loadTimeout && clearTimeout(loadTimeout) aiChatEditorHandler?.clear() absolutePathExtraLibs.forEach((d) => d.dispose()) diff --git a/frontend/src/lib/components/EncryptedDraftField.svelte b/frontend/src/lib/components/EncryptedDraftField.svelte new file mode 100644 index 0000000000..645cf27fae --- /dev/null +++ b/frontend/src/lib/components/EncryptedDraftField.svelte @@ -0,0 +1,48 @@ + + + + + {#snippet trigger()} + + {/snippet} + {#snippet content()} +
+

+ This secret was encrypted when your draft was saved and cannot be loaded back. The last + saved value will be used as-is when you save. +

+ {#if !disabled} + + {/if} +
+ {/snippet} +
diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 24c0071a5e..43e8fe7f42 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -2,7 +2,6 @@ import { FlowService, type Flow, - DraftService, type PathScript, type OpenFlow, type InputTransform, @@ -13,7 +12,6 @@ import { initHistory, redo, undo } from '$lib/history.svelte' import { enterpriseLicense, userStore, workspaceStore, usedTriggerKinds } from '$lib/stores' import { - cleanValueProperties, generateRandomString, orderedJsonStringify, readFieldsRecursively, @@ -25,11 +23,12 @@ type Value } from '$lib/utils' import { sendUserToast } from '$lib/toast' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { Drawer } from '$lib/components/common' import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte' import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte' - import { createRawSnippet, onMount, setContext, untrack } from 'svelte' + import { createRawSnippet, setContext, untrack } from 'svelte' import { writable } from 'svelte/store' import CenteredPage from './CenteredPage.svelte' import { Button } from './common' @@ -46,7 +45,6 @@ import { GroupEditor, setGroupEditorContext } from './graph/groupEditor.svelte' import { cleanFlow } from './flows/utils.svelte' import { - Save, DiffIcon, HistoryIcon, FileJson, @@ -71,18 +69,15 @@ import { tutorialsToDo } from '$lib/stores' import { getTutorialIndex } from '$lib/tutorials/config' import EditorHeader from './EditorHeader.svelte' + import AutosaveIndicator from './AutosaveIndicator.svelte' import type { FlowBuilderWhitelabelCustomUi } from './custom_ui' import FlowYamlEditor from './flows/header/FlowYamlEditor.svelte' import { type TriggerContext, type ScheduleTrigger } from './triggers' import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes' import DeployButton from './DeployButton.svelte' import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte' - import type { FlowWithDraftAndDraftTriggers, Trigger } from './triggers/utils' - import { - deployTriggers, - filterDraftTriggers, - handleSelectTriggerFromKind - } from './triggers/utils' + import type { Trigger } from './triggers/utils' + import { deployTriggers, handleSelectTriggerFromKind } from './triggers/utils' import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte' import { Triggers } from './triggers/triggers.svelte' import { StepsInputArgs } from './flows/stepsInputArgs.svelte' @@ -118,22 +113,21 @@ disabledFlowInputs = false, savedPrimarySchedule = undefined, version = undefined, - setSavedraftCb = undefined, draftTriggersFromUrl = undefined, selectedTriggerIndexFromUrl = undefined, children, loadedFromHistoryFromUrl, noInitial = false, liveEditorDraftStoragePath = undefined, - onSaveInitial, - onSaveDraft, onDeploy, onDeployError, onDetails, - onSaveDraftError, - onSaveDraftOnlyAtNewPath, onHistoryRestore, onNavigate, + onResetToDeployed, + loadedFromDraft = false, + othersDraftsCount = 0, + onOpenOthersDrafts, onTestJob }: FlowBuilderProps = $props() @@ -262,129 +256,28 @@ } let loadingSave = $state(false) - let loadingDraft = $state(false) - export async function saveDraft(forceSave = false): Promise { - withAIChangesWarning(async () => { - await saveDraftInternal(forceSave) + // Ctrl/Cmd+S forces an immediate save of whatever the page-level + // autosave has pending. Unlike ScriptBuilder we don't have a direct + // Monaco ref to flush — any focused module Monaco's pending text is + // constrained by the editor's own ~1s max-wait cap, so the flush + // here picks up whatever's already in `pendingSaveOpts`. Worst case + // the user's very last keystroke (<1s ago) isn't in this POST and + // follows in the next autosave round. + // + // No toast — the AutosaveIndicator narrates the flush (Saving... → + // Saved / Save failed). A toast here would also lie on network + // failure: `flush` never rejects (postSave catches and routes errors + // to the failures map), so the success branch fired regardless. + export async function saveDraft(): Promise { + if (!$workspaceStore || !liveEditorDraftStoragePath) return + await UserDraftDbSyncer.flush({ + workspace: $workspaceStore, + itemKind: 'flow', + path: liveEditorDraftStoragePath }) } - async function saveDraftInternal(forceSave = false): Promise { - if (!newFlow && !savedFlow) { - return - } - - if (savedFlow) { - const draftOrDeployed = cleanValueProperties(savedFlow.draft || savedFlow) - const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot()) - const current = cleanValueProperties( - $state.snapshot({ - ...flowStore.val, - path: $pathStore, - draft_triggers: currentDraftTriggers - }) - ) - if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) { - sendUserToast('No changes detected, ignoring', false, [ - { - label: 'Save anyway', - callback: () => { - saveDraftInternal(true) - } - } - ]) - return - } - } - loadingDraft = true - try { - const flow = cleanFlow(flowStore.val) - if (newFlow || savedFlow?.draft_only) { - if (savedFlow?.draft_only) { - await FlowService.deleteFlowByPath({ - workspace: $workspaceStore!, - path: initialPath, - keepCaptures: true - }) - } - if (!initialPath || $pathStore != initialPath) { - await CaptureService.moveCapturesAndConfigs({ - workspace: $workspaceStore!, - path: initialPath || fakeInitialPath, - requestBody: { - new_path: $pathStore - }, - runnableKind: 'flow' - }) - } - await FlowService.createFlow({ - workspace: $workspaceStore!, - requestBody: { - path: $pathStore, - summary: flow.summary ?? '', - description: flow.description ?? '', - value: flow.value, - schema: flow.schema, - tag: flow.tag, - draft_only: true, - ws_error_handler_muted: flow.ws_error_handler_muted, - visible_to_runner_only: flow.visible_to_runner_only, - on_behalf_of_email: flow.on_behalf_of_email, - labels: (flow as any).labels - } - }) - } - await DraftService.createDraft({ - workspace: $workspaceStore!, - requestBody: { - path: newFlow || savedFlow?.draft_only ? $pathStore : initialPath, - typ: 'flow', - value: { - ...flow, - path: $pathStore, - draft_triggers: triggersState.getDraftTriggersSnapshot() - } - } - }) - - savedFlow = { - ...(newFlow || savedFlow?.draft_only - ? { - ...structuredClone($state.snapshot(flowStore.val)), - path: $pathStore, - draft_only: true - } - : savedFlow), - draft: { - ...structuredClone($state.snapshot(flowStore.val)), - path: $pathStore, - draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot()) - } - } as FlowWithDraftAndDraftTriggers - - let savedAtNewPath = false - if (newFlow) { - onSaveInitial?.({ path: $pathStore, id: getSelectedId() ?? 'settings' }) - } else if (savedFlow?.draft_only && $pathStore !== initialPath) { - savedAtNewPath = true - initialPath = $pathStore - onSaveDraftOnlyAtNewPath?.({ path: $pathStore, selectedId: getSelectedId() ?? 'settings' }) - // this is so we can use the flow builder outside of sveltekit - } - onSaveDraft?.({ path: $pathStore, savedAtNewPath, newFlow }) - sendUserToast('Saved as draft') - } catch (error) { - sendUserToast(`Error while saving the flow as a draft: ${error.body || error.message}`, true) - onSaveDraftError?.({ error }) - } - loadingDraft = false - } - - onMount(() => { - setSavedraftCb?.(() => saveDraft()) - }) - export function computeUnlockedSteps(flow: Flow) { return Object.fromEntries( getAllModules(flow.value.modules, flow.value.failure_module) @@ -401,7 +294,7 @@ async function handleSaveFlowInternal(deploymentMsg?: string) { await compareVersions() - if (onLatest || initialPath == '' || savedFlow?.draft_only) { + if (onLatest || initialPath == '' || newFlow) { // Handle directly await saveFlow(deploymentMsg) } else { @@ -678,7 +571,7 @@ [ { type: 'webhook', path: '', isDraft: false }, { type: 'default_email', path: '', isDraft: false }, - ...(untrack(() => draftTriggersFromUrl) ?? savedFlow?.draft?.draft_triggers ?? []) + ...(untrack(() => draftTriggersFromUrl) ?? []) ], untrack(() => selectedTriggerIndexFromUrl) ) @@ -707,10 +600,6 @@ $primaryScheduleStore, $userStore ) - - if (savedFlow && savedFlow.draft) { - savedFlow = filterDraftTriggers(savedFlow, triggersState) as FlowWithDraftAndDraftTriggers - } } function handleUndo() { @@ -812,7 +701,7 @@ }> = [] if (untrack(() => customUi).topBar?.extraDeployOptions != false) { - if (savedFlow?.draft_only === false || savedFlow?.draft_only === undefined) { + if (!newFlow) { dropdownItems.push({ label: 'Exit & see details', // Use the deployed path, not the live `$pathStore` — the latter @@ -846,7 +735,6 @@ diffDrawer?.setDiff({ mode: 'normal', deployed: deployedValue ?? savedFlow, - draft: savedFlow?.draft, current: { ...currentFlow, path: $pathStore, @@ -891,25 +779,7 @@ const mod = isMac() ? '⌘' : 'Ctrl+' function getMoreItems(): Item[] { - // When the top bar is compact, fold the inline Diff + Save draft buttons - // in here so they stay reachable. Save draft keeps its keyboard shortcut. - const compactExtras: Item[] = compactTopbar - ? [ - ...(customUi?.topBar?.draft !== false - ? [ - { - displayName: 'Save draft', - icon: Save, - action: () => saveDraft(), - shortcut: `${mod}S`, - disabled: (!newFlow && !savedFlow) || loading - } - ] - : []) - ] - : [] return [ - ...compactExtras, ...baseMenuItems, { displayName: 'Undo', @@ -917,7 +787,7 @@ action: () => handleUndo(), disabled: $history.index === 0, shortcut: `${mod}Z`, - separatorTop: compactExtras.length > 0 || baseMenuItems.length > 0 + separatorTop: baseMenuItems.length > 0 }, { displayName: 'Redo', @@ -1030,17 +900,7 @@ ] } - function handleDeployTrigger(trigger: Trigger) { - const { id, path, type } = trigger - //Update the saved flow to remove the draft trigger that is deployed - if (savedFlow && savedFlow.draft && savedFlow.draft.draft_triggers) { - const newSavedDraftTrigers = savedFlow.draft.draft_triggers.filter( - (t) => t.id !== id || t.path !== path || t.type !== type - ) - savedFlow.draft.draft_triggers = - newSavedDraftTrigers.length > 0 ? newSavedDraftTrigers : undefined - } - } + function handleDeployTrigger(_trigger: Trigger) {} let forceTestTab: Record = $state({}) let highlightArg: Record = $state({}) @@ -1070,6 +930,29 @@ if (p) untrack(() => ($pathStore = p)) }) + // Persist the user-typed path into the draft JSON as `draft_path` + // when it differs from the deployed/seeded `flow.path`. The Path + // widget binds `$pathStore` one-way to the popover input — without + // this, the friendly auto-name on `/flows/add` and any in-place + // rename never reach the autosaved Flow, so the home-list draft row + // kept showing the autogenerated `u/{user}/draft_{uuid}` slot. Drop + // the field once it matches the baseline again so it doesn't + // linger after a revert; deploy clears the whole draft, so the + // field naturally disappears post-deploy too. + $effect(() => { + const typed = $pathStore + const baseline = (flowStore.val as Flow | undefined)?.path ?? '' + const flow = flowStore.val as (Flow & { draft_path?: string }) | undefined + if (!flow) return + untrack(() => { + if (typed && typed !== baseline) { + flow.draft_path = typed + } else if (flow.draft_path !== undefined) { + delete (flow as any).draft_path + } + }) + }) + $effect.pre(() => { selectedId && untrack(() => select(selectedId)) }) @@ -1193,7 +1076,7 @@ bind:clientWidth={topbarWidth} class="justify-between flex flex-row items-center pl-2 pr-4 space-x-4 scrollbar-hidden overflow-x-auto max-h-12 h-full relative" > -
+
onNavigate?.(item)} /> + {#if $workspaceStore && liveEditorDraftStoragePath !== undefined} + + {/if}
{#if $enterpriseLicense && !newFlow} @@ -1219,9 +1114,11 @@ variant="default" unifiedSize="md" on:click={() => openDiffDrawer()} - disabled={!savedFlow} + disabled={!savedFlow || newFlow} iconOnly={compactTopbar} - title="Diff" + title={newFlow + ? 'Deploy this flow once to compare against the deployed version' + : 'Diff'} startIcon={{ icon: DiffIcon }} > Diff @@ -1230,19 +1127,6 @@ {#if !compactTopbar} {@render previewButtons()} {/if} - {#if customUi?.topBar?.draft !== false && !compactTopbar} - - {/if} await handleSaveFlow(detail)} diff --git a/frontend/src/lib/components/LocalDraftBanner.svelte b/frontend/src/lib/components/LocalDraftBanner.svelte index 2fb2de4c11..4aa2206f5b 100644 --- a/frontend/src/lib/components/LocalDraftBanner.svelte +++ b/frontend/src/lib/components/LocalDraftBanner.svelte @@ -2,7 +2,12 @@ import { Button } from '$lib/components/common' import DiffDrawer from '$lib/components/DiffDrawer.svelte' import { classes } from '$lib/components/common/alert/model' - import { type Value } from '$lib/utils' + import { + cleanValueProperties, + orderedYamlStringify, + replaceFalseWithUndefined, + type Value + } from '$lib/utils' import { AlertCircle, Diff } from 'lucide-svelte' import { twMerge } from 'tailwind-merge' import { slide } from 'svelte/transition' @@ -31,6 +36,34 @@ title = 'Deployed <> Local changes' }: Props = $props() + /** Same cleaning + YAML serialization the DiffDrawer applies before + * comparing. Without it the banner would fire on differences the + * drawer treats as no-op (toggle defaults, `false ↔ undefined`, + * key ordering noise) — exactly the case where the user clicks + * "Show diff" and sees the "No changes detected" empty state. */ + function diffKey(value: unknown): string { + try { + return orderedYamlStringify(cleanValueProperties(replaceFalseWithUndefined(value as Value))) + } catch { + return '' + } + } + + // Suppress the banner when: + // • There's no deployed baseline (brand-new entity — "Show diff" + // would early-return and "Discard" is semantically backwards), + // OR + // • Deployed and current are equal under the DiffDrawer's own + // comparison. The callers' `show` is a coarser "form differs + // from baseline" check that can stale-fire after a save lands + // or when `false`/`undefined` toggle noise flips a field. + let visible = $derived.by(() => { + if (!show) return false + const deployed = getDeployed() + if (deployed == null) return false + return diffKey(deployed) !== diffKey(getCurrent()) + }) + let diffDrawer: DiffDrawer | undefined = $state() function showDiff() { @@ -66,7 +99,7 @@ -{#if show} +{#if visible}
onMetaChange()) }) + // Reflect an EXTERNAL `path` change back into `meta` (which drives the + // owner/name inputs). The effect above is one-way meta→path; without + // this counterpart, a parent that reassigns `path` — e.g. "Discard" + // reverting a draft to the deployed value — leaves the inputs showing + // the stale value until a remount. Guard against a meta↔path loop: skip + // when `path` already matches what `meta` produces (the meta→path write), + // and only adopt a derivation that round-trips cleanly (so a malformed + // path left to `initPath`/`reset` can't oscillate). + $effect.pre(() => { + const p = path + untrack(() => { + if (p == undefined || p == '' || p.startsWith('tmp/') || p.startsWith('hub/')) return + if (!meta || metaToPath(meta) === p) return + const next = pathToMeta(p, hideUser) + if (metaToPath(next) === p) { + meta = next + } + }) + }) $effect.pre(() => { if ($workspaceStore && $userStore) { untrack(() => { diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 126ab0dc7f..d503763a06 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -10,11 +10,10 @@ import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte' import Alert from './common/alert/Alert.svelte' import { resource } from 'runed' - import { deepEqual } from 'fast-equals' import { getUserExt } from '$lib/user' import type { UserExt } from '$lib/stores' - import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte' - import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte' + import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte' + import { setLocalDraftHint } from '$lib/localDraftHints.svelte' interface Props { canSave?: boolean @@ -75,53 +74,16 @@ let existedInitially: Record = $state({}) let fetchedResources: Record = $state({}) let perWsUser: Record = $state({}) - // Backend `edited_at` per workspace — the rev the staleness check - // compares the local autosave's recorded rev against. Resources have - // no DB-draft concept, so only `remoteRev` is ever populated. - let fetchedRev: Record = $state({}) - - // Local-draft staleness modal: opened when the backend resource moved - // on (someone else edited it) since the local autosave was written. - let staleModalOpen = $state(false) - let pendingStale: { ws: string; backend: ResourceState } | undefined = undefined - - function onStaleLoadLatest(): void { - if (!pendingStale) { - staleModalOpen = false - return - } - const { ws, backend } = pendingStale - // Drop the divergent autosave and reset the handle to the freshly - // fetched backend state. A later edit re-creates the autosave and - // the seeding effect records the new rev. - UserDraft.discard('resource', initialPath ?? '', backend, { workspace: ws }) - initialStates[ws] = $state.snapshot(backend) as ResourceState - pendingStale = undefined - staleModalOpen = false - } - - function onStaleKeepDraft(): void { - if (pendingStale) { - const { ws } = pendingStale - // Ack the new backend rev so the modal doesn't fire again until - // the backend moves once more. Keeps the local autosave intact. - UserDraft.saveMeta( - 'resource', - initialPath ?? '', - { remoteRev: fetchedRev[ws] }, - { workspace: ws } - ) - } - pendingStale = undefined - staleModalOpen = false - } const handlesArray = UserDraft.useMany(() => workspaceSpecs.map((s) => ({ itemKind: 'resource' as const, path: initialPath ?? '', workspace: s.ws, - defaultValue: s.defaultValue + defaultValue: s.defaultValue, + // Autosaves landing back on the deployed value become deletes; + // `existedInitially` guards draft-only items from self-destructing. + discardIf: (val) => !!existedInitially[s.ws] && draftValuesEqual(val, initialStates[s.ws]) })) ) const states = $derived.by(() => { @@ -195,9 +157,24 @@ ) const dirtyWorkspaces = $derived( - Object.keys(states).filter((ws) => !deepEqual(states[ws].draft, initialStates[ws])) + Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws])) ) const anyDirty = $derived(dirtyWorkspaces.length > 0) + + // The syncer owns the list-page `*` hint; the editor only CLEARS it when a + // workspace is at the deployed baseline (so a draft discarded elsewhere + // vanishes on reopen). Never SET here. See VariableEditor for the full note. + $effect(() => { + const p = initialPath + const loadedWs = Object.keys(states) + const dirty = dirtyWorkspaces + untrack(() => { + if (!p) return + for (const ws of loadedWs) { + if (!dirty.includes(ws)) setLocalDraftHint(ws, 'resource', p, false) + } + }) + }) // Banner is scoped to the selected workspace — the diff/discard only // operate on it, so showing it for an unrelated dirty workspace would be // misleading. The cross-workspace `otherDirty` alert below still covers @@ -250,49 +227,28 @@ if (ws in states) return untrack(() => { Promise.all([ - ResourceService.getResource({ workspace: ws, path: initialPath }), + ResourceService.getResource({ workspace: ws, path: initialPath, getDraft: true }), getUserExt(ws) ]).then(([r, user]) => { + // `.draft` already holds the editor's `ResourceState` shape. + const savedDraftState = (r as any).draft as ResourceState | undefined fetchedResources[ws] = r - fetchedRev[ws] = r.edited_at - const s: ResourceState = { + // Deployed baseline as the dirty-check reference, so the banner + // compares draft-vs-deployed and fires immediately when a draft exists. + const deployedState: ResourceState = { path: r.path, description: r.description ?? '', args: (r.value ?? {}) as any, labels: r.labels ?? undefined, wsSpecific: r.ws_specific ?? false } - // Reconcile the local autosave with the backend before the - // handle is registered. If the backend moved on since the - // autosave was written (recorded rev != current rev) surface - // the staleness modal; otherwise the form is just showing the - // user's unsaved work — a toast with a "Reset to deployed" - // escape is enough. - const persisted = UserDraft.get('resource', initialPath ?? '', { - workspace: ws - }) - const previousMeta = UserDraft.getMeta('resource', initialPath ?? '', { workspace: ws }) - if (persisted !== undefined && !deepEqual(persisted, s)) { - const cause = checkStaleness(previousMeta, r.edited_at) - if (cause) { - pendingStale = { ws, backend: s } - staleModalOpen = true - } else { - if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) { - // Legacy autosave (no rev recorded) — backfill so the - // next backend change is detectable as drift. - UserDraft.saveMeta( - 'resource', - initialPath ?? '', - { remoteRev: r.edited_at }, - { workspace: ws } - ) - } - } - } + // Open with the saved draft if present, else the deployed. + const s: ResourceState = savedDraftState ?? deployedState ensureHandle(ws, s) - initialStates[ws] = structuredClone(s) - existedInitially[ws] = true + initialStates[ws] = structuredClone(deployedState) + // Draft-only paths (`no_deployed`) have no row — saving must + // CREATE, not update (update 404s). + existedInitially[ws] = !(r as any).no_deployed perWsUser[ws] = user // Keep resource_type in sync for the base workspace (controls the schema) if (ws === effectiveWorkspace) { @@ -302,25 +258,6 @@ }) }) - // Seed the staleness rev the moment a real autosave appears. Until the - // user's first edit diverges the handle's draft from the backend - // baseline there's no autosave to attach a rev to; once it does, record - // the backend rev captured at fetch time so a later external edit is - // detectable as drift on the next open. Self-limiting: after the write - // `meta.remoteRev` is set so the guard fails on the re-run. - $effect(() => { - for (const ws of Object.keys(states)) { - const h = states[ws] - const rev = fetchedRev[ws] - const baseline = initialStates[ws] - if (!h || rev === undefined || baseline === undefined) continue - const draft = h.draft - if (draft === undefined || deepEqual(draft, baseline)) continue - if (h.meta.remoteRev !== undefined || h.meta.remoteDraftRev !== undefined) continue - untrack(() => h.setMeta({ remoteRev: rev })) - } - }) - // Keep current.path bound to the outer `path` prop for consumers $effect(() => { if (current) path = current.path @@ -417,13 +354,11 @@ } }) } - // Saved on the backend — drop the local autosave for this - // workspace and refresh the dirty baseline. `s` is the - // UserDraft handle's draft, a Svelte $state proxy; - // `structuredClone` can't clone a proxy, so snapshot it to a - // plain object first. + // Reset the handle to the new deployed baseline via `discard`, not + // `remove`. See VariableEditor for the full rationale. initialStates[ws] = $state.snapshot(s) as ResourceState - UserDraft.remove('resource', initialPath ?? '', { workspace: ws }) + existedInitially[ws] = true + UserDraft.discard('resource', initialPath ?? '', s, { workspace: ws }) // Path now exists server-side — drop the autocomplete cache so // it shows up immediately instead of after the 60s TTL. invalidateWorkspacePaths(ws) @@ -438,13 +373,6 @@ } - -
{#if otherDirty.length > 0} diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index f5f59cec34..f4d7e3e17d 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -3,10 +3,9 @@ const bubble = createBubbler() import { - DraftService, ScriptService, - type NewScriptWithDraft, type Script, + type NewScript, type TriggersCount, PostgresTriggerService, CaptureService, @@ -31,7 +30,6 @@ workspaceStore } from '$lib/stores' import { - cleanValueProperties, emptySchema, emptyString, generateRandomString, @@ -59,14 +57,13 @@ EllipsisVertical, Plus, Rocket, - Save, Settings, Shuffle, Tag, X } from 'lucide-svelte' import DropdownV2 from './DropdownV2.svelte' - import { isMac, type Item } from '$lib/utils' + import { type Item } from '$lib/utils' import { sendUserToast } from '$lib/toast' import { isCloudHosted } from '$lib/cloud' import Awareness from './Awareness.svelte' @@ -82,8 +79,9 @@ import { writable } from 'svelte/store' import { defaultScriptLanguages, processLangs } from '$lib/scripts' import DefaultScripts from './DefaultScripts.svelte' - import { getContext, onMount, setContext, untrack } from 'svelte' + import { getContext, onMount, setContext, tick, untrack } from 'svelte' import EditorHeader from './EditorHeader.svelte' + import AutosaveIndicator from './AutosaveIndicator.svelte' import LabelsInput from './LabelsInput.svelte' import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte' @@ -92,13 +90,7 @@ import CaptureTable from './triggers/CaptureTable.svelte' import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes' import DeployButton from './DeployButton.svelte' - import { - type NewScriptWithDraftAndDraftTriggers, - type Trigger, - deployTriggers, - filterDraftTriggers, - handleSelectTriggerFromKind - } from './triggers/utils' + import { type Trigger, deployTriggers, handleSelectTriggerFromKind } from './triggers/utils' import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte' import { Triggers } from './triggers/triggers.svelte' import type { ScriptBuilderProps } from './script_builder' @@ -109,11 +101,14 @@ import { buildForkEditUrl } from '$lib/utils/editInFork' import OnBehalfOfSelector, { type OnBehalfOfChoice } from './OnBehalfOfSelector.svelte' import WacExportDrawer from './scripts/WacExportDrawer.svelte' + import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' let { script = $bindable(), fullyLoaded = true, initialPath = $bindable(''), + userDraftPath = '', template = $bindable('script'), initialArgs = {}, lockedLanguage = false, @@ -129,15 +124,16 @@ children, onDeploy, onDeployError, - onSaveInitial, onSeeDetails, - onSaveDraftError, - onSaveDraft, onNavigate, onTestJob, disableAi, initialTestPanelCollapsed = false, - initialPathChosen = false + initialPathChosen = false, + onResetToDeployed, + loadedFromDraft = false, + othersDraftsCount = 0, + onOpenOthersDrafts }: ScriptBuilderProps = $props() export function getInitialAndModifiedValues(): SavedAndModifiedValue { @@ -166,18 +162,10 @@ // Top-bar responsive collapse — container width, not viewport. let topbarWidth = $state(0) const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 720) - const mod = isMac() ? '⌘' : 'Ctrl+' function getCompactMenuItems(): Item[] { const hasTags = ($workerTags?.length ?? 0) > 0 return [ - { - displayName: 'Save draft', - icon: Save, - action: () => saveDraft(), - shortcut: `${mod}S`, - disabled: initialPath != '' && !savedScript - }, ...(customUi?.topBar?.tagEdit != false && hasTags ? [ { @@ -292,13 +280,6 @@ $primaryScheduleStore, $userStore ) - - if (savedScript && savedScript.draft && savedScript.draft.draft_triggers) { - savedScript = filterDraftTriggers( - savedScript, - triggersState - ) as NewScriptWithDraftAndDraftTriggers - } } // Add triggers context store @@ -369,9 +350,12 @@ let pathError = $state('') let loadingSave = $state(false) - let loadingDraft = $state(false) if (script.content == '') { + // Suspend autosave around the bootstrap mutations: seeding the template + // content is a programmatic write, not the user's first edit. The handle + // keys on `userDraftPath` (URL path), not the editor-displayed `initialPath`. + UserDraft.stopSync('script', userDraftPath) if (template === 'wac_python') { script.modules = { 'helper.py': { @@ -387,7 +371,36 @@ } } } - initContent(script.language, script.kind, template) + // Sync resumes only after two cascades settle: the async `initContent`, + // and the stores-gated `initPath → reset → onMetaChange → bind:path` + // auto-naming chain. Whichever lands last calls `tryRestart`; otherwise + // the auto-generated path posts as the first "user edit". + let initContentDone = false + let storesReady = !!($userStore && $workspaceStore) + let restarted = false + async function tryRestart() { + if (restarted || !initContentDone || !storesReady) return + // 500ms covers the bind:path cascade even on cold reload; two ticks + // weren't enough (bind:path fired ~100ms after restart, posting an edit). + await new Promise((r) => setTimeout(r, 500)) + if (restarted) return + restarted = true + UserDraft.restartSync('script', userDraftPath) + } + initContent(script.language, script.kind, template).finally(() => { + initContentDone = true + void tryRestart() + }) + // Cold reload: auth stores may load after mount; the `restarted` guard + // makes the effect self-cleaning. + if (!storesReady) { + $effect(() => { + if ($userStore && $workspaceStore) { + storesReady = true + untrack(() => void tryRestart()) + } + }) + } } async function isTemplateScript() { @@ -427,8 +440,13 @@ | 'ci_test_python' ) { scriptEditor?.disableCollaboration() + // Seed synchronously so a Deploy before the async template fetch resolves + // doesn't run `inferArgs` on empty content and toast "Could not parse code". + script.content = initialCode(language, kind, template, false) const templateScript = await isTemplateScript() - script.content = initialCode(language, kind, template, templateScript != undefined) + if (templateScript) { + script.content = initialCode(language, kind, template, true) + } if (templateScript) { script.content += '\r\n' + templateScript } @@ -533,7 +551,12 @@ loadingSave = true try { - script.schema = script.schema ?? emptySchema() + // Legacy drafts can carry `schema: {}` (no `properties`), which trips + // `inferArgs` on `JSON.stringify(schema.properties)` and toasts "Could + // not parse code". Backfill an empty schema so it parses. + if (!script.schema || !(script.schema as any).properties) { + script.schema = emptySchema() + } try { const result = await inferArgs( script.language, @@ -623,7 +646,7 @@ } const { draft_triggers: _, ...newScript } = structuredClone($state.snapshot(script)) - savedScript = structuredClone($state.snapshot(newScript)) as NewScriptWithDraft + savedScript = structuredClone($state.snapshot(newScript)) setDraftTriggers([]) if (!disableHistoryChange) { @@ -653,153 +676,19 @@ loadingSave = false } - async function saveDraft(forceSave = false): Promise { - scriptEditor?.flushModuleState() - if (initialPath != '' && !savedScript) { - return - } - - if (savedScript) { - const draftOrDeployed = cleanValueProperties(savedScript.draft || savedScript) - const currentTriggers = structuredClone(triggersState.getDraftTriggersSnapshot()) - const current = cleanValueProperties({ ...script, draft_triggers: currentTriggers }) - if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) { - sendUserToast('No changes detected, ignoring', false, [ - { - label: 'Save anyway', - callback: () => { - saveDraft(true) - } - } - ]) - return - } - } - - loadingDraft = true - try { - script.schema = script.schema ?? emptySchema() - try { - const result = await inferArgs( - script.language, - script.content, - script.schema as any, - script.kind === 'preprocessor' ? 'preprocessor' : undefined - ) - if (script.kind === 'preprocessor') { - script.auto_kind = undefined - script.has_preprocessor = undefined - } else { - script.auto_kind = result?.auto_kind || undefined - script.has_preprocessor = result?.has_preprocessor || undefined - } - } catch (error) { - sendUserToast(`Could not parse code, are you sure it is valid?`, true) - } - let newHash = '' - if (initialPath == '' || savedScript?.draft_only) { - if (savedScript?.draft_only) { - await ScriptService.deleteScriptByPath({ - workspace: $workspaceStore!, - path: initialPath, - keepCaptures: true - }) - script.parent_hash = undefined - } - if (!initialPath || script.path != initialPath) { - await CaptureService.moveCapturesAndConfigs({ - workspace: $workspaceStore!, - path: initialPath || fakeInitialPath, - requestBody: { - new_path: script.path - }, - runnableKind: 'script' - }) - } - newHash = await ScriptService.createScript({ - workspace: $workspaceStore!, - requestBody: { - path: script.path, - summary: script.summary, - description: script.description ?? '', - content: script.content, - schema: script.schema, - is_template: script.is_template, - language: script.language, - kind: script.kind, - tag: script.tag, - draft_only: true, - envs: script.envs, - concurrent_limit: script.concurrent_limit, - concurrency_time_window_s: script.concurrency_time_window_s, - debounce_key: emptyString(script.debounce_key) ? undefined : script.debounce_key, - debounce_delay_s: script.debounce_delay_s, - debounce_args_to_accumulate: - script.debounce_args_to_accumulate && script.debounce_args_to_accumulate.length > 0 - ? script.debounce_args_to_accumulate - : undefined, - max_total_debouncing_time: script.max_total_debouncing_time, - max_total_debounces_amount: script.max_total_debounces_amount, - cache_ttl: script.cache_ttl, - cache_ignore_s3_path: script.cache_ignore_s3_path, - ws_error_handler_muted: script.ws_error_handler_muted, - priority: script.priority, - restart_unless_cancelled: script.restart_unless_cancelled, - timeout: script.timeout, - concurrency_key: emptyString(script.concurrency_key) - ? undefined - : script.concurrency_key, - visible_to_runner_only: script.visible_to_runner_only, - auto_kind: script.auto_kind, - has_preprocessor: script.has_preprocessor, - on_behalf_of_email: script.on_behalf_of_email, - assets: script.assets, - modules: script.modules, - labels: script.labels - } - }) - } - const draftTriggers = triggersState.getDraftTriggersSnapshot() - await DraftService.createDraft({ - workspace: $workspaceStore!, - requestBody: { - path: initialPath == '' || savedScript?.draft_only ? script.path : initialPath, - typ: 'script', - value: { - ...script, - draft_triggers: draftTriggers - } - } - }) - - const clonedScript = structuredClone($state.snapshot(script)) - savedScript = { - ...(initialPath == '' || savedScript?.draft_only - ? { ...clonedScript, draft_only: true } - : savedScript), - draft: { - ...clonedScript, - draft_triggers: draftTriggers - } - } as NewScriptWithDraftAndDraftTriggers - - let savedAtNewPath = false - if (initialPath == '' || (savedScript?.draft_only && script.path !== initialPath)) { - savedAtNewPath = true - initialPath = script.path - onSaveInitial?.({ path: script.path, hash: newHash }) - } - onSaveDraft?.({ path: script.path, savedAtNewPath, script }) - - sendUserToast('Saved as draft') - } catch (error) { - sendUserToast( - `Error while saving the script as a draft: ${error.body || error.message}`, - true - ) - onSaveDraftError?.({ path: script.path, error }) - } - loadingDraft = false + // Ctrl/Cmd+S forces an immediate flush of the pending autosave. Flush Monaco + // + `tick()` first so the last keystrokes reach the bindable before the + // syncer flushes. No toast — the AutosaveIndicator narrates the result, and + // `flush` never rejects (postSave routes errors to the failures map). + async function saveDraft(): Promise { + if (!$workspaceStore || !userDraftPath) return + editor?.flushPendingChanges() + await tick() + await UserDraftDbSyncer.flush({ + workspace: $workspaceStore, + itemKind: 'script', + path: userDraftPath + }) } // Inside an AI session pane (which injects an aiChatManager via context) the @@ -830,10 +719,7 @@ }) } - function computeDropdownItems( - initialPath: string, - savedScript: NewScriptWithDraftAndDraftTriggers | undefined - ) { + function computeDropdownItems(initialPath: string, savedScript: Script | NewScript | undefined) { let dropdownItems: { label: string; onClick: () => void }[] = initialPath != '' && customUi?.topBar?.extraDeployOptions != false ? [ @@ -864,7 +750,7 @@ ] : []), ...(!inSessionPane && - !script.draft_only && + (savedScript as any)?.no_deployed !== true && script.kind === 'script' && !script.auto_kind ? [ @@ -994,17 +880,7 @@ } } - function handleDeployTrigger(trigger: Trigger) { - const { id, path, type } = trigger - //Update the saved script to remove the draft trigger that is deployed - if (savedScript && savedScript.draft && savedScript.draft.draft_triggers) { - const newSavedDraftTrigers = savedScript.draft.draft_triggers.filter( - (t) => t.id !== id || t.path !== path || t.type !== type - ) - savedScript.draft.draft_triggers = - newSavedDraftTrigers.length > 0 ? newSavedDraftTrigers : undefined - } - } + function handleDeployTrigger(_trigger: Trigger) {} function onScriptLanguageTrigger(lang: 'docker' | 'bunnative' | ScriptLang) { if (lang == 'docker') { @@ -1946,7 +1822,7 @@ {hasPreprocessor} canHavePreprocessor={canHavePreprocessor(script.language)} args={hasPreprocessor && selectedInputTab !== 'preprocessor' ? {} : args} - isDeployed={savedScript && !savedScript?.draft_only} + isDeployed={savedScript && (savedScript as any)?.no_deployed !== true} schema={script.schema} runnableVersion={script.parent_hash} onDeployTrigger={handleDeployTrigger} @@ -1985,6 +1861,18 @@ onNavigate={(item) => onNavigate?.(item)} /> {/if} + {#if $workspaceStore} + + {/if}
@@ -2015,13 +1903,16 @@ {/snippet} {#snippet diffButton()} {#if customUi?.topBar?.diff != false} + {@const isDraftOnly = (savedScript as any)?.no_deployed === true} {/if} = $state({}) let selected: string | undefined = $state(undefined) let pathError = $state('') - // Backend `edited_at` per workspace — the rev the staleness check - // compares the local autosave's recorded rev against. Variables have - // no DB-draft concept, so only `remoteRev` is ever populated. - let fetchedRev: Record = $state({}) - - // Local-draft staleness modal: opened when the backend variable moved - // on (someone else edited it) since the local autosave was written. - let staleModalOpen = $state(false) - let pendingStale: { ws: string; backend: VariableState } | undefined = undefined - - function onStaleLoadLatest(): void { - if (!pendingStale) { - staleModalOpen = false - return - } - const { ws, backend } = pendingStale - UserDraft.discard('variable', editPath ?? '', backend, { workspace: ws }) - initialStates[ws] = $state.snapshot(backend) as VariableState - pendingStale = undefined - staleModalOpen = false - } - - function onStaleKeepDraft(): void { - if (pendingStale) { - const { ws } = pendingStale - UserDraft.saveMeta( - 'variable', - editPath ?? '', - { remoteRev: fetchedRev[ws] }, - { workspace: ws } - ) - } - pendingStale = undefined - staleModalOpen = false - } const handlesArray = UserDraft.useMany(() => workspaceSpecs.map((s) => ({ itemKind: 'variable' as const, path: editPath ?? '', workspace: s.ws, - defaultValue: s.defaultValue + defaultValue: s.defaultValue, + // Autosaves landing back on the deployed value become deletes (same + // comparison as the banner's `dirtyWorkspaces`, so they can't disagree). + // Guarded by `existedInitially` so draft-only items aren't destroyed. + discardIf: (val) => !!existedInitially[s.ws] && draftValuesEqual(val, initialStates[s.ws]) })) ) const states = $derived.by(() => { @@ -125,8 +94,24 @@ return canWrite(editPath ?? '', perms, perWsUser[selected] ?? $userStore) }) const dirtyWorkspaces = $derived( - Object.keys(states).filter((ws) => !deepEqual(states[ws].draft, initialStates[ws])) + Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws])) ) + + // The list-page `*` hint is owned by UserDraftDbSyncer (set on save, cleared + // on delete). The editor only CLEARS it — a workspace at the deployed + // baseline has no draft, so drop any stale hint (this is how a draft + // discarded in another tab vanishes on reopen). Never SET here. + $effect(() => { + const p = editPath + const loadedWs = Object.keys(states) + const dirty = dirtyWorkspaces + untrack(() => { + if (!p) return + for (const ws of loadedWs) { + if (!dirty.includes(ws)) setLocalDraftHint(ws, 'variable', p, false) + } + }) + }) const anyDirty = $derived(dirtyWorkspaces.length > 0) // Banner is scoped to the selected workspace — the diff/discard only // operate on it, so showing it for an unrelated dirty workspace would be @@ -141,7 +126,12 @@ const dirtyValid = $derived( dirtyWorkspaces.every((ws) => { const v = states[ws].draft - return !!v && v.variable.value.length <= MAX_VARIABLE_LENGTH + // `$encrypted:` markers are ciphertext; the backend re-derives the + // real value on save, so the length cap doesn't apply. + return ( + !!v && + (isEncryptedDraftValue(v.variable.value) || v.variable.value.length <= MAX_VARIABLE_LENGTH) + ) }) ) const dirtyCanWrite = $derived( @@ -159,11 +149,19 @@ if (ws in states) return untrack(() => { Promise.all([ - VariableService.getVariable({ workspace: ws, path: p, decryptSecret: false }), + VariableService.getVariable({ + workspace: ws, + path: p, + decryptSecret: false, + getDraft: true + }), getUserExt(ws) ]).then(([v, user]) => { - fetchedRev[ws] = v.edited_at - const s: VariableState = { + // `.draft` already holds the editor's `VariableState` shape. + const savedDraftState = (v as any).draft as VariableState | undefined + // Deployed baseline as the dirty-check reference, so the banner + // compares draft-vs-deployed and fires immediately when a draft exists. + const deployedState: VariableState = { path: v.path, variable: { value: v.value ?? '', @@ -173,48 +171,19 @@ labels: v.labels ?? undefined, wsSpecific: v.ws_specific ?? false } - // See ResourceEditor for the same pattern: a backend that - // moved on since the autosave was written → staleness modal; - // otherwise just a "showing your local autosave" toast with - // a "Reset to deployed" escape. - const persisted = UserDraft.get('variable', p, { workspace: ws }) - const previousMeta = UserDraft.getMeta('variable', p, { workspace: ws }) - if (persisted !== undefined && !deepEqual(persisted, s)) { - const cause = checkStaleness(previousMeta, v.edited_at) - if (cause) { - pendingStale = { ws, backend: s } - staleModalOpen = true - } else { - if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) { - UserDraft.saveMeta('variable', p, { remoteRev: v.edited_at }, { workspace: ws }) - } - } - } + // Open with the saved draft if present, else the deployed. + const s: VariableState = savedDraftState ?? deployedState ensureHandle(ws, s) - initialStates[ws] = structuredClone(s) - existedInitially[ws] = true + initialStates[ws] = structuredClone(deployedState) + // Draft-only paths (`no_deployed`) have no row — saving must + // CREATE, not update (update 404s). + existedInitially[ws] = !(v as any).no_deployed extraPerms[ws] = v.extra_perms ?? {} perWsUser[ws] = user }) }) }) - // Seed the staleness rev once a real autosave appears (see - // ResourceEditor for the rationale). Self-limiting via the - // meta-already-set guard. - $effect(() => { - for (const ws of Object.keys(states)) { - const h = states[ws] - const rev = fetchedRev[ws] - const baseline = initialStates[ws] - if (!h || rev === undefined || baseline === undefined) continue - const draft = h.draft - if (draft === undefined || deepEqual(draft, baseline)) continue - if (h.meta.remoteRev !== undefined || h.meta.remoteDraftRev !== undefined) continue - untrack(() => h.setMeta({ remoteRev: rev })) - } - }) - function reset() { // Clearing workspaceSpecs triggers useMany's reconcile to release // every acquired entry. The $derived `states` then collapses to {}. @@ -300,8 +269,13 @@ } }) } - // Saved on the backend — drop the local autosave for this workspace. - UserDraft.remove('variable', editPath ?? '', { workspace: ws }) + // The just-saved state is the new deployed baseline; reset the + // handle to it via `discard` (not `remove` — blanking the cell to + // `undefined` reads as dirty). The `value: null` POST also deletes + // the server draft row so `is_draft` clears on refetch. + initialStates[ws] = $state.snapshot(s) as VariableState + existedInitially[ws] = true + UserDraft.discard('variable', editPath ?? '', s, { workspace: ws }) // Path now exists server-side — drop the autocomplete cache so // it shows up immediately instead of after the 60s TTL. invalidateWorkspacePaths(ws) @@ -315,13 +289,6 @@ } - -
{#if $mode !== 'preview'} @@ -1210,19 +1073,6 @@
- {#if !compactTopbar} - - {/if}
- {#if !savedApp} - + {#if !savedApp || newApp} + {:else if secretUrlHref}
- import { Alert } from '$lib/components/common' - import Path from '$lib/components/Path.svelte' - - let { - summary = $bindable(), - appPath = $bindable(), - pathError = $bindable(), - newEditedPath = $bindable() - } = $props() - - let path: Path | undefined = $state(undefined) - let dirtyPath = $state(false) - - - - Choose a path to save the initial draft of the app. - -

Summary

-
- - { - e.stopPropagation() - }} - bind:value={summary} - onkeyup={() => { - if (appPath == '' && summary?.length > 0 && !dirtyPath) { - path?.setName( - summary - .toLowerCase() - .replace(/[^a-z0-9_]/g, '_') - .replace(/-+/g, '_') - .replace(/^-|-$/g, '') - ) - } - }} - /> -
-
- -
diff --git a/frontend/src/lib/components/apps/editor/AppEditorTutorial.svelte b/frontend/src/lib/components/apps/editor/AppEditorTutorial.svelte index 3431e906ad..c268058717 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorTutorial.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorTutorial.svelte @@ -26,7 +26,7 @@ targetTutorial = undefined }} on:confirmed={async () => { - window.open(`/apps/add?tutorial=${targetTutorial}&nodraft=true`, '_blank') + window.open(`/apps/add?tutorial=${targetTutorial}`, '_blank') }} >
diff --git a/frontend/src/lib/components/apps/editor/AppJsonEditor.svelte b/frontend/src/lib/components/apps/editor/AppJsonEditor.svelte index e7a35ded20..1e44b147f3 100644 --- a/frontend/src/lib/components/apps/editor/AppJsonEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppJsonEditor.svelte @@ -1,11 +1,12 @@ jsonViewerDrawer?.toggleDrawer()}> - {#if useDraft} -
- +Draft -
- {/if} {#if loading} {:else} @@ -80,11 +88,13 @@ {#snippet actions()} {#if !$userStore?.operator} - - {/if} {/snippet} diff --git a/frontend/src/lib/components/apps/types.ts b/frontend/src/lib/components/apps/types.ts index 64b08d621e..8eef469472 100644 --- a/frontend/src/lib/components/apps/types.ts +++ b/frontend/src/lib/components/apps/types.ts @@ -149,11 +149,9 @@ export interface AppEditorProps { savedApp?: | { value: App - draft?: any path: string summary: string policy: any - draft_only?: boolean custom_path?: string } | undefined @@ -162,20 +160,18 @@ export interface AppEditorProps { newPath?: string | undefined replaceStateFn?: (path: string) => void gotoFn?: (path: string, opt?: Record | undefined) => void - unsavedConfirmationModal?: import('svelte').Snippet<[any]> onSavedNewAppPath?: (path: string) => void /** Override breadcrumb-picker navigation. Defaults to goto(editPathFor(item)). */ onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void - /** - * Backend revs at the load that produced `app`. Used as the seed - * `UserDraft` meta on the first local autosave: until the handle has - * its own meta (set on a previous reload, or by route backfill), the - * mirror `$effect` injects these revs so the next reload's staleness - * check has something to compare the current backend rev against. - * Without this, the first deploy-after-edit can't be detected as - * drift — `previousMeta` would be empty and the modal wouldn't fire. - */ - initialRevs?: import('$lib/userDraft.svelte').UserDraftMeta + // Threaded through `AppEditorHeader` to the `AutosaveIndicator` + // popover so its "Reset to deployed" button can do the same thing + // the load-time toast offers. + onResetToDeployed?: () => void | Promise + // See ScriptBuilderProps — same semantics for the app editor's + // indicator. Threaded through AppEditorHeader. + loadedFromDraft?: boolean + othersDraftsCount?: number + onOpenOthersDrafts?: () => void } export type App = { @@ -198,6 +194,15 @@ export type App = { hideLegacyTopBar?: boolean | undefined mobileViewOnSmallerScreens?: boolean | undefined version?: number + /** + * User-typed path persisted on the autosaved App when it differs from + * the deployed/seeded baseline. The home list renders it so a friendly + * name shows up instead of the autogenerated `u/{user}/draft_{uuid}` + * URL slot for renames-in-progress and brand-new drafts. Dropped from + * the JSON once the typed path matches the baseline again, and deploy + * clears the whole draft. + */ + draft_path?: string } export type ConnectingInput = { diff --git a/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte b/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte new file mode 100644 index 0000000000..8bd9eeebd1 --- /dev/null +++ b/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte @@ -0,0 +1,102 @@ + + +{#if enabled && workspace && path} + + {#if otherDraftsUsers.length > 0} + {#key path} + + {/key} + {/if} + {#if onLoadLatestDeploy} + + {/if} +{/if} diff --git a/frontend/src/lib/components/common/confirmationModal/DraftSyncConflictModal.svelte b/frontend/src/lib/components/common/confirmationModal/DraftSyncConflictModal.svelte new file mode 100644 index 0000000000..ca467fd571 --- /dev/null +++ b/frontend/src/lib/components/common/confirmationModal/DraftSyncConflictModal.svelte @@ -0,0 +1,92 @@ + + + +
+
+ +
+

+ Another tab, browser, or AI agent saved a newer version of this draft. Your autosave was + rejected to avoid overwriting their work. +

+ {#if conflictHandle.conflict} +

+ Server timestamp: {new Date(conflictHandle.conflict.serverTimestamp).toLocaleString()} +

+ {/if} +
+
+ +
+ + + +
+
+
diff --git a/frontend/src/lib/components/common/confirmationModal/LocalDraftStaleModal.svelte b/frontend/src/lib/components/common/confirmationModal/LocalDraftStaleModal.svelte deleted file mode 100644 index 94782f176e..0000000000 --- a/frontend/src/lib/components/common/confirmationModal/LocalDraftStaleModal.svelte +++ /dev/null @@ -1,125 +0,0 @@ - - - - -{#if open} - -{/if} diff --git a/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte b/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte new file mode 100644 index 0000000000..493931e640 --- /dev/null +++ b/frontend/src/lib/components/common/confirmationModal/OtherUsersDraftsModal.svelte @@ -0,0 +1,169 @@ + + + +
+
+ +

+ Their drafts are independent of yours. For advanced collaboration, consider using workspace forks (EE) +

+
+ +
    + {#each otherDraftsUsers as owner (ownerKey(owner))} +
  • +
    + + {ownerLabel(owner)} + + {#if !owner.username} + + Pre-migration workspace-scoped draft (no owner). Saved before drafts became per-user + — kept around so you can recover the content, but no current user owns it. + + {/if} +
    + + +
  • + {/each} +
+ +
+ +
+
+
+ + + {#snippet headerRight()} + + {/snippet} +
+
{JSON.stringify(jsonValue ?? {}, null, 2)}
+
+
diff --git a/frontend/src/lib/components/common/confirmationModal/StaleDraftModal.svelte b/frontend/src/lib/components/common/confirmationModal/StaleDraftModal.svelte new file mode 100644 index 0000000000..cc027c6f12 --- /dev/null +++ b/frontend/src/lib/components/common/confirmationModal/StaleDraftModal.svelte @@ -0,0 +1,86 @@ + + + +
+
+ +
+

+ A newer version was deployed after you started editing. Your draft is based on the older + deploy. +

+

+ Draft saved {formatTs(draftSavedAt)} · Deployed {formatTs(deployedAt)} +

+
+
+ +
+ + +
+
+
diff --git a/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte b/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte index 55a78245be..b39a718572 100644 --- a/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte +++ b/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte @@ -2,8 +2,6 @@ import ConfirmationModal from './ConfirmationModal.svelte' import { beforeNavigate } from '$app/navigation' import { goto as gotoUrl } from '$app/navigation' - import Button from '../button/Button.svelte' - import type DiffDrawer from '$lib/components/DiffDrawer.svelte' import { cleanValueProperties, orderedJsonStringify, @@ -16,7 +14,6 @@ interface Props { getInitialAndModifiedValues?: GetInitialAndModifiedValues - diffDrawer?: DiffDrawer | undefined additionalExitAction?: () => void triggerOnSearchParamsChange?: boolean onDiscardChanges?: () => void @@ -25,7 +22,6 @@ let { getInitialAndModifiedValues = undefined, - diffDrawer = undefined, additionalExitAction = () => {}, triggerOnSearchParamsChange = false, onDiscardChanges = undefined, @@ -125,37 +121,5 @@ >
Are you sure you want to discard the changes you have made? - {#if savedValue && modifiedValue && diffDrawer} - - {/if}
diff --git a/frontend/src/lib/components/common/modal/Modal2.svelte b/frontend/src/lib/components/common/modal/Modal2.svelte index 36fbb23c5f..fcce008ad7 100644 --- a/frontend/src/lib/components/common/modal/Modal2.svelte +++ b/frontend/src/lib/components/common/modal/Modal2.svelte @@ -15,8 +15,15 @@ target?: string isOpen?: boolean fixedWidth?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' - fixedHeight?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' + /** `adaptive` sizes the modal to its content (no fixed height, + * still capped by max-h-screen-80). */ + fixedHeight?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'adaptive' contentClasses?: string + /** Close when the user clicks outside the modal body. Default + * true. Set false when the caller stacks a child modal on top + * and clicks "outside" the child would otherwise propagate + * here and close the underlying modal. */ + closeOnOutsideClick?: boolean headerLeft?: import('svelte').Snippet headerRight?: import('svelte').Snippet children?: import('svelte').Snippet @@ -25,11 +32,15 @@ let { title, css = {}, - target = '', + // Forwarded to `Portal`. An empty string would hit + // `document.querySelector('')` and throw "The provided selector + // is empty" — match `Portal`'s own default instead. + target = 'body', isOpen = $bindable(false), fixedWidth = 'md', fixedHeight = 'md', contentClasses = '', + closeOnOutsideClick = true, headerLeft, headerRight, children @@ -49,7 +60,9 @@ md: '500px', lg: '720px', xl: '800px', - xxl: '1000px' + xxl: '1000px', + // Content-driven height — emit no `height:` rule at all. + adaptive: undefined } export function close() { @@ -61,6 +74,7 @@ } function handleKeyDown(event: KeyboardEvent) { + if (!isOpen) return if (event.key === 'Escape') { event.preventDefault() event.stopPropagation() @@ -83,15 +97,17 @@ >
close() }} + use:clickOutside={{ + onClickOutside: () => closeOnOutsideClick && close() + }} >
diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index bc84f0f884..5682bd47e0 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -3,13 +3,14 @@ import Dropdown from '$lib/components/DropdownV2.svelte' import type MoveDrawer from '$lib/components/MoveDrawer.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' + import DraftBadge from '$lib/components/DraftBadge.svelte' import type ShareModal from '$lib/components/ShareModal.svelte' - import { AppService, DraftService, type ListableApp } from '$lib/gen' + import { AppService, type ListableApp } from '$lib/gen' import { userStore, workspaceStore } from '$lib/stores' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { createEventDispatcher } from 'svelte' import Button from '../button/Button.svelte' import Row from './Row.svelte' - import DraftBadge from '$lib/components/DraftBadge.svelte' import InheritedLabels from '$lib/components/InheritedLabels.svelte' import Badge from '../badge/Badge.svelte' import { @@ -29,7 +30,7 @@ import { goto as gotoUrl } from '$app/navigation' import { page } from '$app/state' import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' - import { DELETE, copyToClipboard } from '$lib/utils' + import { copyToClipboard } from '$lib/utils' import AppDeploymentHistory from '$lib/components/apps/editor/AppDeploymentHistory.svelte' import { isDeployable } from '$lib/utils_deployable' import { getDeployUiSettings } from '$lib/components/home/deploy_ui' @@ -38,7 +39,7 @@ import { isCloudHosted } from '$lib/cloud' interface Props { - app: ListableApp & { has_draft?: boolean; draft_only?: boolean; canWrite: boolean } + app: ListableApp & { draft_only?: boolean; canWrite: boolean } marked: string | undefined shareModal: ShareModal moveDrawer: MoveDrawer @@ -65,11 +66,31 @@ const dispatch = createEventDispatcher() - let appExport: { open: (path: string) => void } | undefined = $state(undefined) + let appExport: { open: (path: string, rawApp?: boolean) => void } | undefined = $state(undefined) let appDeploymentHistory: AppDeploymentHistory | undefined = $state(undefined) async function loadAppJson() { - appExport?.open(app.path) + // Thread the row's `raw_app` flag so the JSON drawer's backend + // fetch picks the right draft kind on draft-only items (no + // deployed row to read the kind from server-side). + appExport?.open(app.path, !!app.raw_app) + } + + async function deleteApp(path: string): Promise { + // Draft-only items have no deployed row — the regular route would + // 404. Route the delete through the syncer instead; the `app` vs + // `raw_app` choice mirrors the row's own `raw_app` flag. + if (app.draft_only) { + await UserDraftDbSyncer.save({ + workspace: $workspaceStore ?? '', + itemKind: app.raw_app ? 'raw_app' : 'app', + path, + value: null, + immediate: true + }) + } else { + await AppService.deleteApp({ workspace: $workspaceStore ?? '', path }) + } } @@ -84,8 +105,8 @@ href="{base}/apps{app.raw_app ? '_raw' : ''}/get/{app.path}" kind="app" {marked} - path={app.path} - summary={app.summary} + path={(app as any).draft_path ?? app.path} + summary={app.is_draft ? `${app.summary || (app as any).draft_path || app.path}*` : app.summary} workspaceId={app.workspace_id ?? $workspaceStore ?? ''} canFavorite={!app.draft_only} {depth} @@ -99,7 +120,15 @@ Raw {/if} - + {#if app.labels?.length}
{#each app.labels.slice(0, 3) as label} @@ -132,7 +161,7 @@ variant="subtle" wrapperClasses="w-20" startIcon={{ icon: Pen }} - href="{base}/apps{app.raw_app ? '_raw' : ''}/edit/{app.path}?nodraft=true" + href="{base}/apps{app.raw_app ? '_raw' : ''}/edit/{app.path}" > Edit @@ -157,7 +186,7 @@ aiId={`app-row-dropdown-${app.summary?.length > 0 ? app.summary : app.path}`} aiDescription={`Open dropdown for app ${app.summary?.length > 0 ? app.summary : app.path} options`} items={async () => { - let { draft_only, canWrite, summary, execution_mode, path, has_draft } = app + let { draft_only, canWrite, summary, execution_mode, path } = app const canEdit = canWrite && showEditButton if (draft_only) { @@ -169,11 +198,11 @@ // TODO // @ts-ignore if (event?.shiftKey) { - await AppService.deleteApp({ workspace: $workspaceStore ?? '', path }) + await deleteApp(path) dispatch('change') } else { deleteConfirmedCallback = async () => { - await AppService.deleteApp({ workspace: $workspaceStore ?? '', path }) + await deleteApp(path) dispatch('change') } } @@ -273,25 +302,6 @@ } ] : []), - ...(has_draft - ? [ - { - displayName: 'Delete Draft', - icon: Trash, - action: async () => { - await DraftService.deleteDraft({ - workspace: $workspaceStore ?? '', - path, - kind: 'app' - }) - dispatch('change') - }, - type: DELETE, - disabled: !canWrite, - hide: $userStore?.operator - } - ] - : []), { displayName: 'Delete', icon: Trash, @@ -299,11 +309,11 @@ // TODO // @ts-ignore if (event?.shiftKey) { - await AppService.deleteApp({ workspace: $workspaceStore ?? '', path }) + await deleteApp(path) dispatch('change') } else { deleteConfirmedCallback = async () => { - await AppService.deleteApp({ workspace: $workspaceStore ?? '', path }) + await deleteApp(path) dispatch('change') } } diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index aaed01e6d7..3dca4ac60d 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -5,16 +5,17 @@ import type MoveDrawer from '$lib/components/MoveDrawer.svelte' import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' + import DraftBadge from '$lib/components/DraftBadge.svelte' import type ShareModal from '$lib/components/ShareModal.svelte' - import { FlowService, type Flow, DraftService } from '$lib/gen' + import { FlowService, type Flow } from '$lib/gen' import { userStore, workspaceStore } from '$lib/stores' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { createEventDispatcher } from 'svelte' import Badge from '../badge/Badge.svelte' import Button from '../button/Button.svelte' import Row from './Row.svelte' - import DraftBadge from '$lib/components/DraftBadge.svelte' import { sendUserToast } from '$lib/toast' - import { DELETE, copyToClipboard, isOwner } from '$lib/utils' + import { copyToClipboard, isOwner } from '$lib/utils' import { isDeployable } from '$lib/utils_deployable' import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' @@ -40,7 +41,13 @@ import { isCloudHosted } from '$lib/cloud' interface Props { - flow: Flow & { has_draft?: boolean; draft_only?: boolean; canWrite: boolean } + flow: Flow & { + draft_only?: boolean + is_draft?: boolean + draft_path?: string + draft_users?: { username?: string | null }[] + canWrite: boolean + } marked: string | undefined shareModal: ShareModal moveDrawer: MoveDrawer @@ -85,7 +92,20 @@ async function deleteFlow(path: string): Promise { try { - await FlowService.deleteFlowByPath({ workspace: $workspaceStore!, path }) + // Draft-only items have no deployed row to delete — the regular + // route would 404. Route the delete through the syncer so the + // per-user draft row is removed instead. + if (flow.draft_only) { + await UserDraftDbSyncer.save({ + workspace: $workspaceStore!, + itemKind: 'flow', + path, + value: null, + immediate: true + }) + } else { + await FlowService.deleteFlowByPath({ workspace: $workspaceStore!, path }) + } dispatch('change') sendUserToast(`Deleted flow ${path}`) } catch (err) { @@ -105,13 +125,13 @@ aiId={`flow-row-${flow.path}`} aiDescription={`Button to access the form to run the flow ${flow.summary ?? flow.path}`} href={flow.draft_only - ? `${base}/flows/edit/${flow.path}?nodraft=true` + ? `${base}/flows/edit/${flow.path}` : `${base}/flows/get/${flow.path}?workspace=${$workspaceStore}`} kind="flow" workspaceId={flow.workspace_id ?? $workspaceStore ?? ''} {marked} - path={flow.path} - summary={flow.summary} + path={flow.draft_path ?? flow.path} + summary={flow.is_draft ? `${flow.summary || flow.draft_path || flow.path}*` : flow.summary} {errorHandlerMuted} canFavorite={!flow.draft_only} {depth} @@ -122,7 +142,15 @@ archived {/if} - + {#if flow.labels?.length}
{#each flow.labels.slice(0, 3) as label} @@ -154,7 +182,7 @@ wrapperClasses="w-20" unifiedSize="md" startIcon={{ icon: Pen }} - href="{base}/flows/edit/{flow.path}?nodraft=true" + href="{base}/flows/edit/{flow.path}" aiId={`edit-flow-button-${flow.summary?.length > 0 ? flow.summary : flow.path}`} aiDescription={`Edits the flow ${flow.summary?.length > 0 ? flow.summary : flow.path}`} > @@ -182,7 +210,7 @@ aiId={`flow-row-dropdown-${flow.summary?.length > 0 ? flow.summary : flow.path}`} aiDescription={`Open dropdown for flow ${flow.summary?.length > 0 ? flow.summary : flow.path} options`} items={async () => { - let { draft_only, path, archived, has_draft } = flow + let { draft_only, path, archived } = flow let owner = isOwner(path, $userStore, $workspaceStore) const canEdit = flow.canWrite && showEditButton if (draft_only) { @@ -295,25 +323,6 @@ disabled: !owner || !canEdit, hide: $userStore?.operator }, - ...(has_draft - ? [ - { - displayName: 'Delete Draft', - icon: Trash, - action: async () => { - await DraftService.deleteDraft({ - workspace: $workspaceStore ?? '', - path, - kind: 'flow' - }) - dispatch('change') - }, - type: DELETE, - disabled: !owner, - hide: $userStore?.operator - } - ] - : []), { displayName: 'Delete', icon: Trash, diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index 2174776167..be7f63dace 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -5,18 +5,19 @@ import type MoveDrawer from '$lib/components/MoveDrawer.svelte' import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' + import DraftBadge from '$lib/components/DraftBadge.svelte' import type ShareModal from '$lib/components/ShareModal.svelte' - import { ScriptService, type Script, DraftService } from '$lib/gen' + import { ScriptService, type Script } from '$lib/gen' import { hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { createEventDispatcher } from 'svelte' import Badge from '../badge/Badge.svelte' import Button from '../button/Button.svelte' import Row from './Row.svelte' - import DraftBadge from '$lib/components/DraftBadge.svelte' import { sendUserToast } from '$lib/toast' - import { capitalize, copyToClipboard, DELETE, isOwner } from '$lib/utils' + import { capitalize, copyToClipboard, isOwner } from '$lib/utils' import { isDeployable } from '$lib/utils_deployable' import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' @@ -52,7 +53,13 @@ import { isCloudHosted } from '$lib/cloud' interface Props { - script: Script & { canWrite: boolean; use_codebase: boolean } + script: Script & { + canWrite: boolean + use_codebase: boolean + is_draft?: boolean + draft_path?: string + draft_users?: { username?: string | null }[] + } marked: string | undefined shareModal: ShareModal moveDrawer: MoveDrawer @@ -104,7 +111,20 @@ } async function deleteScript(path: string): Promise { - await ScriptService.deleteScriptByPath({ workspace: $workspaceStore!, path }) + // Draft-only items have no deployed row to delete — the regular + // route would 404. Route the delete through the syncer so the + // per-user draft row is removed instead. + if (script.draft_only) { + await UserDraftDbSyncer.save({ + workspace: $workspaceStore!, + itemKind: 'script', + path, + value: null, + immediate: true + }) + } else { + await ScriptService.deleteScriptByPath({ workspace: $workspaceStore!, path }) + } dispatch('change') sendUserToast(`Deleted script ${path}`) } @@ -127,8 +147,10 @@ : `${base}/scripts/get/${script.hash}?workspace=${$workspaceStore}`} kind="script" {marked} - path={script.path} - summary={script.summary} + path={script.draft_path ?? script.path} + summary={script.is_draft + ? `${script.summary || script.draft_path || script.path}*` + : script.summary} {errorHandlerMuted} workspaceId={$workspaceStore ?? ''} canFavorite={!script.draft_only} @@ -169,7 +191,15 @@ > {/if} - + {#if script.labels?.length}
{#each script.labels.slice(0, 3) as label} @@ -406,25 +436,6 @@ hide: $userStore?.operator }, - ...(script.has_draft - ? [ - { - displayName: 'Delete Draft', - icon: Trash, - action: async () => { - await DraftService.deleteDraft({ - workspace: $workspaceStore ?? '', - path: script.path, - kind: 'script' - }) - dispatch('change') - }, - type: DELETE, - disabled: !owner, - hide: $userStore?.operator - } - ] - : []), ...($userStore?.is_admin || $userStore?.is_super_admin ? [ { diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index d6eb6803e4..536abff952 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -48,12 +48,6 @@ vi.mock('$lib/gen', async () => { getScriptByPath: vi.fn(async () => { throw new Error('getScriptByPath mock not configured') }), - getScriptByHash: vi.fn(async () => { - throw new Error('getScriptByHash mock not configured') - }), - getScriptByPathWithDraft: vi.fn(async () => { - throw new Error('getScriptByPathWithDraft mock not configured') - }), queryHubScripts: vi.fn(async () => []), getHubScriptContentByPath: vi.fn(async () => ''), listScripts: vi.fn(async () => []) @@ -109,9 +103,6 @@ vi.mock('$lib/gen', async () => { getFlowByPath: vi.fn(async () => { throw new Error('getFlowByPath mock not configured') }), - getFlowByPathWithDraft: vi.fn(async () => { - throw new Error('getFlowByPathWithDraft mock not configured') - }), getFlowLatestVersion: vi.fn(async () => ({ id: 1 })), listFlows: vi.fn(async () => []) }), @@ -131,8 +122,8 @@ vi.mock('$lib/gen', async () => { existsApp: vi.fn(async () => false), createAppRaw: vi.fn(async () => 'created'), updateAppRaw: vi.fn(async () => 'updated'), - getAppByPathWithDraft: vi.fn(async () => { - throw new Error('getAppByPathWithDraft mock not configured') + getAppByPath: vi.fn(async () => { + throw new Error('getAppByPath mock not configured') }), listApps: vi.fn(async () => []) }), @@ -423,9 +414,6 @@ describe('global AI tools', () => { wsSpecific: true, resource_type: 'postgresql' }) - expect(UserDraft.getMeta('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({ - remoteRev: '2026-05-22T09:30:00Z' - }) }) it('writes variable drafts in the editor UserDraft shape', async () => { @@ -463,9 +451,6 @@ describe('global AI tools', () => { is_oauth: true, expires_at: '2026-06-22T09:30:00Z' }) - expect(UserDraft.getMeta('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toEqual({ - remoteRev: '2026-05-22T09:30:00Z' - }) expect(localStorageSnapshot()).not.toContain('new-secret-token') }) @@ -751,23 +736,14 @@ describe('global AI tools', () => { it('preserves existing script metadata and seeds freshness on first script write', async () => { vi.mocked(ScriptService.existsScriptByPath).mockResolvedValueOnce(true) - vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({ + vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({ path: 'f/scripts/existing', hash: 'deployed-hash', - draft_created_at: '2026-05-22T10:00:00Z', summary: 'deployed summary', description: 'deployed description', content: 'old deployed content', language: 'bun', - kind: 'script', - draft: { - path: 'f/scripts/existing', - summary: 'db draft summary', - description: 'db draft description', - content: 'old draft content', - language: 'bun', - kind: 'script' - } + kind: 'script' } as any) await callGlobalTool('write_script', { @@ -783,20 +759,16 @@ describe('global AI tools', () => { path: 'f/scripts/existing', parent_hash: 'deployed-hash', summary: 'new summary', - description: 'db draft description', + description: 'deployed description', content: 'new content', language: 'bun' }) - expect(UserDraft.getMeta('script', 'f/scripts/existing', { workspace: WORKSPACE })).toEqual({ - remoteRev: 'deployed-hash', - remoteDraftRev: '2026-05-22T10:00:00Z' - }) }) it('preserves existing flow metadata and seeds freshness on first flow write', async () => { vi.mocked(FlowService.existsFlowByPath).mockResolvedValueOnce(true) vi.mocked(FlowService.getFlowLatestVersion).mockResolvedValueOnce({ id: 42 } as any) - vi.mocked(FlowService.getFlowByPathWithDraft).mockResolvedValueOnce({ + vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({ path: 'f/flows/existing', summary: 'deployed summary', description: 'deployed description', @@ -805,19 +777,7 @@ describe('global AI tools', () => { edited_by: 'admin', edited_at: '2026-05-22T09:00:00Z', archived: false, - extra_perms: {}, - draft_created_at: '2026-05-22T10:00:00Z', - draft: { - path: 'f/flows/existing', - summary: 'db draft summary', - description: 'db draft description', - value: { modules: [] }, - schema: { properties: { draft: { type: 'string' } } }, - edited_by: 'admin', - edited_at: '2026-05-22T09:30:00Z', - archived: false, - extra_perms: {} - } + extra_perms: {} } as any) await callGlobalTool('write_flow', { @@ -829,13 +789,9 @@ describe('global AI tools', () => { expect(UserDraft.get('flow', 'f/flows/existing', { workspace: WORKSPACE })).toMatchObject({ path: 'f/flows/existing', summary: 'new summary', - description: 'db draft description', + description: 'deployed description', value: { modules: [{ id: 'step', value: { type: 'identity' } }] } }) - expect(UserDraft.getMeta('flow', 'f/flows/existing', { workspace: WORKSPACE })).toEqual({ - remoteRev: 42, - remoteDraftRev: '2026-05-22T10:00:00Z' - }) }) it('preserves editor schedule fields when writing over an existing schedule', async () => { @@ -947,32 +903,22 @@ describe('global AI tools', () => { }) it('seeds raw app draft metadata on first app write', async () => { - vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({ + vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({ path: 'f/apps/report', summary: 'deployed app', versions: [3, 4], - draft_created_at: '2026-05-22T10:30:00Z', value: { files: { '/src/App.tsx': 'deployed content' }, - runnables: {}, - data: { tables: [] } + runnables: { + main: { + type: 'inline', + inlineScript: { language: 'bun', content: 'export async function main() {}' } + } + }, + data: { tables: ['orders'], datatable: 'db', schema: 'public' } }, policy: { execution_mode: 'publisher' }, - custom_path: 'report', - draft: { - summary: 'saved app draft', - value: { - files: { '/src/App.tsx': 'draft content' }, - runnables: { - main: { - type: 'inline', - inlineScript: { language: 'bun', content: 'export async function main() {}' } - } - }, - data: { tables: ['orders'], datatable: 'db', schema: 'public' } - }, - policy: { execution_mode: 'anonymous' } - } + custom_path: 'report' } as any) await callGlobalTool('write_app_file', { @@ -983,9 +929,9 @@ describe('global AI tools', () => { const draft = UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE }) expect(draft).toMatchObject({ - summary: 'saved app draft', + summary: 'deployed app', files: { - '/src/App.tsx': 'draft content', + '/src/App.tsx': 'deployed content', '/src/New.tsx': 'export default function New() { return null }' }, runnables: { @@ -995,13 +941,9 @@ describe('global AI tools', () => { } }, data: { tables: ['orders'], datatable: 'db', schema: 'public' }, - policy: { execution_mode: 'anonymous' }, + policy: { execution_mode: 'publisher' }, custom_path: 'report' }) - expect(UserDraft.getMeta('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toEqual({ - remoteRev: 4, - remoteDraftRev: '2026-05-22T10:30:00Z' - }) }) it('summarizes local raw app drafts in read_workspace_item', async () => { @@ -1055,39 +997,31 @@ describe('global AI tools', () => { expect(item.value.backend[0]).not.toHaveProperty('content') }) - it('summarizes backend raw app drafts from the same source as file reads', async () => { - const appWithDraft = { + it('summarizes backend raw apps from the same source as file reads', async () => { + const deployedApp = { path: 'f/apps/report', summary: 'deployed app', versions: [5], value: { - files: { '/src/App.tsx': 'deployed content' }, - runnables: {}, - data: { tables: ['deployed'] } - }, - draft: { - summary: 'saved app draft', - value: { - files: { - '/src/App.tsx': 'draft content', - '/src/DraftOnly.tsx': 'draft-only content' - }, - runnables: { - main: { - type: 'inline', - inlineScript: { - language: 'bun', - content: 'export async function main() { return "draft" }' - } + files: { + '/src/App.tsx': 'deployed content', + '/src/Helper.tsx': 'helper content' + }, + runnables: { + main: { + type: 'inline', + inlineScript: { + language: 'bun', + content: 'export async function main() { return "deployed" }' } - }, - data: { tables: ['draft'] } - } + } + }, + data: { tables: ['deployed'] } } } - vi.mocked(AppService.getAppByPathWithDraft) - .mockResolvedValueOnce(appWithDraft as any) - .mockResolvedValueOnce(appWithDraft as any) + vi.mocked(AppService.getAppByPath) + .mockResolvedValueOnce(deployedApp as any) + .mockResolvedValueOnce(deployedApp as any) const raw = await callGlobalTool('read_workspace_item', { type: 'app', @@ -1095,15 +1029,14 @@ describe('global AI tools', () => { }) const item = JSON.parse(raw) - expect(raw).not.toContain('draft-only content') expect(item).toMatchObject({ type: 'app', path: 'f/apps/report', - summary: 'saved app draft', + summary: 'deployed app', value: { frontend: [ - { path: '/src/App.tsx', size: 'draft content'.length }, - { path: '/src/DraftOnly.tsx', size: 'draft-only content'.length } + { path: '/src/App.tsx', size: 'deployed content'.length }, + { path: '/src/Helper.tsx', size: 'helper content'.length } ], backend: [ expect.objectContaining({ @@ -1111,10 +1044,10 @@ describe('global AI tools', () => { name: 'main', type: 'inline', language: 'bun', - contentSize: 'export async function main() { return "draft" }'.length + contentSize: 'export async function main() { return "deployed" }'.length }) ], - data: { tables: ['draft'] } + data: { tables: ['deployed'] } }, isDraft: false }) @@ -1122,14 +1055,14 @@ describe('global AI tools', () => { await expect( callGlobalTool('read_app_file', { path: 'f/apps/report', - file_path: '/src/DraftOnly.tsx' + file_path: '/src/Helper.tsx' }) - ).resolves.toBe('draft-only content') + ).resolves.toBe('helper content') expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() }) it('reads raw app files without creating a local draft', async () => { - vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({ + vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({ path: 'f/apps/report', summary: 'deployed app', versions: [5], @@ -1137,14 +1070,6 @@ describe('global AI tools', () => { files: { '/src/App.tsx': 'deployed content' }, runnables: {}, data: { tables: [] } - }, - draft: { - summary: 'saved app draft', - value: { - files: { '/src/App.tsx': 'draft content' }, - runnables: {}, - data: { tables: [] } - } } } as any) @@ -1153,12 +1078,12 @@ describe('global AI tools', () => { path: 'f/apps/report', file_path: '/src/App.tsx' }) - ).resolves.toBe('draft content') + ).resolves.toBe('deployed content') expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() }) it('does not persist a raw app draft when patch_app_file validation fails', async () => { - vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({ + vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({ path: 'f/apps/report', summary: 'deployed app', versions: [5], @@ -1182,7 +1107,7 @@ describe('global AI tools', () => { }) it('does not persist a raw app draft when delete_app_file validation fails', async () => { - vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({ + vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({ path: 'f/apps/report', summary: 'deployed app', versions: [5], @@ -1203,7 +1128,7 @@ describe('global AI tools', () => { }) it('does not persist a raw app draft when delete_app_runnable validation fails', async () => { - vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({ + vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({ path: 'f/apps/report', summary: 'deployed app', versions: [5], diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 7c3040e284..d3e1e6889d 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -74,7 +74,7 @@ import { } from '../shared' import type { ContextElement } from '../context' import { getDatatableTools } from '../datatableTools' -import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte' +import { UserDraft } from '$lib/userDraft.svelte' import { emptySchema } from '$lib/utils' import { inferArgs } from '$lib/infer' import { @@ -129,10 +129,7 @@ const INSTRUCTION_SUBJECTS = [ // `datatable` is not a workspace item type, but the model can request the // datatable SDK reference (the wmill.datatable() runnable API) the same way. const INSTRUCTION_SUBJECTS_EXTRA = ['datatable'] as const -const ALL_INSTRUCTION_SUBJECTS = [ - ...INSTRUCTION_SUBJECTS, - ...INSTRUCTION_SUBJECTS_EXTRA -] as const +const ALL_INSTRUCTION_SUBJECTS = [...INSTRUCTION_SUBJECTS, ...INSTRUCTION_SUBJECTS_EXTRA] as const const MAX_LIST_LIMIT = 100 type ActiveGlobalEditorType = Extract type LiveEditorDraftKind = Parameters[0] @@ -164,7 +161,7 @@ const scriptLangSchema = z.enum($ScriptLang.enum) const getInstructionsSchema = z.object({ subject: instructionSubjectSchema.describe( - "What to get authoring instructions for: a workspace item type (script, flow, resource, app) or \"datatable\" for the wmill.datatable() SQL SDK used inside runnables. Schedules, triggers, and variables don't need instructions — their tool schemas describe everything." + 'What to get authoring instructions for: a workspace item type (script, flow, resource, app) or "datatable" for the wmill.datatable() SQL SDK used inside runnables. Schedules, triggers, and variables don\'t need instructions — their tool schemas describe everything.' ), language: scriptLangSchema .optional() @@ -954,7 +951,6 @@ type AppMetadata = { type LoadedAppDraftValue = { value: AppDraftValue - meta?: UserDraftMeta } function summarizeAppValue(value: AppDraftValue): AppMetadata { @@ -1095,21 +1091,14 @@ function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue { } } -function appDraftMeta(app: { versions?: number[]; draft_created_at?: string }): UserDraftMeta { - return { - remoteRev: app.versions ? app.versions[app.versions.length - 1] : undefined, - remoteDraftRev: app.draft_created_at - } -} - async function loadAppValueForRead(path: string, workspace: string): Promise { const draft = getGlobalDraft(workspace, 'app', path) if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) { return draft.value as AppDraftValue } - const app = await AppService.getAppByPathWithDraft({ workspace, path }) - return appSourceToDraftValue(app.draft ?? app, app) + const app = await AppService.getAppByPath({ workspace, path }) + return appSourceToDraftValue(app, app) } async function loadAppDraftValue(path: string, workspace: string): Promise { @@ -1118,18 +1107,12 @@ async function loadAppDraftValue(path: string, workspace: string): Promise void } -} -| { - mode: 'simple' - original: Value - current: Value - title: string - button?: { text: string; onClick: () => void } -} +export type DiffDrawerDiff = + | { + mode: 'normal' + deployed: Value + draft?: Value | undefined + current: Value + defaultDiffType?: 'deployed' | 'draft' + button?: { text: string; onClick: () => void } + } + | { + mode: 'simple' + original: Value + current: Value + title: string + button?: { text: string; onClick: () => void } + } export interface DiffDrawerI { - openDrawer: () => void - closeDrawer: () => void - setDiff: (diff: DiffDrawerDiff) => void -} \ No newline at end of file + openDrawer: () => void + closeDrawer: () => void + setDiff: (diff: DiffDrawerDiff) => void +} diff --git a/frontend/src/lib/components/flow_builder.ts b/frontend/src/lib/components/flow_builder.ts index 5d2493b930..5b8b018665 100644 --- a/frontend/src/lib/components/flow_builder.ts +++ b/frontend/src/lib/components/flow_builder.ts @@ -1,7 +1,7 @@ -import type { OpenFlow } from '$lib/gen' +import type { Flow, OpenFlow } from '$lib/gen' import type { StateStore } from '$lib/utils' import type { FlowState } from './flows/flowState' -import type { FlowWithDraftAndDraftTriggers, Trigger } from './triggers/utils' +import type { Trigger } from './triggers/utils' import type { DiffDrawerI } from './diff_drawer' import type { FlowBuilderWhitelabelCustomUi } from './custom_ui' import type { ScheduleTrigger } from './triggers' @@ -17,14 +17,13 @@ export type FlowBuilderProps = { loading?: boolean flowStore: StateStore flowStateStore: StateStore - savedFlow?: FlowWithDraftAndDraftTriggers | undefined + savedFlow?: Flow | undefined diffDrawer?: DiffDrawerI | undefined customUi?: FlowBuilderWhitelabelCustomUi disableAi?: boolean disabledFlowInputs?: boolean savedPrimarySchedule?: ScheduleTrigger | undefined // used to set the primary schedule in the legacy primaryScheduleStore version?: number | undefined - setSavedraftCb?: ((cb: () => void) => void) | undefined draftTriggersFromUrl?: Trigger[] | undefined selectedTriggerIndexFromUrl?: number | undefined children?: import('svelte').Snippet @@ -34,23 +33,19 @@ export type FlowBuilderProps = { } noInitial?: boolean liveEditorDraftStoragePath?: string - onSaveInitial?: ({ path, id }: { path: string; id: string }) => void - onSaveDraft?: ({ - path, - savedAtNewPath, - newFlow - }: { - path: string - savedAtNewPath: boolean - newFlow: boolean - }) => void - onSaveDraftError?: ({ error }: { error: any }) => void - onSaveDraftOnlyAtNewPath?: ({ path, selectedId }: { path: string; selectedId: string }) => void onDeploy?: ({ path }: { path: string }) => void onDeployError?: ({ error }: { error: any }) => void onDetails?: ({ path }: { path: string }) => void onHistoryRestore?: () => void onNavigate?: (item: WorkspaceItem) => void + // Threaded to the `AutosaveIndicator` popover so its "Reset to + // deployed" button can do the same thing the load-time toast offers. + onResetToDeployed?: () => void | Promise + // See ScriptBuilderProps — same semantics for the flow editor's + // indicator. + loadedFromDraft?: boolean + othersDraftsCount?: number + onOpenOthersDrafts?: () => void // Fired whenever a test run is started from the flow editor, with the // preview job id. Used by whitelabel embedders to track test jobs. onTestJob?: (e: { jobId: string }) => void diff --git a/frontend/src/lib/components/flows/CreateActionsApp.svelte b/frontend/src/lib/components/flows/CreateActionsApp.svelte index 5cc0df1502..fba509f8a9 100644 --- a/frontend/src/lib/components/flows/CreateActionsApp.svelte +++ b/frontend/src/lib/components/flows/CreateActionsApp.svelte @@ -26,10 +26,10 @@ // Navigation to /apps_raw/add triggers a full page reload (for cross-origin isolation), // so the in-memory importStore would be lost. Use sessionStorage instead. sessionStorage.setItem('rawAppImport', JSON.stringify(parsed)) - await goto('/apps_raw/add?nodraft=true') + await goto('/apps_raw/add') } else { $importStore = parsed - await goto('/apps/add?nodraft=true') + await goto('/apps/add') } drawer?.closeDrawer?.() } @@ -40,12 +40,12 @@ function selectLowCode() { appTypeModalOpen = false - goto(`${base}/apps/add?nodraft=true`) + goto(`${base}/apps/add`) } function selectFullCode() { appTypeModalOpen = false - goto(`${base}/apps_raw/add?nodraft=true`) + goto(`${base}/apps_raw/add`) } diff --git a/frontend/src/lib/components/flows/CreateActionsFlow.svelte b/frontend/src/lib/components/flows/CreateActionsFlow.svelte index dd975fcc72..1539504ecc 100644 --- a/frontend/src/lib/components/flows/CreateActionsFlow.svelte +++ b/frontend/src/lib/components/flows/CreateActionsFlow.svelte @@ -33,7 +33,7 @@ async function importRaw() { $importFlowStore = importType === 'yaml' ? YAML.parse(pendingRaw ?? '') : JSON.parse(pendingRaw ?? '') - await goto('/flows/add?nodraft=true') + await goto('/flows/add') drawer?.closeDrawer?.() } @@ -41,13 +41,13 @@ const parsed = wacImportType === 'yaml' ? YAML.parse(pendingWacRaw ?? '') : JSON.parse(pendingWacRaw ?? '') $importScriptStore = parsed - await goto(`${base}/scripts/add?import=true&nodraft=true`) + await goto(`${base}/scripts/add?import=true`) wacDrawer?.closeDrawer?.() } function handleFlowClick() { if (skipModal) { - goto(`${base}/flows/add?nodraft=true`) + goto(`${base}/flows/add`) } else { flowModalOpen = true } @@ -55,17 +55,17 @@ function selectFlowEditor() { flowModalOpen = false - goto(`${base}/flows/add?nodraft=true`) + goto(`${base}/flows/add`) } function selectWacPython() { flowModalOpen = false - goto(`${base}/scripts/add?nodraft=true&wac=python`) + goto(`${base}/scripts/add?wac=python`) } function selectWacTypescript() { flowModalOpen = false - goto(`${base}/scripts/add?nodraft=true&wac=typescript`) + goto(`${base}/scripts/add?wac=typescript`) } function toggleSkipModal() { diff --git a/frontend/src/lib/components/flows/content/FlowEditorDrawer.svelte b/frontend/src/lib/components/flows/content/FlowEditorDrawer.svelte index 30311e2fa3..0ce0843bbf 100644 --- a/frontend/src/lib/components/flows/content/FlowEditorDrawer.svelte +++ b/frontend/src/lib/components/flows/content/FlowEditorDrawer.svelte @@ -24,23 +24,14 @@ flowEditorDrawer?.openDrawer?.() try { - const flowWithDraft = await FlowService.getFlowByPathWithDraft({ + const backendFlow = await FlowService.getFlowByPath({ workspace: $workspaceStore!, path }) - savedFlow = { - ...structuredClone(flowWithDraft), - draft: flowWithDraft.draft - ? { - ...structuredClone(flowWithDraft.draft), - path: flowWithDraft.draft.path ?? flowWithDraft.path - } - : undefined - } as Flow & { draft?: Flow } + savedFlow = structuredClone(backendFlow) as Flow - // Use the draft if available, otherwise the deployed flow - flow = flowWithDraft.draft ?? flowWithDraft + flow = backendFlow await initFlow(flow, flowStore, flowStateStore) loading = false @@ -53,11 +44,7 @@ let callback: (() => void) | undefined = undefined let flowPath: string = $state('') let flow: Flow | undefined = $state(undefined) - let savedFlow: - | (Flow & { - draft?: Flow | undefined - }) - | undefined = $state(undefined) + let savedFlow: Flow | undefined = $state(undefined) let loading = $state(true) const flowStore: StateStore = $state({ diff --git a/frontend/src/lib/components/forkDraftToImport.ts b/frontend/src/lib/components/forkDraftToImport.ts new file mode 100644 index 0000000000..0ed9ea3efc --- /dev/null +++ b/frontend/src/lib/components/forkDraftToImport.ts @@ -0,0 +1,60 @@ +import { goto } from '$lib/navigation' +import { base } from '$app/paths' +import type { Flow, NewScript, UserDraftItemKind } from '$lib/gen' +import { importStore } from '$lib/components/apps/store' +import { importFlowStore } from '$lib/components/flows/flowStore.svelte' +import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte' +import { getUsernameForNamespace } from '$lib/userNamespace' + +/** + * Re-home the source path into the forker's namespace: drop the first two + * segments, prefix `u/{me}`. `u/admin/myflow` → `u/me/myflow`. + */ +function forkSeedPath(sourcePath: string): string { + const rest = sourcePath.split('/').slice(2).join('/') + return `u/${getUsernameForNamespace()}/${rest}` +} + +/** + * Open a fetched draft value as a brand-new item of `itemKind`, via the same + * one-shot import handoff as "Import from YAML/JSON": stash the payload in the + * import store and route to the kind's `/add` page. The fork behaves like a new + * item of one's own — nothing saved until the first edit, no source identity + * carried over. The re-homed source path travels as `?seed_path=` (not `?path=`, + * which ScriptBuilder strips in transit) so the Path widget starts recognizable. + * Only the cross-user-visible kinds can be forked. + */ +export function forkDraftToImport( + itemKind: UserDraftItemKind, + value: unknown, + sourcePath: string +): void { + const seed = `?seed_path=${encodeURIComponent(forkSeedPath(sourcePath))}` + switch (itemKind) { + case 'script': + importScriptStore.set(value as NewScript) + goto(`${base}/scripts/add${seed}`) + return + case 'flow': + importFlowStore.set(value as Flow) + goto(`${base}/flows/add${seed}`) + return + case 'app': + // App drafts store the bare `App` value (no summary/policy + // wrapper) — the /apps/edit import branch accepts both shapes. + importStore.set(value as any) + goto(`${base}/apps/add${seed}`) + return + case 'raw_app': { + // Raw-app drafts bundle `{files, runnables, data, summary, + // policy, ...}` flat; wrap so the /apps_raw/edit import branch + // picks up summary and policy alongside the value. + const v = value as any + importStore.set({ summary: v?.summary ?? '', value: v, policy: v?.policy }) + goto(`${base}/apps_raw/add${seed}`) + return + } + default: + throw new Error(`Cannot fork drafts of kind ${itemKind}`) + } +} diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index 80b04fa455..8c821bb152 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -61,7 +61,6 @@ type?: U time?: number starred?: boolean - has_draft?: boolean hash?: string } @@ -241,9 +240,13 @@ async function showCode(path: string, summary: string) { viewCodeTitle = summary || path await viewCodeDrawer?.openDrawer() + // `getDraft: true` so draft-only scripts (no deployed row at this + // path) still return their content via the per-user draft overlay + // instead of 404'ing. script = await ScriptService.getScriptByPath({ workspace: $workspaceStore!, - path + path, + getDraft: true }) } diff --git a/frontend/src/lib/components/home/treeViewUtils.ts b/frontend/src/lib/components/home/treeViewUtils.ts index 3196621a4d..1bbe013e46 100644 --- a/frontend/src/lib/components/home/treeViewUtils.ts +++ b/frontend/src/lib/components/home/treeViewUtils.ts @@ -5,7 +5,6 @@ type TableItem = T & { type?: U time?: number starred?: boolean - has_draft?: boolean } type TableScript = TableItem diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index b62e047e84..adafc8256a 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -43,6 +43,7 @@ import { runScriptAndPollResult } from '../jobs/utils' import { RawAppHistoryManager } from './RawAppHistoryManager.svelte' import { sendUserToast } from '$lib/utils' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { buildDataTableWhitelist, parseDataTableRef, @@ -78,9 +79,6 @@ onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void /** Fired after a successful deploy; the session preview reloads on it. */ onDeploy?: (e: { path: string }) => void - /** Fired after a successful server-draft save; the session refreshes its - * draft-bar count on it (parity with the script/flow editors). */ - onSaveDraft?: (e: { path: string }) => void /** Initial collapsed state for the file/runnable sidebar. The user's * toggled preference is persisted under `sidebarStorageKey`; this prop * only seeds the very first open. */ @@ -98,6 +96,15 @@ * still toggle the mode after mount; this prop only seeds the * initial state. */ defaultSplitWithPreview?: boolean + /** User-typed path when it differs from `savedApp.path`. The route injects + * it as `draft_path` so the home row shows the friendly name, not `draft_{uuid}`. */ + pendingDraftPath?: string | undefined + // Threaded to the AutosaveIndicator's "Reset to deployed" button. + onResetToDeployed?: () => void | Promise + // See ScriptBuilderProps — same indicator semantics. + loadedFromDraft?: boolean + othersDraftsCount?: number + onOpenOthersDrafts?: () => void onRuntimeLogRequester?: (requester: RawAppRuntimeLogRequester | undefined) => void onRunsProvider?: (provider: RawAppRunsProvider | undefined) => void } @@ -115,11 +122,15 @@ diffDrawer = undefined, onNavigate, onDeploy = undefined, - onSaveDraft = undefined, defaultSidebarCollapsed = false, sidebarStorageKey = 'raw-app-sidebar-collapsed', liveEditorDraftStoragePath = undefined, defaultSplitWithPreview = true, + pendingDraftPath = $bindable(undefined), + onResetToDeployed, + loadedFromDraft = false, + othersDraftsCount = 0, + onOpenOthersDrafts, onRuntimeLogRequester = undefined, onRunsProvider = undefined }: Props = $props() @@ -1396,7 +1407,51 @@ return () => window.removeEventListener('keydown', onEscapeCapture, true) }) + // Force an immediate flush. No toast — the AutosaveIndicator narrates the + // result, and `flush` never rejects (postSave routes errors to the failures map). + function flushDraft() { + if (!$workspaceStore || !liveEditorDraftStoragePath) return + void UserDraftDbSyncer.flush({ + workspace: $workspaceStore, + itemKind: 'raw_app', + path: liveEditorDraftStoragePath + }) + } + + // The VS Code workbench iframe's keydowns don't bubble out, so the window + // handler can't see Ctrl/Cmd+S while editing code. Attach a capture listener + // inside the iframe per load (it dies with the iframe, so no leak). No + // preventDefault: VS Code's own save still runs; we just flush alongside it. + function attachIframeSaveShortcut() { + const win = iframe?.contentWindow + if (!win) return + win.addEventListener( + 'keydown', + (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 's' || e.key === 'S')) { + flushDraft() + } + }, + true + ) + } + + // Monaco swallows Ctrl/Cmd+S in inline editors; Editor/SimpleEditor + // re-broadcast it as `wm-monaco-save-shortcut` (untyped, hence manual listener). + $effect(() => { + window.addEventListener('wm-monaco-save-shortcut', flushDraft) + return () => window.removeEventListener('wm-monaco-save-shortcut', flushDraft) + }) + function handleKeydown(e: KeyboardEvent) { + // Ctrl/Cmd + S — catch this BEFORE the input/Monaco guard below so + // the shortcut fires regardless of focus. + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === 's' || e.key === 'S')) { + e.preventDefault() + flushDraft() + return + } + // Skip when typing in an input, textarea, or Monaco editor. const classes = (e.target as HTMLElement | null)?.className if ( @@ -1439,6 +1494,7 @@ bind:jobsById bind:savedApp bind:summary + bind:pendingDraftPath on:restore on:savedNewAppPath {policy} @@ -1453,7 +1509,10 @@ {getBundle} {onNavigate} {onDeploy} - {onSaveDraft} + {onResetToDeployed} + {loadedFromDraft} + {othersDraftsCount} + {onOpenOthersDrafts} canUndo={historyManager.canUndo} canRedo={historyManager.canRedo} onUndo={handleUndo} @@ -1592,6 +1651,7 @@ title="UI builder" src="/ui_builder/index.html" class="w-full h-full block" + onload={attachIframeSaveShortcut} > {/if}
diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index 46dec152f7..e21d1cd914 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -5,8 +5,9 @@ import { editPathFor } from '$lib/components/workspacePicker' import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte' - import { AppService, DraftService, type Policy } from '$lib/gen' + import { AppService, type Policy } from '$lib/gen' import { UserDraft } from '$lib/userDraft.svelte' + import { discardDraftAfterDeploy } from '$lib/userDraftToast' import { rawAppToHubUrl } from '$lib/hub' import { enterpriseLicense, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores' import YAML from 'yaml' @@ -26,32 +27,25 @@ WandSparkles } from 'lucide-svelte' import { createEventDispatcher, untrack } from 'svelte' - import { - cleanValueProperties, - orderedJsonStringify, - type Value, - replaceFalseWithUndefined - } from '../../utils' + import { orderedJsonStringify, type Value, replaceFalseWithUndefined } from '../../utils' import { random_adj } from '$lib/components/random_positive_adjetive' // import { allItems, toStatic } from '../apps/editor/settingsPanel/utils' import AppExportButton from '../apps/editor/AppExportButton.svelte' - import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' import { sendUserToast } from '$lib/toast' import DeploymentHistory from '../apps/editor/DeploymentHistory.svelte' import Awareness from '$lib/components/Awareness.svelte' import type DiffDrawer from '$lib/components/DiffDrawer.svelte' import EditorHeader from '$lib/components/EditorHeader.svelte' + import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte' import { goto } from '$app/navigation' import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte' import AppJobsDrawer from '../apps/editor/AppJobsDrawer.svelte' - import type { SavedAndModifiedValue } from '../common/confirmationModal/unsavedTypes' import DropdownV2 from '../DropdownV2.svelte' import { stateSnapshot } from '$lib/svelte5Utils.svelte' - import AppEditorHeaderDeployInitialDraft from '../apps/editor/AppEditorHeaderDeployInitialDraft.svelte' import AppEditorHeaderDeploy from '../apps/editor/AppEditorHeaderDeploy.svelte' import type { Runnable } from './RawAppInlineScriptRunnable.svelte' import { updateRawAppPolicy } from './rawAppPolicy' @@ -103,11 +97,9 @@ savedApp?: | { value: any - draft?: any path: string summary: string policy: any - draft_only?: boolean custom_path?: string } | undefined @@ -136,9 +128,20 @@ liveEditorDraftStoragePath?: string // Fired after a successful deploy; lets the session preview reload. onDeploy?: (e: { path: string }) => void - // Fired after a successful server-draft save; lets the session refresh the - // draft-bar count (the script/flow editors do the same on save-draft). - onSaveDraft?: (e: { path: string }) => void + /** Surfaces the user-typed path (`newEditedPath`) up to the route + * when (and only when) it differs from the deployed/seeded + * `savedApp.path`. The route writes it into the autosaved raw-app + * draft as `draft_path` so the home-page row can render the + * friendly name instead of the URL's autogenerated draft slot. */ + pendingDraftPath?: string | undefined + // Threaded to the `AutosaveIndicator` popover so its "Reset to + // deployed" button can do the same thing the load-time toast offers. + onResetToDeployed?: () => void | Promise + // See ScriptBuilderProps — same semantics for the raw-app editor's + // indicator. + loadedFromDraft?: boolean + othersDraftsCount?: number + onOpenOthersDrafts?: () => void } let { @@ -166,9 +169,21 @@ onNavigate = undefined, liveEditorDraftStoragePath = undefined, onDeploy = undefined, - onSaveDraft = undefined + pendingDraftPath = $bindable(undefined), + onResetToDeployed, + loadedFromDraft = false, + othersDraftsCount = 0, + onOpenOthersDrafts }: Props = $props() + $effect(() => { + const typed = newEditedPath + const baseline = savedApp?.path ?? '' + untrack(() => { + pendingDraftPath = typed && typed !== baseline ? typed : undefined + }) + }) + let newEditedPath = $state( untrack(() => newApp @@ -202,14 +217,12 @@ const loading = $state({ publish: false, - save: false, - saveDraft: false + save: false }) let pathError: string = $state('') let appExport = $state() as AppExportButton | undefined - let draftDrawerOpen = $state(false) let saveDrawerOpen = $state(false) let historyBrowserDrawerOpen = $state(false) let publishToHubDrawerOpen = $state(false) @@ -255,10 +268,6 @@ saveDrawerOpen = false } - function closeDraftDrawer() { - draftDrawerOpen = false - } - async function computeTriggerables() { policy = await updateRawAppPolicy(runnables, policy) } @@ -301,7 +310,19 @@ } closeSaveDrawer() sendUserToast('App deployed successfully') - if (!inSessionPane) UserDraft.remove('raw_app', path) + // Canonical autosave key (the URL slot `appPath`), NOT the + // just-typed deploy `path` — for a draft-only app they differ + // (`u/{user}/draft_{uuid}` vs the chosen path), so removing at + // `path` orphaned the real draft row. Bracketed + flushed: + // RawAppEditor stays mounted through the post-deploy navigation + // and its mirror would otherwise displace the queued delete. + if (!inSessionPane && $workspaceStore) { + discardDraftAfterDeploy({ + workspace: $workspaceStore, + itemKind: 'raw_app', + path: appPath + }) + } dispatch('savedNewAppPath', path) onDeploy?.({ path }) } catch (e) { @@ -330,7 +351,7 @@ replaceFalseWithUndefined({ summary: summary, value: app, - path: newEditedPath || savedApp.draft?.path || savedApp.path, + path: newEditedPath || savedApp.path, policy, custom_path: customPath }) @@ -380,11 +401,10 @@ diffDrawer?.setDiff({ mode: 'normal', deployed: deployedValue ?? savedApp, - draft: savedApp.draft, current: { summary: summary, value: app, - path: newEditedPath || savedApp.draft?.path || savedApp.path, + path: newEditedPath || savedApp.path, policy, custom_path: customPath } @@ -436,7 +456,14 @@ closeSaveDrawer() sendUserToast('App deployed successfully') - if (!inSessionPane) UserDraft.remove('raw_app', appPath) + // Bracketed + flushed (see createApp). + if (!inSessionPane && $workspaceStore) { + discardDraftAfterDeploy({ + workspace: $workspaceStore, + itemKind: 'raw_app', + path: appPath + }) + } if (appPath !== npath) { dispatch('savedNewAppPath', npath) } @@ -462,185 +489,6 @@ return } - async function saveInitialDraft() { - if (!app) { - sendUserToast(`App hasn't been loaded yet`, true) - return - } - await computeTriggerables() - try { - let { css, js } = await getBundle() - await AppService.createAppRaw({ - workspace: $workspaceStore!, - formData: { - app: { - value: app, - path: newEditedPath, - summary: summary, - policy, - draft_only: true, - custom_path: customPath - }, - js, - css - } - }) - await DraftService.createDraft({ - workspace: $workspaceStore!, - requestBody: { - path: newEditedPath, - typ: 'app', - value: { - value: app, - path: newEditedPath, - summary: summary, - policy, - custom_path: customPath - } - } - }) - savedApp = { - summary: summary, - value: structuredClone(stateSnapshot(app)), - path: newEditedPath, - policy, - draft_only: true, - draft: { - summary: summary, - value: structuredClone(stateSnapshot(app)), - path: newEditedPath, - policy, - custom_path: customPath - }, - custom_path: customPath - } - - draftDrawerOpen = false - // The initial draft was promoted to a real path on the backend — - // drop the autosave keyed on the prior (possibly empty) path so - // a future "+ App" click opens on a clean slate. - if (!inSessionPane) UserDraft.remove('raw_app', appPath) - dispatch('savedNewAppPath', newEditedPath) - sendUserToast('Draft saved') - onSaveDraft?.({ path: newEditedPath }) - } catch (e) { - sendUserToast(`Error saving initial draft: ${e.body ?? e.message}`, true) - } - draftDrawerOpen = false - } - - async function saveDraft(forceSave = false) { - if (!app) { - sendUserToast(`App hasn't been loaded yet`, true) - return - } - if (newApp) { - if (appPath === '') { - // Standalone "+ App" with no path chosen yet — pick one via the drawer. - draftDrawerOpen = true - return - } - // Path already known (e.g. an AI-created raw app in the session preview). - // The path-picker drawer is gated on `appPath == ''`, so opening it here - // renders nothing — save the initial draft directly instead. - await saveInitialDraft() - return - } - if (!savedApp) { - return - } - const draftOrDeployed = cleanValueProperties(savedApp.draft || savedApp) - const current = cleanValueProperties({ - summary: summary, - value: app, - path: newEditedPath || savedApp.draft?.path || savedApp.path, - policy - }) - if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) { - sendUserToast('No changes detected, ignoring', false, [ - { - label: 'Save anyway', - callback: () => { - saveDraft(true) - } - } - ]) - return - } - loading.saveDraft = true - try { - await computeTriggerables() - let path = appPath - if (savedApp.draft_only) { - await AppService.deleteApp({ - workspace: $workspaceStore!, - path: path - }) - let { css, js } = await getBundle() - - await AppService.createAppRaw({ - workspace: $workspaceStore!, - formData: { - app: { - value: app!, - summary: summary, - policy, - path: newEditedPath || path, - draft_only: true, - custom_path: customPath - }, - js, - css - } - }) - } - await DraftService.createDraft({ - workspace: $workspaceStore!, - requestBody: { - path: savedApp.draft_only ? newEditedPath || path : path, - typ: 'app', - value: { - value: app!, - summary: summary, - policy, - path: newEditedPath || path - } - } - }) - - savedApp = { - ...(savedApp?.draft_only - ? { - summary: summary, - value: structuredClone(stateSnapshot(app)), - path: savedApp.draft_only ? newEditedPath || path : path, - policy, - draft_only: true, - custom_path: customPath - } - : savedApp), - draft: { - summary: summary, - value: structuredClone(stateSnapshot(app)), - path: newEditedPath || path, - policy, - custom_path: customPath - } - } - - sendUserToast('Draft saved') - if (!inSessionPane) UserDraft.remove('raw_app', path) - loading.saveDraft = false - if (newApp || savedApp.draft_only) { - dispatch('savedNewAppPath', newEditedPath || path) - } - onSaveDraft?.({ path: newEditedPath || path }) - } catch (e) { - loading.saveDraft = false - throw e - } - } - let onLatest = $state(true) async function compareVersions() { if (version === undefined) { @@ -663,13 +511,6 @@ let moreItems = $derived([ ...(compactTopbar ? [ - { - displayName: 'Save draft', - icon: Save, - action: () => saveDraft(), - shortcut: `${mod}S`, - disabled: !newApp && !savedApp - }, { displayName: `Jobs (${jobs?.length > 99 ? '99+' : (jobs?.length ?? 0)})`, icon: Bug, @@ -729,18 +570,6 @@ let jobsDrawerOpen = $state(false) - function getInitialAndModifiedValues(): SavedAndModifiedValue { - return { - savedValue: savedApp, - modifiedValue: { - summary: summary, - value: app, - path: newEditedPath || savedApp?.draft?.path || savedApp?.path, - policy, - custom_path: customPath - } - } - } let app = $derived(files ? { runnables: runnables, files, data } : undefined) $effect(() => { @@ -748,14 +577,6 @@ }) - -{#if !inSessionPane} - -{/if} - -{#if appPath == ''} - - closeDraftDrawer()}> - {#snippet actions()} -
- -
- {/snippet} - -
-
-{/if} closeSaveDrawer()}> {#snippet actions()}
{#if $enterpriseLicense && appPath != ''} @@ -979,9 +786,9 @@ variant="default" unifiedSize="md" on:click={() => openDiffDrawer()} - disabled={!savedApp} + disabled={!savedApp || newApp} iconOnly={compactTopbar} - title="Diff" + title={newApp ? 'Deploy this app once to compare against the deployed version' : 'Diff'} startIcon={{ icon: DiffIcon }} > Diff @@ -1020,19 +827,6 @@ AI {/if} - {#if !compactTopbar} - - {/if} + {/each} +
+
+ +
+

Data configuration

+ + {#if hasNoDatatables} + + You can still create an app, but for data storage you won't be able to use data tables + which are highly recommended. +
+ {#if $userStore?.is_admin} + Configure datatables in + workspace settings + to enable this feature. + {:else} + Ask your workspace admin to configure datatables in workspace settings to enable this + feature. + {/if} +
+ {:else} +
+
+ Default settings for new tables +
+
+
+ + +
+ {/if} +
+ {#if newSchemaAlreadyExists} + Schema "{newSchemaName}" already exists + {/if} +
+
+
+
+ +
+ +
+ +
+ dataTableDrawer?.openDrawer()} + onRemove={(index) => { + preWhitelistedTables = preWhitelistedTables.filter((_, i) => i !== index) + }} + /> +
+
+ {/if} +
+ +
+

+ + Start with AI + (optional) +

+ + {#if !isAiEnabled} + + You can still create an app manually but using AI is highly recommended. +
+ {#if $userStore?.is_admin} + Configure AI in + workspace settings + to enable this feature. + {:else} + Ask your workspace admin to configure AI in workspace settings to enable this feature. + {/if} +
+ {:else} +
+ +

+ Leave empty to start with a blank template, or describe your app to get AI assistance + right away. +

+
+ {/if} +
+ +
+ + {#if isAiEnabled} + + {/if} +
+
+ +{/if} + + { + preWhitelistedTables = [...preWhitelistedTables, ref] + }} +/> diff --git a/frontend/src/lib/components/script_builder.ts b/frontend/src/lib/components/script_builder.ts index cac47d4eef..2bd16dd5c8 100644 --- a/frontend/src/lib/components/script_builder.ts +++ b/frontend/src/lib/components/script_builder.ts @@ -1,10 +1,10 @@ -import type { NewScript } from '$lib/gen' +import type { NewScript, Script } from '$lib/gen' import type { AssetWithAltAccessType } from './assets/lib' import type { ScriptBuilderWhitelabelCustomUi } from './custom_ui' import type { DiffDrawerI } from './diff_drawer' import type { ScriptBuilderFunctionExports } from './scriptBuilder' import type { ScheduleTrigger } from './triggers' -import type { NewScriptWithDraftAndDraftTriggers, Trigger } from './triggers/utils' +import type { Trigger } from './triggers/utils' import type { WorkspaceItem } from './workspacePicker' export interface ScriptBuilderProps { @@ -15,6 +15,18 @@ export interface ScriptBuilderProps { disableAi?: boolean fullyLoaded?: boolean initialPath?: string + /** + * Path the route's `UserDraft.use('script', ...)` + * handle is keyed by. Distinct from `initialPath` for new drafts — + * `initialPath` is the displayed/editor path (empty for new), while + * this is the URL path the draft is persisted under (`u/{user}/ + * draft_{uuid}`). Used to bracket the bootstrap `initContent` write + * with `UserDraft.stopSync` / `restartSync` so the template seed + * doesn't POST before the user's first real edit. Default to `''` + * for backwards compat with callers that don't manage drafts; the + * stop/restart pair is a no-op on a non-live entry. + */ + userDraftPath?: string template?: | 'docker' | 'bunnative' @@ -29,7 +41,7 @@ export interface ScriptBuilderProps { showMeta?: boolean neverShowMeta?: boolean diffDrawer?: DiffDrawerI | undefined - savedScript?: NewScriptWithDraftAndDraftTriggers | undefined + savedScript?: Script | NewScript | undefined searchParams?: URLSearchParams disableHistoryChange?: boolean customUi?: ScriptBuilderWhitelabelCustomUi @@ -41,12 +53,8 @@ export interface ScriptBuilderProps { // the deployed item) — consumers should skip post-deploy navigation when set. onDeploy?: (e: { path: string; hash: string; stay: boolean }) => void onDeployError?: (e: { path: string; error: any }) => void - onSaveInitial?: (e: { path: string; hash: string }) => void onHistoryRestore?: () => void - onSaveDraftOnlyAtNewPath?: (e: { path: string }) => void - onSaveDraft?: (e: { path: string; savedAtNewPath: boolean; script: NewScript }) => void onSeeDetails?: (e: { path: string }) => void - onSaveDraftError?: (e: { path: string; error: any }) => void onNavigate?: (item: WorkspaceItem) => void // Fired whenever a test run is started from the script editor, with the // preview job id. Used by whitelabel embedders to track test jobs. @@ -59,4 +67,19 @@ export interface ScriptBuilderProps { // overwrite it. Used by the session preview, which opens AI-created scripts // as new but with a path the AI already assigned. initialPathChosen?: boolean + // Threaded to the `AutosaveIndicator` popover so its "Reset to + // deployed" button can do the same thing the load-time toast offers. + // Routes pass their own re-load-without-draft callback here; omit on + // callers (session preview, embedded SDK) that shouldn't surface the + // action at all. + onResetToDeployed?: () => void | Promise + // Triggers the AutosaveIndicator's on-mount "Loaded from draft" hint + // (with a one-shot green flash) the first time it flips to true. + loadedFromDraft?: boolean + // Non-zero when other workspace users have a draft at this path. + // Drives both the indicator's hint label ("Others are working on + // this script") and the popover's "See others' drafts" button. + othersDraftsCount?: number + // Wired by the route to flip the OtherUsersDraftsModal open. + onOpenOthersDrafts?: () => void } diff --git a/frontend/src/lib/components/scripts/CreateActionsScript.svelte b/frontend/src/lib/components/scripts/CreateActionsScript.svelte index 5abd8235f2..48480918e0 100644 --- a/frontend/src/lib/components/scripts/CreateActionsScript.svelte +++ b/frontend/src/lib/components/scripts/CreateActionsScript.svelte @@ -14,7 +14,7 @@ unifiedSize="lg" variant="accent" startIcon={{ icon: Plus }} - href="{base}/scripts/add?nodraft=true" + href="{base}/scripts/add" endIcon={{ icon: Code2 }} > Script diff --git a/frontend/src/lib/components/search/GlobalSearchModal.svelte b/frontend/src/lib/components/search/GlobalSearchModal.svelte index 21b79ea6b9..19e71530e6 100644 --- a/frontend/src/lib/components/search/GlobalSearchModal.svelte +++ b/frontend/src/lib/components/search/GlobalSearchModal.svelte @@ -472,7 +472,6 @@ type?: U time?: number starred?: boolean - has_draft?: boolean } // interface SelectableSearchMenuItem { diff --git a/frontend/src/lib/components/sessions/FlowEditorView.svelte b/frontend/src/lib/components/sessions/FlowEditorView.svelte index 9613b4c25a..6dc761fdc8 100644 --- a/frontend/src/lib/components/sessions/FlowEditorView.svelte +++ b/frontend/src/lib/components/sessions/FlowEditorView.svelte @@ -66,11 +66,6 @@ {diffDrawer} {onNavigate} customUi={{ topBar: { aiBuilder: false } }} - onSaveDraft={() => { - runtime.scheduleForkComparisonRefresh() - // Saving a draft adds/keeps a pending draft — refresh the Draft Count. - invalidateWorkspaceDrafts(workspaceId) - }} onDeploy={() => { // FlowBuilder has no deploy toast and the session stays put, so toast // here, then sync the preview to deployed (pulls the new locks + version_id). diff --git a/frontend/src/lib/components/sessions/RawAppEditorView.svelte b/frontend/src/lib/components/sessions/RawAppEditorView.svelte index 8b2d435daa..ca21b9be03 100644 --- a/frontend/src/lib/components/sessions/RawAppEditorView.svelte +++ b/frontend/src/lib/components/sessions/RawAppEditorView.svelte @@ -78,11 +78,6 @@ // Deploying clears the item's pending draft — refresh the Draft Count. invalidateWorkspaceDrafts(workspaceId) }} - onSaveDraft={() => { - // Saving a server draft adds/updates a draft — refresh the Draft Count so - // the session draft bar appears/updates immediately (parity with script/flow). - invalidateWorkspaceDrafts(workspaceId) - }} defaultSidebarCollapsed sidebarStorageKey="raw-app-sidebar-collapsed-preview" defaultSplitWithPreview={false} diff --git a/frontend/src/lib/components/sessions/ScriptEditorView.svelte b/frontend/src/lib/components/sessions/ScriptEditorView.svelte index 2e6fc4210f..88b8e4da44 100644 --- a/frontend/src/lib/components/sessions/ScriptEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScriptEditorView.svelte @@ -3,8 +3,9 @@ import DiffDrawer from '$lib/components/DiffDrawer.svelte' import type { WorkspaceItem } from '$lib/components/workspacePicker' import type { SessionRuntime } from './sessionRuntime.svelte' - import { DraftService, ScriptService, type NewScript } from '$lib/gen' + import type { NewScript } from '$lib/gen' import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import SessionEditorTarget from './SessionEditorTarget.svelte' import { sendUserToast } from '$lib/toast' import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' @@ -41,18 +42,28 @@ return } diffDrawer?.closeDrawer() - // Drop the backend (DB) draft too, so "deployed" sticks across a reload. - if (saved.draft) { - try { - await DraftService.deleteDraft({ workspace: workspaceId, kind: 'script', path: saved.path }) - saved.draft = undefined - // Server draft gone — refresh the session draft-bar count immediately - // instead of waiting for an AI turn-end / tab-refocus signal. - invalidateWorkspaceDrafts(workspaceId) - } catch (e: any) { - sendUserToast(`Could not delete draft: ${e?.body ?? e}`, true) - return - } + // Drop the user's per-user draft too, so "deployed" sticks across + // a reload. The overlay sets `is_draft: true` when a draft exists + // for the authed user; the syncer's `value: null` POST is the + // canonical per-user delete. + // + // Fire-and-forget: every read here (`saved`, the snapshot we build + // below, the UserDraft.discard write) is purely in-memory, so we + // don't need the DELETE to have landed to finish the restore. We + // flip `is_draft` optimistically so the UI matches the new intent + // immediately. A failed DELETE only matters across a hard reload + // before it lands — log and move on. + if (saved.is_draft) { + saved.is_draft = false + UserDraftDbSyncer.save({ + workspace: workspaceId, + itemKind: 'script', + path: saved.path, + value: null + }).catch((e) => console.error('restoreDeployed: draft delete failed', e)) + // Per-user draft gone — refresh the session draft-bar count immediately + // instead of waiting for an AI turn-end / tab-refocus signal. + invalidateWorkspaceDrafts(workspaceId) } const deployed = structuredClone($state.snapshot(saved)) as NewScript & { draft?: unknown } delete deployed.draft @@ -105,24 +116,6 @@ {diffDrawer} {onNavigate} {initialTestPanelCollapsed} - onSaveDraft={async (e) => { - runtime.scheduleForkComparisonRefresh() - // Saving a draft adds/keeps a pending draft — refresh the Draft Count. - invalidateWorkspaceDrafts(workspaceId) - // Re-pin parent_hash to the latest version so the next Deploy's conflict - // check (which runs before deploy, while the session stays mounted) - // doesn't misfire. - try { - const latest = await ScriptService.getScriptLatestVersion({ - workspace: workspaceId, - path: e.path - }) - const cur = runtime.scriptStore.val - if (latest?.script_hash && cur) cur.parent_hash = latest.script_hash - } catch (err) { - console.error('Failed to sync parent_hash after save draft', err) - } - }} onDeploy={(e) => { // Fires on every deploy (primary, "Deploy & Stay here", and lib — we // ignore e.stay since the session always stays). Toast, then sync the diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 5509c1dc25..bfeddd1f66 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -9,9 +9,20 @@ import { WorkspaceService, type Flow, type NewScript, - type NewScriptWithDraft, + type Script, + type UserDraftOverlay, type WorkspaceComparison } from '$lib/gen' + +// `get_draft=true` does NOT merge: the top-level fields stay the deployed +// payload and the user's draft rides in a sibling `.draft` pocket (with +// `is_draft` / `draft_saved_at` alongside). Hence the `saved.draft ?? saved` +// fall-throughs below prefer the draft, else the deployed payload. +// The generated `UserDraftOverlay` types `draft` permissively; locally it's a +// `NewScript` / `Flow`, so `Omit` and re-add the precise type (assignments from +// the response type still need an explicit cast). +type SavedScript = Omit - -{#if value} -
- {#key value} - { - goto(`/apps/edit/${path}`) - }} - {summary} - app={value} - path={''} - {policy} - fromHub={hubId != null} - newApp={true} - replaceStateFn={(path) => replaceState(path, page.state)} - gotoFn={(path, opt) => goto(path, opt)} - > - {#snippet unsavedConfirmationModal({ - diffDrawer, - additionalExitAction, - getInitialAndModifiedValues - })} - - {/snippet} - - {/key} -
-{/if} + diff --git a/frontend/src/routes/(root)/(logged)/apps/add/+page.ts b/frontend/src/routes/(root)/(logged)/apps/add/+page.ts new file mode 100644 index 0000000000..52ecb242b7 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/apps/add/+page.ts @@ -0,0 +1,6 @@ +import { makeDraftAddLoad } from '$lib/draftAddRedirect' +import type { PageLoad } from './$types' + +export const prerender = false + +export const load: PageLoad = makeDraftAddLoad('apps/edit') diff --git a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte index e2e1029d0e..2060acb14e 100644 --- a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte @@ -1,227 +1,252 @@ - - + { + // AppEditor's `stateApp` is captured at mount and ignores prop changes, + // so `redraw++` remounts it against the fresh `app`. + await loadApp() + redraw++ + }} + getLocalDraft={() => app?.value} + bind:othersModalOpen + {draftSavedAt} + {deployedAt} + onLoadLatestDeploy={async () => { + // stopSync-bracketed; see /scripts/edit's restoreDeployed for the race. + if (!$workspaceStore) return + await runResetToDeployed({ + workspace: $workspaceStore, + itemKind: 'app', + path, + onResetToDeployed: async () => { + UserDraft.remove('app', path) + await loadApp({ getDraft: false }) + redraw++ + } + }) + }} /> {#key redraw} {#if app}
{ goto(`/apps/edit/${url}`) if (app) { @@ -308,29 +349,24 @@ on:restore={onRestore} summary={app.summary} app={app.value} - newPath={app.path} + newPath={app.value?.draft_path ?? app.path} path={page.params.path ?? ''} policy={app.policy} bind:savedApp {diffDrawer} version={app.versions ? app.versions[app.versions.length - 1] : undefined} - newApp={false} - initialRevs={currentRevs} + newApp={isNewApp} replaceStateFn={(path) => replaceState(path, page.state)} gotoFn={(path, opt) => goto(path, opt)} - > - {#snippet unsavedConfirmationModal({ - diffDrawer, - additionalExitAction, - getInitialAndModifiedValues - })} - - {/snippet} - + onResetToDeployed={async () => { + UserDraft.remove('app', path) + await loadApp({ getDraft: false }) + redraw++ + }} + {loadedFromDraft} + othersDraftsCount={otherDraftsUsers.length} + onOpenOthersDrafts={() => (othersModalOpen = true)} + />
{/if} {/key} diff --git a/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte index 737ebb2e33..460b2957e0 100644 --- a/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte @@ -81,7 +81,7 @@ size="sm" startIcon={{ icon: Pen }} variant="subtle" - href="{base}/apps/edit/{app.path}?nodraft=true">EditEdit
{/if} diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.js b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.js deleted file mode 100644 index 1f2d07eb57..0000000000 --- a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.js +++ /dev/null @@ -1,5 +0,0 @@ -export function load() { - return { - stuff: { title: `New Raw App` } - } -} diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte index 716000215f..e54568a9ed 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte @@ -1,688 +1,5 @@ - - -{#if templatePicker} - -
- -
-

Summary

- -
- - -
-

Framework

-
- {#each templates as t, i} - - {/each} -
-
- - -
-

Data configuration

- - {#if hasNoDatatables} - - You can still create an app, but for data storage you won't be able to use data tables - which are highly recommended. -
- - {#if $userStore?.is_admin} - Configure datatables in - workspace settings - to enable this feature. - {:else} - Ask your workspace admin to configure datatables in workspace settings to enable this - feature. - {/if} -
- {:else} -
- -
- Default settings for new tables -
-
-
- - -
- {/if} -
- {#if newSchemaAlreadyExists} - Schema "{newSchemaName}" already exists - {/if} -
-
-
-
- - -
- -
- - -
- dataTableDrawer?.openDrawer()} - onRemove={(index) => { - preWhitelistedTables = preWhitelistedTables.filter((_, i) => i !== index) - }} - /> -
-
- {/if} -
- - -
-

- - Start with AI - (optional) -

- - {#if !isAiEnabled} - - You can still create an app manually but using AI is highly recommended. -
- {#if $userStore?.is_admin} - Configure AI in - workspace settings - - to enable this feature. - {:else} - Ask your workspace admin to configure AI in workspace settings to enable this feature. - {/if} -
- {:else} -
- -

- Leave empty to start with a blank template, or describe your app to get AI assistance - right away. -

-
- {/if} -
- - -
- - {#if isAiEnabled} - - {/if} -
-
-
-{/if} -{#key reloadCounter} - { - goto(`/apps_raw/edit/${event.detail}`) - }} - bind:files - bind:runnables - bind:data - {policy} - path={''} - liveEditorDraftStoragePath="" - bind:summary - newApp - /> -{/key} - - { - preWhitelistedTables = [...preWhitelistedTables, ref] - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.ts b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.ts new file mode 100644 index 0000000000..ea96e67846 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.ts @@ -0,0 +1,6 @@ +import { makeDraftAddLoad } from '$lib/draftAddRedirect' +import type { PageLoad } from './$types' + +export const prerender = false + +export const load: PageLoad = makeDraftAddLoad('apps_raw/edit') diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index 84ce23e7a5..3fc657c3ae 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -1,15 +1,9 @@ - - + loadApp()} + getLocalDraft={() => draftSync.draft} + bind:othersModalOpen + {draftSavedAt} + {deployedAt} + onLoadLatestDeploy={async () => { + // stopSync-bracketed; see /scripts/edit's restoreDeployed for the race. + if (!$workspaceStore) return + await runResetToDeployed({ + workspace: $workspaceStore, + itemKind: 'raw_app', + path, + onResetToDeployed: async () => { + draftSync.draft = undefined + await loadApp({ getDraft: false }) + } + }) + }} /> + + {#if files} {#key redraw}
{ - UserDraft.remove('raw_app', path) + draftSync.remove() goto(`/apps_raw/edit/${event.detail}`) newPath = event.detail }} @@ -392,13 +433,21 @@ bind:runnables bind:data bind:summary + bind:pendingDraftPath {newPath} path={page.params.path ?? ''} liveEditorDraftStoragePath={path} {policy} bind:savedApp {diffDrawer} - newApp={false} + newApp={isNewApp} + onResetToDeployed={async () => { + draftSync.draft = undefined + await loadApp({ getDraft: false }) + }} + {loadedFromDraft} + othersDraftsCount={otherDraftsUsers.length} + onOpenOthersDrafts={() => (othersModalOpen = true)} />
{/key} diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte index eefa19fcd3..9fdf4c5c7d 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte @@ -58,7 +58,7 @@ size="sm" startIcon={{ icon: Pen }} variant="subtle" - href="{base}/apps_raw/edit/{page.params.path}?nodraft=true">EditEdit
{/if} diff --git a/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte index a8d0b566b8..2d03b85398 100644 --- a/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte @@ -1,4 +1,5 @@ - - - - { - UserDraft.remove('flow', '') - if ($workspaceStore) invalidate($workspaceStore, 'flow') - goto(`/flows/edit/${e.path}?selected=${e.id}`) - }} - onDeploy={(e) => { - UserDraft.remove('flow', '') - if ($workspaceStore) invalidate($workspaceStore, 'flow') - goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) - }} - onDetails={(e) => { - goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) - }} - onNavigate={(item) => goto(editPathFor(item))} - {initialPath} - {pathStoreInit} - liveEditorDraftStoragePath="" - bind:this={flowBuilder} - newFlow - {initialArgs} - {flowStore} - {flowStateStore} - {selectedId} - {loading} - {draftTriggersFromUrl} - {selectedTriggerIndexFromUrl} - noInitial -> - - + diff --git a/frontend/src/routes/(root)/(logged)/flows/add/+page.ts b/frontend/src/routes/(root)/(logged)/flows/add/+page.ts new file mode 100644 index 0000000000..153c2255df --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/flows/add/+page.ts @@ -0,0 +1,6 @@ +import { makeDraftAddLoad } from '$lib/draftAddRedirect' +import type { PageLoad } from './$types' + +export const prerender = false + +export const load: PageLoad = makeDraftAddLoad('flows/edit') diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index b979a09758..1e1fcdccbc 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -1,35 +1,30 @@ - - + loadFlow()} + getLocalDraft={() => draftSync.draft} + bind:othersModalOpen + {draftSavedAt} + {deployedAt} + onLoadLatestDeploy={async () => { + // stopSync-bracketed; see /scripts/edit's restoreDeployed for the race. + if (!$workspaceStore) return + await runResetToDeployed({ + workspace: $workspaceStore, + itemKind: 'flow', + path: flowDraftPath, + onResetToDeployed: async () => { + draftSync.draft = undefined + await loadFlow({ getDraft: false }) + } + }) + }} /> {#if notFound}
@@ -404,25 +441,36 @@ {:else if renderEditor} { - UserDraft.remove('flow', flowDraftPath) + // stopSync-bracketed immediate delete; see /scripts/edit's restoreDeployed. + if ($workspaceStore) { + discardDraftAfterDeploy({ + workspace: $workspaceStore, + itemKind: 'flow', + path: flowDraftPath + }) + } if ($workspaceStore) invalidate($workspaceStore, 'flow') goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) }} onDetails={(e) => { goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`) }} - onSaveDraftOnlyAtNewPath={(e) => { - goto(`/flows/edit/${e.path}?selected=${e.selectedId}`) - }} onHistoryRestore={() => { loadFlow() }} + onResetToDeployed={async () => { + draftSync.draft = undefined + await loadFlow({ getDraft: false }) + }} + {loadedFromDraft} + othersDraftsCount={otherDraftsUsers.length} + onOpenOthersDrafts={() => (othersModalOpen = true)} onNavigate={(item) => goto(editPathFor(item))} {flowStore} {flowStateStore} - initialPath={page.params.path ?? ''} + bind:initialPath={flowInitialPath} liveEditorDraftStoragePath={flowDraftPath} - newFlow={false} + newFlow={isNewFlow} {selectedId} {initialArgs} {loading} @@ -434,10 +482,5 @@ {selectedTriggerIndexFromUrl} {version} {loadedFromHistoryFromUrl} - > - - + /> {/if} diff --git a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte index 088563b640..8741a8b93b 100644 --- a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte @@ -329,7 +329,7 @@ onClick: async () => { const app = createAppFromFlow(flow.path, flow.schema) $importStore = JSON.parse(JSON.stringify(app)) - await goto('/apps/add?nodraft=true') + await goto('/apps/add') }, unifiedSize: 'md', variant: 'subtle', @@ -341,7 +341,7 @@ buttons.push({ label: 'Edit', buttonProps: { - href: `${base}/flows/edit/${path}?nodraft=true`, + href: `${base}/flows/edit/${path}`, variant: 'accent', unifiedSize: 'md', disabled: !can_write || !showEditButtons, diff --git a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte index bb65a27065..6e59e7be5e 100644 --- a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte @@ -1,4 +1,5 @@ - - - - -{#if scriptHandle.draft} - { - // "Deploy & Stay here" / lib: stay on the editor (just confirm). - if (e.stay) { - sendUserToast('Deployed') - return - } - goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`) - }} - onSaveInitial={(e) => { - goto(`/scripts/edit/${e.path}`) - }} - onNavigate={(item) => goto(editPathFor(item))} - searchParams={page.url.searchParams} - bind:script={scriptHandle.draft} - {showMeta} - > - - -{:else} - -{/if} + diff --git a/frontend/src/routes/(root)/(logged)/scripts/add/+page.ts b/frontend/src/routes/(root)/(logged)/scripts/add/+page.ts new file mode 100644 index 0000000000..05237ef0db --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/scripts/add/+page.ts @@ -0,0 +1,6 @@ +import { makeDraftAddLoad } from '$lib/draftAddRedirect' +import type { PageLoad } from './$types' + +export const prerender = false + +export const load: PageLoad = makeDraftAddLoad('scripts/edit') diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index b76c03a804..f04ef93188 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -1,95 +1,40 @@ - - + loadScript()} + getLocalDraft={() => draftSync.draft} + bind:othersModalOpen + {draftSavedAt} + {deployedAt} + onLoadLatestDeploy={async () => { + // stopSync-bracketed; see restoreDeployed for the race. + if (!$workspaceStore) return + await runResetToDeployed({ + workspace: $workspaceStore, + itemKind: 'script', + path: draftPath, + onResetToDeployed: async () => { + draftSync.draft = undefined + await loadScript({ getDraft: false }) + } + }) + }} /> - - -{#if scriptHandle.draft && renderEditor} +{#if draftSync.draft && renderEditor} (othersModalOpen = true)} + onResetToDeployed={async () => { + draftSync.draft = undefined + await loadScript({ getDraft: false }) + }} onDeploy={(e) => { // "Deploy & Stay here" / lib: stay on the editor (just confirm). if (e.stay) { sendUserToast('Deployed') return } - UserDraft.remove('script', draftPath) + // stopSync-bracketed immediate delete; see restoreDeployed for the race. + if ($workspaceStore) { + discardDraftAfterDeploy({ + workspace: $workspaceStore, + itemKind: 'script', + path: draftPath + }) + } if ($workspaceStore) invalidate($workspaceStore, 'script') goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`) }} - onSaveInitial={(e) => { - goto(`/scripts/edit/${e.path}`) - }} onSeeDetails={(e) => { goto(`/scripts/get/${e.path}?workspace=${$workspaceStore}`) }} onNavigate={(item) => goto(editPathFor(item))} - > - - + /> {/if} diff --git a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte index df37a946f9..24bf997c50 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte @@ -422,7 +422,7 @@ onClick: async () => { const app = createAppFromScript(script.path, script.schema) $importStore = JSON.parse(JSON.stringify(app)) - await goto('/apps/add?nodraft=true') + await goto('/apps/add') }, disabled: !showEditButtons, unifiedSize: 'md', diff --git a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte index b70730bd8c..586d2077d2 100644 --- a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte @@ -1,4 +1,5 @@ -{#if !globalEnabled} - +{#if !globalEnabled || !$copilotInfo.enabled} + {:else if isCollapsed}
diff --git a/frontend/src/lib/components/settings/AIUserSettings.svelte b/frontend/src/lib/components/settings/AIUserSettings.svelte index c512ba6f78..de83b15158 100644 --- a/frontend/src/lib/components/settings/AIUserSettings.svelte +++ b/frontend/src/lib/components/settings/AIUserSettings.svelte @@ -1,5 +1,7 @@
@@ -33,35 +42,52 @@
{ - updateSetting(codeCompletionSessionEnabled, e.detail, 'codeCompletionSessionEnabled') + updateAiEnabled(e.detail) }} - checked={$codeCompletionSessionEnabled} + checked={!$aiUserDisabled} options={{ - right: 'Code completion', - rightTooltip: 'AI completion in the code editors' + right: 'Windmill AI', + rightTooltip: + 'Enable Windmill AI for your account on this device. Turning this off hides the AI chat, code completion, metadata completion and flow step input completion.' }} /> - { - updateSetting(metadataCompletionEnabled, e.detail, 'metadataCompletionEnabled') - }} - checked={$metadataCompletionEnabled} - options={{ - right: 'Metadata completion', - rightTooltip: 'AI completion for summaries and descriptions' - }} - /> - { - updateSetting(stepInputCompletionEnabled, e.detail, 'stepInputCompletionEnabled') - }} - checked={$stepInputCompletionEnabled} - options={{ - right: 'Flow step input completion', - rightTooltip: 'AI completion for flow step inputs' - }} - /> +
+ { + updateSetting(codeCompletionSessionEnabled, e.detail, 'codeCompletionSessionEnabled') + }} + checked={$codeCompletionSessionEnabled} + options={{ + right: 'Code completion', + rightTooltip: 'AI completion in the code editors' + }} + /> + + { + updateSetting(metadataCompletionEnabled, e.detail, 'metadataCompletionEnabled') + }} + checked={$metadataCompletionEnabled} + options={{ + right: 'Metadata completion', + rightTooltip: 'AI completion for summaries and descriptions' + }} + /> + { + updateSetting(stepInputCompletionEnabled, e.detail, 'stepInputCompletionEnabled') + }} + checked={$stepInputCompletionEnabled} + options={{ + right: 'Flow step input completion', + rightTooltip: 'AI completion for flow step inputs' + }} + /> +
diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index ce7b25ace6..92c8f18946 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -130,6 +130,13 @@ export const codeCompletionSessionEnabled = writable( getLocalSetting(CODE_COMPLETION_SETTING_NAME) != 'false' ) +export const AI_USER_DISABLED_SETTING_NAME = 'aiUserDisabled' +// Master per-user (per-device) opt-out for all Windmill AI features. Initialized at +// module load so it applies on startup, not only once the settings panel mounts. +export const aiUserDisabled = writable( + getLocalSetting(AI_USER_DISABLED_SETTING_NAME) === 'true' +) + export const usedTriggerKinds = writable([]) export let globalDbManagerDrawer: StateStore = { val: undefined } From 5ccaae8ab36f2be18b67863ea069763455908029 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 15 Jun 2026 20:04:43 +0200 Subject: [PATCH 039/246] fix: resolve release CI failures (pypi bundle, flow serde test, cli windows) (#9595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent failures on the latest release commit: - pypi (Publish python-client): the `UserDraftOverlay`/`UserDraftItemKind` schema definitions were placed inside the `# -- INLINE START/END --` markers in openapi.yaml. The python-client build replaces that whole block with a wildcard import of `openflow.openapi.yaml`'s schemas, which do not define these two, so every `$ref` to them became unresolvable and the redocly bundle aborted. Move both definitions outside the markers — they are windmill-api schemas, not openflow-mirrored ones. - flows::tests::flowmodule_serde: the expected JSON still carried `"error_message": null` in three `stop_after_if` blocks, but StopAfterIf.error_message is now skipped when None. Drop those keys. - CLI Tests (test-windows): preservePendingScriptLocks mixed the OS path separator (SEP) into map keys that are always forward-slash normalized, so on Windows the multi-module suffix match and the lock-file lookup both failed. Use forward slashes consistently; this also fixes real Windows git-sync deploys, not just the test. Co-authored-by: Claude Opus 4.8 (1M context) --- backend/windmill-api-flows/src/flows.rs | 9 +++------ backend/windmill-api/openapi.yaml | 4 ++-- cli/src/commands/sync/sync.ts | 9 +++++---- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 652d22bed0..b5f8e2f285 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -2109,8 +2109,7 @@ mod tests { }, "stop_after_if": { "expr": "foo = 'bar'", - "skip_if_stopped": false, - "error_message": null + "skip_if_stopped": false } }, { @@ -2131,8 +2130,7 @@ mod tests { }, "stop_after_if": { "expr": "previous.isEmpty()", - "skip_if_stopped": false, - "error_message": null + "skip_if_stopped": false } } ], @@ -2145,8 +2143,7 @@ mod tests { }, "stop_after_if": { "expr": "previous.isEmpty()", - "skip_if_stopped": false, - "error_message": null + "skip_if_stopped": false } }, }); diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 0ef88e96fe..4ba9f4e734 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -21167,8 +21167,6 @@ components: # NOTE: Not so many generators and validators support this format: # $ref: "../../openflow.openapi.yaml#/components/schemas" # This is why it is better to inline each of schemas for better compat - # Do not change next line. It is used by python-client for pre-processing - # -- INLINE START -- UserDraftOverlay: type: object description: | @@ -21244,6 +21242,8 @@ components: - trigger_nextcloud - trigger_google - trigger_github + # Do not change next line. It is used by python-client for pre-processing + # -- INLINE START -- OpenFlow: $ref: "../../openflow.openapi.yaml#/components/schemas/OpenFlow" FlowValue: diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 594fbeb43c..e0e4e73659 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -2099,7 +2099,8 @@ export function preservePendingScriptLocks( ): void { // A multi-module script keeps its metadata in the folder layout // `…__mod/script.{yaml,json}` instead of `….script.{yaml,json}`. - const modMeta = getModuleFolderSuffix() + SEP + "script"; + // Map keys are always forward-slash normalized, on every platform. + const modMeta = getModuleFolderSuffix() + "/script"; for (const metaKey of Object.keys(remote)) { const isYaml = metaKey.endsWith(".script.yaml") || metaKey.endsWith(modMeta + ".yaml"); @@ -2134,9 +2135,9 @@ export function preservePendingScriptLocks( // Derive the lock-file key from the `!inline` reference itself, not from the // metadata path: a multi-module script keeps its lock at `…__mod/script.lock`, - // which a `.script.yaml -> .script.lock` rewrite would miss. The reference is - // always forward-slash; map keys use the OS separator. - const lockKey = localLock.slice("!inline ".length).replaceAll("/", SEP); + // which a `.script.yaml -> .script.lock` rewrite would miss. The reference and + // the map keys are both forward-slash, so no separator rewrite is needed. + const lockKey = localLock.slice("!inline ".length); if (local[lockKey] === undefined) continue; // committed lock already gone remoteParsed["lock"] = localLock; From 6a6295921d681359155d814507908792be405679 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 15 Jun 2026 20:05:00 +0200 Subject: [PATCH 040/246] fix(embeddings): retry HuggingFace model downloads with backoff (#9597) Caching the gte-small embedding model fetched config.json / tokenizer.json / model.safetensors from HuggingFace with no retry, so a single transient network error ("error sending request for url ...") failed the whole image build. Wrap each download in a retry loop (up to 5 attempts, exponential backoff capped at 8s) that logs each retry and surfaces the error only after the final attempt. No new dependency. Co-authored-by: Claude Opus 4.8 (1M context) --- backend/windmill-api-embeddings/src/lib.rs | 59 ++++++++++++++++------ 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/backend/windmill-api-embeddings/src/lib.rs b/backend/windmill-api-embeddings/src/lib.rs index f0c87633d2..016e72be91 100644 --- a/backend/windmill-api-embeddings/src/lib.rs +++ b/backend/windmill-api-embeddings/src/lib.rs @@ -3,11 +3,11 @@ use anyhow::{anyhow, Error, Result}; #[cfg(feature = "embedding")] use std::{collections::HashMap, path::PathBuf, sync::Arc}; #[cfg(feature = "embedding")] +use windmill_common::utils::HTTP_CLIENT_PERMISSIVE as HTTP_CLIENT; +#[cfg(feature = "embedding")] use windmill_common::DEFAULT_HUB_BASE_URL; #[cfg(feature = "embedding")] use windmill_common::HUB_BASE_URL; -#[cfg(feature = "embedding")] -use windmill_common::utils::HTTP_CLIENT_PERMISSIVE as HTTP_CLIENT; use axum::Router; @@ -159,23 +159,52 @@ pub struct ModelInstance { #[cfg(feature = "embedding")] impl ModelInstance { + async fn get_hf_file( + repo_api: &hf_hub::api::tokio::ApiRepo, + filename: &str, + ) -> Result { + // HuggingFace downloads have no built-in retry, so a single transient + // network blip would fail the whole image build. Retry with exponential + // backoff (1s, 2s, 4s, 8s, capped at 8s) up to MAX_ATTEMPTS times. + const MAX_ATTEMPTS: u32 = 5; + let mut attempt: u32 = 0; + loop { + attempt += 1; + match repo_api.get(filename).await { + Ok(path) => return Ok(path), + Err(e) => { + if attempt >= MAX_ATTEMPTS { + return Err(anyhow!( + "Failed to get {} from hugging face after {} attempts: {}", + filename, + attempt, + e + )); + } + let delay_secs = 1u64 << (attempt - 1).min(3); + tracing::warn!( + "Failed to get {} from hugging face (attempt {}/{}): {}. Retrying in {}s...", + filename, + attempt, + MAX_ATTEMPTS, + e, + delay_secs + ); + tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await; + } + } + } + } + pub async fn load_model_files() -> Result<(PathBuf, PathBuf, PathBuf)> { let api = Api::new()?; let repo_api = api.model("thenlper/gte-small".to_string()); - let (config_filename, tokenizer_filename, weights_filename) = - ( - repo_api - .get("config.json") - .await - .map_err(|e| anyhow!("Failed to get config.json from hugging face: {}", e))?, - repo_api.get("tokenizer.json").await.map_err(|e| { - anyhow!("Failed to get tokenizer.json from hugging face: {}", e) - })?, - repo_api.get("model.safetensors").await.map_err(|e| { - anyhow!("Failed to get model.safetensors from hugging face: {}", e) - })?, - ); + let (config_filename, tokenizer_filename, weights_filename) = ( + Self::get_hf_file(&repo_api, "config.json").await?, + Self::get_hf_file(&repo_api, "tokenizer.json").await?, + Self::get_hf_file(&repo_api, "model.safetensors").await?, + ); Ok((config_filename, tokenizer_filename, weights_filename)) } From 9de57086086bb5626d175c7f926915d1d6ac67ca Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 15 Jun 2026 20:05:13 +0200 Subject: [PATCH 041/246] feat(audit): record workspace archive/unarchive/delete in instance audit log (#9596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archiving a workspace sets `deleted = true`, hiding it from the workspace switcher for everyone (the `user_workspaces` query filters `workspace.deleted = false`). The archive/delete actions were audited only inside that same workspace's audit log, which then becomes inaccessible — so there was no durable, discoverable record of who archived or deleted a workspace, or when. Also write these lifecycle events under the instance-level `admins` workspace, the canonical instance-audit scope (a superadmin querying `admins` with `all_workspaces=true` sees entries across all workspaces). The target workspace id is carried in the audit `resource` field and the actor in the author. For delete, the per-workspace rows are removed in the same transaction, so the instance-level entry is the sole durable record. Co-authored-by: Claude Opus 4.8 (1M context) --- .../windmill-api-workspaces/src/workspaces.rs | 24 +++++++++++++++++++ .../src/workspaces_extra.rs | 7 ++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index d809cc1343..265a74d1ed 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -5238,6 +5238,18 @@ async fn archive_workspace( ActionKind::Update, &w_id, Some(&authed.email), + Some(audit_params_refs.clone()), + ) + .await?; + // Also record under the instance-level "admins" workspace so superadmins can + // discover who archived a workspace after it becomes hidden from the UI. + audit_log( + &mut *tx, + &authed, + "workspaces.archive", + ActionKind::Update, + "admins", + Some(&w_id), Some(audit_params_refs), ) .await?; @@ -5299,6 +5311,18 @@ async fn unarchive_workspace( None, ) .await?; + // Also record under the instance-level "admins" workspace so superadmins keep + // a durable trail of who unarchived a workspace. + audit_log( + &mut *tx, + &authed, + "workspaces.unarchive", + ActionKind::Update, + "admins", + Some(&w_id), + None, + ) + .await?; tx.commit().await?; Ok(format!("Unarchived workspace {}", &w_id)) diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 45cac3a37d..4c6b57172f 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -872,13 +872,16 @@ pub(crate) async fn delete_workspace( .execute(&mut *tx) .await?; + // Record under the instance-level "admins" workspace. The per-workspace audit + // rows are deleted along with the workspace, so this instance-level entry is the + // only durable, superadmin-discoverable record of who deleted the workspace. audit_log( &mut *tx, &authed, "workspaces.delete", ActionKind::Delete, - &w_id, - Some(&authed.email), + "admins", + Some(&w_id), None, ) .await?; From abe442bf42bf01b1fb1baee96454e0bd9f9553ef Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 15 Jun 2026 20:22:16 +0200 Subject: [PATCH 042/246] chore(main): release 1.726.0 (#9598) * chore(main): release 1.726.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 14 ++ backend/Cargo.lock | 156 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/core/constants.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 132 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63aede40ac..ad2f3ca1cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.726.0](https://github.com/windmill-labs/windmill/compare/v1.725.1...v1.726.0) (2026-06-15) + + +### Features + +* **audit:** record workspace archive/unarchive/delete in instance audit log ([#9596](https://github.com/windmill-labs/windmill/issues/9596)) ([9de5708](https://github.com/windmill-labs/windmill/commit/9de57086086bb5626d175c7f926915d1d6ac67ca)) +* **frontend:** add user-level toggle to disable Windmill AI ([#9585](https://github.com/windmill-labs/windmill/issues/9585)) ([5709a56](https://github.com/windmill-labs/windmill/commit/5709a564fbafd9aa91943572ecd8c3e0c45c20b1)) + + +### Bug Fixes + +* **embeddings:** retry HuggingFace model downloads with backoff ([#9597](https://github.com/windmill-labs/windmill/issues/9597)) ([6a62959](https://github.com/windmill-labs/windmill/commit/6a6295921d681359155d814507908792be405679)) +* resolve release CI failures (pypi bundle, flow serde test, cli windows) ([#9595](https://github.com/windmill-labs/windmill/issues/9595)) ([5ccaae8](https://github.com/windmill-labs/windmill/commit/5ccaae8ab36f2be18b67863ea069763455908029)) + ## [1.725.1](https://github.com/windmill-labs/windmill/compare/v1.725.0...v1.725.1) (2026-06-15) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 1dba33b555..779dae3934 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -13792,7 +13792,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "async-nats", @@ -13874,7 +13874,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.725.1" +version = "1.726.0" dependencies = [ "async-stream", "async-trait", @@ -13907,7 +13907,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.725.1" +version = "1.726.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13920,7 +13920,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "argon2", @@ -14058,7 +14058,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.725.1" +version = "1.726.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14081,7 +14081,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.725.1" +version = "1.726.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14094,7 +14094,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14120,7 +14120,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.725.1" +version = "1.726.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14130,7 +14130,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.725.1" +version = "1.726.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14147,7 +14147,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.725.1" +version = "1.726.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14169,7 +14169,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14192,7 +14192,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.725.1" +version = "1.726.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14208,7 +14208,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.725.1" +version = "1.726.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14229,7 +14229,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.725.1" +version = "1.726.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14250,7 +14250,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.725.1" +version = "1.726.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14264,7 +14264,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "async-nats", @@ -14299,7 +14299,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14324,7 +14324,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.725.1" +version = "1.726.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14342,7 +14342,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14364,7 +14364,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.725.1" +version = "1.726.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14384,7 +14384,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.725.1" +version = "1.726.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14415,7 +14415,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14443,7 +14443,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.725.1" +version = "1.726.0" dependencies = [ "lazy_static", "serde", @@ -14455,7 +14455,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.725.1" +version = "1.726.0" dependencies = [ "argon2", "axum 0.8.9", @@ -14480,7 +14480,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.725.1" +version = "1.726.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14494,7 +14494,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.725.1" +version = "1.726.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14527,7 +14527,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.725.1" +version = "1.726.0" dependencies = [ "chrono", "lazy_static", @@ -14541,7 +14541,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14560,7 +14560,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.725.1" +version = "1.726.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14662,7 +14662,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.725.1" +version = "1.726.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14681,7 +14681,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.725.1" +version = "1.726.0" dependencies = [ "regex", "serde", @@ -14696,7 +14696,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14720,7 +14720,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "futures", @@ -14737,7 +14737,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.725.1" +version = "1.726.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14753,7 +14753,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "async-trait", @@ -14774,7 +14774,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "async-trait", @@ -14805,7 +14805,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "arc-swap", @@ -14830,7 +14830,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "async-stream", @@ -14864,7 +14864,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "futures", @@ -14882,7 +14882,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.725.1" +version = "1.726.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14891,7 +14891,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "lazy_static", @@ -14903,7 +14903,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "serde_json", @@ -14915,7 +14915,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "gosyn", @@ -14927,7 +14927,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "lazy_static", @@ -14939,7 +14939,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "serde_json", @@ -14951,7 +14951,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "nu-parser", @@ -14962,7 +14962,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14973,7 +14973,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14985,7 +14985,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "rustpython-ast", @@ -14996,7 +14996,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "async-recursion", @@ -15018,7 +15018,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "serde_json", @@ -15030,7 +15030,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "lazy_static", @@ -15044,7 +15044,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15061,7 +15061,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "lazy_static", @@ -15074,7 +15074,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "serde", @@ -15086,7 +15086,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "lazy_static", @@ -15104,7 +15104,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15120,7 +15120,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15136,7 +15136,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "serde", @@ -15147,7 +15147,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "async-recursion", @@ -15185,7 +15185,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "const_format", @@ -15223,7 +15223,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.725.1" +version = "1.726.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15234,7 +15234,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "async-recursion", @@ -15266,7 +15266,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "async-trait", @@ -15290,7 +15290,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "async-trait", @@ -15323,7 +15323,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "async-trait", @@ -15356,7 +15356,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "async-trait", @@ -15376,7 +15376,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "async-trait", @@ -15410,7 +15410,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "async-trait", @@ -15446,7 +15446,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "async-trait", @@ -15469,7 +15469,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "async-trait", @@ -15493,7 +15493,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "async-nats", @@ -15517,7 +15517,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "async-trait", @@ -15552,7 +15552,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "async-trait", @@ -15580,7 +15580,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "async-trait", @@ -15605,7 +15605,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -15624,7 +15624,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "async-once-cell", @@ -15734,7 +15734,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.725.1" +version = "1.726.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index b5f5347644..0bb4c0846c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.725.1" +version = "1.726.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.725.1" +version = "1.726.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 13aca9634c..90f744f5fd 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.725.1" +version = "1.726.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.725.1" +version = "1.726.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.725.1" +version = "1.726.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.725.1" +version = "1.726.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index e0fb32b366..9906338b0c 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.725.1" +version = "1.726.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 4ba9f4e734..153c5aab8c 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.725.1 + version: 1.726.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 9ef4ca48f5..f71a55627e 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.725.1"; +export const VERSION = "v1.726.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index b1d102a070..e0788e0102 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.725.1"; +export const VERSION = "1.726.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b69cff8a8c..9039966560 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.725.1", + "version": "1.726.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.725.1", + "version": "1.726.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index a7d418cdaf..67de2205e2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.725.1", + "version": "1.726.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/lsp/Pipfile b/lsp/Pipfile index e11a9e5a5f..d92f510721 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.725.1" +wmill = ">=1.726.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index d78477b766..08f05393b0 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.725.1 + version: 1.726.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 737bd9aeff..340453e27e 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.725.1' + ModuleVersion = '1.726.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 9185ae5e73..8dcc2c69bc 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.725.1" +version = "1.726.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 7a57a555ff..521de11688 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.725.1", + "version": "1.726.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index a11aa8a2cb..25eccc1446 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.725.1", + "version": "1.726.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index 5f4b63a4a9..83ebf79365 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.725.1 +1.726.0 From 4e4b2247ef471dada1b8c894974fe921d14b3947 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:46:53 +0200 Subject: [PATCH 043/246] =?UTF-8?q?fix:=20db-backed=20draft=20fixes=20?= =?UTF-8?q?=E2=80=94=20review-page=20UX,=20legacy=20drafts,=20session=20re?= =?UTF-8?q?store=20(#9600)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(frontend): session-pane draft seeding + restore actions Seed per-tab last_sync from the server draft's draft_saved_at in the loadFlow/loadScript "no local draft" branches (mirroring loadRawApp) so the seeding save attaches a matching last_sync and the server no longer clobbers an existing server draft with a fresh created_at. Replace the no-op loadFlow/loadRawApp-based diff-drawer restore handlers with proper restoreDeployed/restoreDraft that reset the live UserDraft cell (the inbound sync then updates the preview) and delete the per-user server draft, mirroring ScriptEditorView. Add rawAppValueToDraft to project a deployed raw-app value into the draft shape. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(api): move UserDraftOverlay/UserDraftItemKind out of openflow inline block These two schemas were defined between the python-client's "# -- INLINE START/END --" markers, whose contents build.sh replaces with the openflow legacy wildcard $ref. That deleted both definitions during bundling while ~19 path responses still referenced them, failing the python-client build. Relocated them after the marker block. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(frontend): explain legacy drafts in the draft badge popover The home-page draft badge lists each draft owner; a workspace-level row from before the per-user drafts migration shows as "Legacy workspace draft". Add an info tooltip next to it explaining that a legacy draft isn't tied to any user (email NULL) so everyone with access to the path sees it. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(compare): show friendly draft path on the review & deploy page list_drafts now surfaces the draft JSON's `draft_path` (when set and different from the storage path) alongside summary, mirroring the home-page list endpoints. CompareDrafts displays it instead of the `u/{user}/draft_{uuid}` storage path, while all fetch/deploy/discard calls keep using the storage path (the draft's server-side key). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(compare): delete the storage-path draft when deploying a renamed draft Deploying a draft from the review page replays the editor's create/update at the draft's friendly path, which deletes the draft server-side only at that path. A never-deployed item parked at `u/{user}/draft_{uuid}` therefore left its storage-path draft behind on deploy and kept listing. Delete the storage-path draft for every kind after a successful deploy, mirroring the editors' discardDraftAfterDeploy. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(compare): badge legacy drafts on the review & deploy page list_drafts now reports `legacy_draft` (true when the listed row is a workspace-level NULL-email draft and no per-user row exists at the path). CompareDrafts shows a "Legacy draft" badge with a hover tooltip explaining these predate the per-user drafts migration and aren't tied to a user. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(compare): allow discarding a legacy draft from the review page Legacy drafts (workspace-level, email NULL) aren't owned by the authed user, so the email-scoped draft delete in update_draft never matched them and the discard was a silent no-op. Add a delete-only `legacy` flag that retargets the DELETE (and the conflict re-read) to the NULL-email row, and route the review page's discard of a legacy draft through it. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(backend): prune orphaned sqlx offline cache entries Re-ran the canonical update_sqlx.sh after rebasing windmill-ee-private onto origin/main and re-running substitute_ee_code.sh. Compiling the full workspace with all features recorded every live query and pruned 55 stale cache entries no longer produced by any query (22 are the removed `draft_only`-on-app lookups dropped by the db-backed user drafts work; the rest pre-existing orphans). Orphan entries don't break offline builds — this is cleanup only. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(drafts): stop migrated draft-only items flooding the home list 20260609165313_remove_draft_only inserted the legacy (email IS NULL) draft stubs without an explicit created_at, so every row defaulted to the migration's now() (transaction_timestamp, constant for the whole transaction) and they all bunched at the migration instant — flooding the top of the newest-first home list. Add a corrective migration that resets those rows' created_at to the epoch so they sort to the bottom (their real per-item timestamps are unrecoverable — the source rows were deleted and the draft value carries no timestamp; editing one bumps created_at to now() and floats it back up). The rows are identified exactly via _sqlx_migrations.installed_on, which sqlx writes in the same transaction as the migration so it is byte-identical to the inserted rows' created_at; rows edited since no longer match and are left alone. Leaving remove_draft_only intact (rather than neutralizing it) keeps its essential schema work running everywhere; this migration runs right after and corrects the timestamps. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(migration): note both timestamps are timestamptz in draft created_at repair Pre-empt a misread: draft.created_at became TIMESTAMPTZ in 20260514233244, so `created_at = installed_on` is an exact instant comparison, not a tz-sensitive timestamp/timestamptz cast. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(compare): resolve friendly draft path per kind + strip email from u/ path list_drafts read the friendly path only from value->>'draft_path', which is empty for scripts — the script editor binds the Path widget to script.path, so the typed path round-trips through the draft JSON's own `path` (flows/apps/raw -apps use draft_path). Read the right field per kind, matching the home-page list endpoints, so renamed never-deployed scripts show their friendly name. Also truncate the user segment at `@` when displaying a `u/{user}/…` path: auto-generated draft slots are `u/{user}/draft_{uuid}`, and in the admins workspace (or email-as-username setups) `{user}` is the full email (`u/admin@windmill.dev/…` → `u/admin/…`). Display only — the path/key used for fetch/deploy/discard is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(raw-app): make diff-drawer "restore to deployed" reset like the autosave indicator The diff drawer's restoreDeployed ran the same runResetToDeployed as the AutosaveIndicator's "Reset to deployed", but its onResetToDeployed callback also did `redraw++`, remounting RawAppEditor mid-reset (inside the stopSync bracket); the fresh mount's draft write resurrected the draft, so the restore appeared to do nothing. Extract a single `reloadDeployed` callback (drop the draft handle + reload without the draft overlay) and use it for the diff drawer, the conflict modal, and the AutosaveIndicator so all three reset the same way. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(compare): don't show auto-generated draft path as the bold title A never-named draft lives at a synthetic `u/{user}/draft_{uuid}` slot. When it had no summary and no friendly draft path, that uuid showed as the row's bold title. Return '' from displayPath for auto-generated paths so they aren't bolded — the row still shows the storage path in its secondary (grey) line. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(diff-drawer): remove obsolete draft-vs-current tab selector The "Latest saved draft <> Current" comparison is obsolete. Remove the whole diff-type tab selector; normal-mode diffs now always show deployed-vs-current, simple-mode shows its single custom diff. Drop the now-unreachable restore-to-draft button and the `restoreDraft` prop (plus the dead handlers in the session editor views). The content/metadata selector is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(compare): make "Reset to deployed" work from the diff drawer Route the raw-app session preview and the low-code app editor diff-drawer restore through the same reset-to-deployed callback the AutosaveIndicator uses. - Raw-app session: add a deployedOnly path to loadRawApp that bypasses the draft (cell + server overlay) and reloads the deployed value; the diff drawer's restore now runs it via runResetToDeployed instead of rebuilding the draft shape in place (which hung and never reset). Also wires the in-session AutosaveIndicator reset. - Low-code app editor: drop the goto in the diff-drawer restoreDeployed that re-ran the page load with the draft overlay on and resurrected the draft; share one reloadDeployed across the diff drawer, AutosaveIndicator and the load-latest-deploy modal. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- ...6794f18d33e5bd6340c0189be7818cda64328.json | 94 ------------- ...e9c06440168806fe605e38808bdcb400d4034.json | 16 --- ...31410d32a8d672cfa4929e9e3763c51daa1bc.json | 89 ------------- ...e5fe144c59156a8f06d8592291684c49b2f37.json | 95 ------------- ...7bc0edfa295d6c2292e509a5536765d120bd8.json | 23 ---- ...d914a6e158d636b854248e4028ed35326f3c6.json | 23 ---- ...913c889b374999ece04173b2e67dc74005f60.json | 23 ---- ...a48f77e2abe4523180c507a9a90570127be6d.json | 60 --------- ...a25bfb67dd641153d1ca23f7d0d2ae73624e.json} | 5 +- ...a1dd8e8e4a09200768a73c844810975741894.json | 23 ---- ...21645ce8b1bbcaccee13fb4f2c0eed28a6096.json | 15 --- ...f3fce18c09693b645f2ac520de6936366f3c8.json | 60 --------- ...9008b382c445476117a2311030734bc6d6d53.json | 14 -- ...40fcb380f2470c8488371b207a42cb7d4cd26.json | 22 --- ...d8e9353bc083724fa5e4530fd715fb237dc0c.json | 57 -------- ...2844c71b95c75bd8f173eac51336ab176ccc0.json | 16 --- ...fb5c2f6fd58e518085caf8e8d189951f7c4d8.json | 28 ---- ...b25915f671e7c52426fc54b2fd533b90596e2.json | 20 --- ...d82c350857166fc27fe9eecc88bcc4b229bc.json} | 7 +- ...b26e3ef9171f478e7ab3bd68eb09663b393c4.json | 35 ----- ...0d965fba9a3422c58e67ed2ed06dd107ae139.json | 15 --- ...7ea87026bb4e56c0ccfb12f2feaf1a6124c86.json | 15 --- ...41a82b26105c1bf7833c6e5d6178e65974067.json | 125 ------------------ ...703b87daa538cf3a72007649bbb074f56131e.json | 35 ----- ...b0128c02bc44461ccc9cd9fb29de567762f63.json | 41 ------ ...687a54bd098cf6e84f57e5755eb84e1552345.json | 23 ---- ...bae28b5ba639731cb4867ab2a8e0acdcc9c32.json | 15 --- ...6e0783acf9bdc1a6e058060bd4a1703f747c3.json | 70 ---------- ...cf4386eb97e4e00c7431ff2860b225d212780.json | 16 --- ...fe955820f7b2761df6b38a6a6615b518188f9.json | 47 ------- ...95fd6118b329a4421c9e8022df90ff7e775c8.json | 22 --- ...cc9ecbb767c69ee294638c1c0957f29fd440f.json | 23 ---- ...74c66da29fdfb3e862e06c10c5deb4a2b5771.json | 12 -- ...675a2a05eaf30237e21359c52f31bb1bddc73.json | 22 --- ...816db2dad8fdf02aecbceac0979ce5a7982c8.json | 24 ---- ...b5b31f0efc6d8ef73f691009c73f833dcee10.json | 89 ------------- ...ed531b05eaa17fccc599306eb1a96a65ee761.json | 34 ----- ...b66512801c20ad685e8ed544fe4b86601aaa8.json | 28 ---- ...8d5ab98fa93e0382a57a698564695db6c40ac.json | 23 ---- ...bb20cb3020f93454978d078082482b86cbebe.json | 27 ---- ...df39178f5111119d50f2975dd1180502c0c52.json | 35 ----- ...aaa872403890eae3e606f379343671c2fff02.json | 27 ---- ...09d02cfabe7e0864af577df6968428ac448ef.json | 95 ------------- ...cccbdfa316e5db051f1d52085a2d5447c81ae.json | 16 --- ...c07c31ca9bdd96f8f92ba19d90e6060721354.json | 15 --- ...0529ca2731a3a00110709024efc80f5b25cc5.json | 29 ---- ...b8d305bd9ad73858e8f7b4269097b5ef4cf73.json | 15 --- ...75cacfffd2d3173dfdddcf37589cba356791e.json | 100 -------------- ...667056f6b3696dea7d73758e53f825bfce13e.json | 16 --- ...3a54b6aa1f458f3ecca8f35432153e54b143d.json | 28 ---- ...dda41f946268be4ffbaacc7fc1865c8974628.json | 27 ---- ...e6697e20385b9a1f222382d7bf9e540b0b9aa.json | 63 --------- ...d504b7b5cb7a39538ab9abeb44f781c711493.json | 88 ------------ ...88b2b5e316536efae31da09217176b59db030.json | 15 --- ...e73344dff5b98a33daaee144ffaccaa8a0bad.json | 22 --- ...02a9bd039d16f7dfb11e22d16ff9090456853.json | 16 --- ...1ff1f6787a852bb27acf49593af963a6aacca.json | 23 ---- ...ix_migrated_draft_only_created_at.down.sql | 4 + ..._fix_migrated_draft_only_created_at.up.sql | 33 +++++ backend/windmill-api/openapi.yaml | 11 +- backend/windmill-api/src/drafts.rs | 41 +++++- .../src/lib/components/CompareDrafts.svelte | 50 ++++++- frontend/src/lib/components/DiffDrawer.svelte | 90 +++---------- frontend/src/lib/components/DraftBadge.svelte | 12 +- .../components/sessions/FlowEditorView.svelte | 43 ++++-- .../sessions/RawAppEditorView.svelte | 22 ++- .../sessions/ScriptEditorView.svelte | 14 +- .../sessions/sessionRuntime.svelte.ts | 108 +++++++++------ frontend/src/lib/utils_draft_deploy.ts | 54 +++++--- frontend/src/lib/workspaceDrafts.svelte.ts | 9 ++ .../(logged)/apps/edit/[...path]/+page.svelte | 28 ++-- .../apps_raw/edit/[...path]/+page.svelte | 29 ++-- 72 files changed, 355 insertions(+), 2254 deletions(-) delete mode 100644 backend/.sqlx/query-0142d9dc9c1b57487dd5709a0376794f18d33e5bd6340c0189be7818cda64328.json delete mode 100644 backend/.sqlx/query-0ca770234f3e38be3fb1c280d82e9c06440168806fe605e38808bdcb400d4034.json delete mode 100644 backend/.sqlx/query-115a9cb44d0a41952c08dc36e0331410d32a8d672cfa4929e9e3763c51daa1bc.json delete mode 100644 backend/.sqlx/query-16b4496c21d0619dab4521dca22e5fe144c59156a8f06d8592291684c49b2f37.json delete mode 100644 backend/.sqlx/query-27b0c827467cc92979f094620957bc0edfa295d6c2292e509a5536765d120bd8.json delete mode 100644 backend/.sqlx/query-285c136fc92ce63417e4c65e657d914a6e158d636b854248e4028ed35326f3c6.json delete mode 100644 backend/.sqlx/query-28f1ecca40c8b81cc59dffb75e2913c889b374999ece04173b2e67dc74005f60.json delete mode 100644 backend/.sqlx/query-2b5fc0500beb2f4c7cf5997f9aea48f77e2abe4523180c507a9a90570127be6d.json rename backend/.sqlx/{query-2cb84c274a3e8f7c6ec91c5d86b885dac4de6171463ac0d1c45909da9f91b3a4.json => query-2bed492ef32edf36e60e8a03268fa25bfb67dd641153d1ca23f7d0d2ae73624e.json} (74%) delete mode 100644 backend/.sqlx/query-311de4a5d2fb3066dc9e49693b9a1dd8e8e4a09200768a73c844810975741894.json delete mode 100644 backend/.sqlx/query-39a72ff9bd2ab9bdf59a73ea32821645ce8b1bbcaccee13fb4f2c0eed28a6096.json delete mode 100644 backend/.sqlx/query-40f0bc9a2555a7c90b3985a190bf3fce18c09693b645f2ac520de6936366f3c8.json delete mode 100644 backend/.sqlx/query-464c51a8ea8c06232d33c45b8e59008b382c445476117a2311030734bc6d6d53.json delete mode 100644 backend/.sqlx/query-48efd8f89df9c1dd8f8a4eb2b2640fcb380f2470c8488371b207a42cb7d4cd26.json delete mode 100644 backend/.sqlx/query-4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c.json delete mode 100644 backend/.sqlx/query-4c333861e736b8138162f0ff3bf2844c71b95c75bd8f173eac51336ab176ccc0.json delete mode 100644 backend/.sqlx/query-5104cf045dc9b7b82d0028af11cfb5c2f6fd58e518085caf8e8d189951f7c4d8.json delete mode 100644 backend/.sqlx/query-54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2.json rename backend/.sqlx/{query-ee783aeeb2eba7446995ca8467ff271cc64e5a3c9901c2e2f806ab3acbb4aa75.json => query-560539adbad0ecfa57fa477c3b82d82c350857166fc27fe9eecc88bcc4b229bc.json} (83%) delete mode 100644 backend/.sqlx/query-56741ef2a510917f6c460968117b26e3ef9171f478e7ab3bd68eb09663b393c4.json delete mode 100644 backend/.sqlx/query-567ded2a717af9370a80c00bdb50d965fba9a3422c58e67ed2ed06dd107ae139.json delete mode 100644 backend/.sqlx/query-65c2ecb52cc777f17ffeb77be597ea87026bb4e56c0ccfb12f2feaf1a6124c86.json delete mode 100644 backend/.sqlx/query-665de8d7956cf034d2d009e6e3241a82b26105c1bf7833c6e5d6178e65974067.json delete mode 100644 backend/.sqlx/query-66a9cb11fcc4757a3b35154840b703b87daa538cf3a72007649bbb074f56131e.json delete mode 100644 backend/.sqlx/query-684e025167e097e60a3fc436ec8b0128c02bc44461ccc9cd9fb29de567762f63.json delete mode 100644 backend/.sqlx/query-71c945f93c0a1b561a85e8462b1687a54bd098cf6e84f57e5755eb84e1552345.json delete mode 100644 backend/.sqlx/query-755a9c2f19d3befe68ebffca43abae28b5ba639731cb4867ab2a8e0acdcc9c32.json delete mode 100644 backend/.sqlx/query-825ca00bd011b220f47da175d1d6e0783acf9bdc1a6e058060bd4a1703f747c3.json delete mode 100644 backend/.sqlx/query-8bba7bec4f5f09a90da3eecaab4cf4386eb97e4e00c7431ff2860b225d212780.json delete mode 100644 backend/.sqlx/query-93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9.json delete mode 100644 backend/.sqlx/query-9517395ac7230ab7c40c03ddd2a95fd6118b329a4421c9e8022df90ff7e775c8.json delete mode 100644 backend/.sqlx/query-97966407e9f1fa80fd227f75686cc9ecbb767c69ee294638c1c0957f29fd440f.json delete mode 100644 backend/.sqlx/query-9a7f4786fc29ed2b561d9eb96c274c66da29fdfb3e862e06c10c5deb4a2b5771.json delete mode 100644 backend/.sqlx/query-9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73.json delete mode 100644 backend/.sqlx/query-a4e759ec0c5ff37fe5c280c2d2f816db2dad8fdf02aecbceac0979ce5a7982c8.json delete mode 100644 backend/.sqlx/query-a5fd115e7be5129d623543bbfa7b5b31f0efc6d8ef73f691009c73f833dcee10.json delete mode 100644 backend/.sqlx/query-ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761.json delete mode 100644 backend/.sqlx/query-b474ae4401b3d4c95add2d3353eb66512801c20ad685e8ed544fe4b86601aaa8.json delete mode 100644 backend/.sqlx/query-b5860f6a7672a368d740dcd367a8d5ab98fa93e0382a57a698564695db6c40ac.json delete mode 100644 backend/.sqlx/query-b6f95b3fd1d0431d96d0409424dbb20cb3020f93454978d078082482b86cbebe.json delete mode 100644 backend/.sqlx/query-bf945c34c18a9fe5f0af8f54042df39178f5111119d50f2975dd1180502c0c52.json delete mode 100644 backend/.sqlx/query-c06796e8647cf278c6e0809562aaaa872403890eae3e606f379343671c2fff02.json delete mode 100644 backend/.sqlx/query-c17c39add3f70218dbae38595a909d02cfabe7e0864af577df6968428ac448ef.json delete mode 100644 backend/.sqlx/query-c269f14ae9ae4e96eff9483eb84cccbdfa316e5db051f1d52085a2d5447c81ae.json delete mode 100644 backend/.sqlx/query-cd6a2559d76e7c6462bd18c8f0bc07c31ca9bdd96f8f92ba19d90e6060721354.json delete mode 100644 backend/.sqlx/query-cf8baf59f9e87058dbf2b2335c00529ca2731a3a00110709024efc80f5b25cc5.json delete mode 100644 backend/.sqlx/query-d8e293c0c8fd7e329b921822ffab8d305bd9ad73858e8f7b4269097b5ef4cf73.json delete mode 100644 backend/.sqlx/query-dafc503a5f3adc5c7db7c11096775cacfffd2d3173dfdddcf37589cba356791e.json delete mode 100644 backend/.sqlx/query-ddda19024473b4e5b1d450bf56c667056f6b3696dea7d73758e53f825bfce13e.json delete mode 100644 backend/.sqlx/query-e4836a1ee97e4723bddc28b94e23a54b6aa1f458f3ecca8f35432153e54b143d.json delete mode 100644 backend/.sqlx/query-ea1637af410b48f3673f64ae783dda41f946268be4ffbaacc7fc1865c8974628.json delete mode 100644 backend/.sqlx/query-ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa.json delete mode 100644 backend/.sqlx/query-f0c9c54740cc1c0c2a6fa4e79d4d504b7b5cb7a39538ab9abeb44f781c711493.json delete mode 100644 backend/.sqlx/query-f0fcc355334f4d715b366e1b3be88b2b5e316536efae31da09217176b59db030.json delete mode 100644 backend/.sqlx/query-f175f0eda0dcdb26c08b743de80e73344dff5b98a33daaee144ffaccaa8a0bad.json delete mode 100644 backend/.sqlx/query-f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853.json delete mode 100644 backend/.sqlx/query-fc9753f501974c4b570d710c7621ff1f6787a852bb27acf49593af963a6aacca.json create mode 100644 backend/migrations/20260615204237_fix_migrated_draft_only_created_at.down.sql create mode 100644 backend/migrations/20260615204237_fix_migrated_draft_only_created_at.up.sql diff --git a/backend/.sqlx/query-0142d9dc9c1b57487dd5709a0376794f18d33e5bd6340c0189be7818cda64328.json b/backend/.sqlx/query-0142d9dc9c1b57487dd5709a0376794f18d33e5bd6340c0189be7818cda64328.json deleted file mode 100644 index 461d1afb14..0000000000 --- a/backend/.sqlx/query-0142d9dc9c1b57487dd5709a0376794f18d33e5bd6340c0189be7818cda64328.json +++ /dev/null @@ -1,94 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled, NULL::text as workspace_id FROM password WHERE email = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "email", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "login_type", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "super_admin", - "type_info": "Bool" - }, - { - "ordinal": 3, - "name": "devops", - "type_info": "Bool" - }, - { - "ordinal": 4, - "name": "verified", - "type_info": "Bool" - }, - { - "ordinal": 5, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "company", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "username", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "operator_only", - "type_info": "Bool" - }, - { - "ordinal": 9, - "name": "first_time_user", - "type_info": "Bool" - }, - { - "ordinal": 10, - "name": "role_source", - "type_info": "Varchar" - }, - { - "ordinal": 11, - "name": "disabled", - "type_info": "Bool" - }, - { - "ordinal": 12, - "name": "workspace_id", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - null, - false, - false, - false, - true, - true, - true, - null, - false, - false, - false, - null - ] - }, - "hash": "0142d9dc9c1b57487dd5709a0376794f18d33e5bd6340c0189be7818cda64328" -} diff --git a/backend/.sqlx/query-0ca770234f3e38be3fb1c280d82e9c06440168806fe605e38808bdcb400d4034.json b/backend/.sqlx/query-0ca770234f3e38be3fb1c280d82e9c06440168806fe605e38808bdcb400d4034.json deleted file mode 100644 index 2dcdba586b..0000000000 --- a/backend/.sqlx/query-0ca770234f3e38be3fb1c280d82e9c06440168806fe605e38808bdcb400d4034.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Text" - ] - }, - "nullable": [] - }, - "hash": "0ca770234f3e38be3fb1c280d82e9c06440168806fe605e38808bdcb400d4034" -} diff --git a/backend/.sqlx/query-115a9cb44d0a41952c08dc36e0331410d32a8d672cfa4929e9e3763c51daa1bc.json b/backend/.sqlx/query-115a9cb44d0a41952c08dc36e0331410d32a8d672cfa4929e9e3763c51daa1bc.json deleted file mode 100644 index d2c85b0e53..0000000000 --- a/backend/.sqlx/query-115a9cb44d0a41952c08dc36e0331410d32a8d672cfa4929e9e3763c51daa1bc.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT $1 OFFSET $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "email", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "login_type", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "verified", - "type_info": "Bool" - }, - { - "ordinal": 3, - "name": "super_admin", - "type_info": "Bool" - }, - { - "ordinal": 4, - "name": "devops", - "type_info": "Bool" - }, - { - "ordinal": 5, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "company", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "username", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "operator_only", - "type_info": "Bool" - }, - { - "ordinal": 9, - "name": "first_time_user", - "type_info": "Bool" - }, - { - "ordinal": 10, - "name": "role_source", - "type_info": "Varchar" - }, - { - "ordinal": 11, - "name": "disabled", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Int8", - "Int8" - ] - }, - "nullable": [ - false, - null, - false, - false, - false, - true, - true, - true, - null, - false, - false, - false - ] - }, - "hash": "115a9cb44d0a41952c08dc36e0331410d32a8d672cfa4929e9e3763c51daa1bc" -} diff --git a/backend/.sqlx/query-16b4496c21d0619dab4521dca22e5fe144c59156a8f06d8592291684c49b2f37.json b/backend/.sqlx/query-16b4496c21d0619dab4521dca22e5fe144c59156a8f06d8592291684c49b2f37.json deleted file mode 100644 index 023eaaa010..0000000000 --- a/backend/.sqlx/query-16b4496c21d0619dab4521dca22e5fe144c59156a8f06d8592291684c49b2f37.json +++ /dev/null @@ -1,95 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT email as \"email!\", login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, NULL::bool as operator_only, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id FROM password\n UNION ALL\n SELECT email as \"email!\", 'service_account'::text as login_type, true as \"verified!\", false as \"super_admin!\", false as \"devops!\", NULL::text as name, NULL::text as company, username, true as operator_only, false as \"first_time_user!\", 'service_account'::text as \"role_source!\", disabled as \"disabled!\", workspace_id\n FROM usr\n WHERE is_service_account IS true\n ORDER BY \"super_admin!\" DESC, \"devops!\" DESC, \"email!\"\n LIMIT $1 OFFSET $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "email!", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "login_type", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "verified!", - "type_info": "Bool" - }, - { - "ordinal": 3, - "name": "super_admin!", - "type_info": "Bool" - }, - { - "ordinal": 4, - "name": "devops!", - "type_info": "Bool" - }, - { - "ordinal": 5, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "company", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "username", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "operator_only", - "type_info": "Bool" - }, - { - "ordinal": 9, - "name": "first_time_user!", - "type_info": "Bool" - }, - { - "ordinal": 10, - "name": "role_source!", - "type_info": "Varchar" - }, - { - "ordinal": 11, - "name": "disabled!", - "type_info": "Bool" - }, - { - "ordinal": 12, - "name": "workspace_id", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Int8", - "Int8" - ] - }, - "nullable": [ - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null - ] - }, - "hash": "16b4496c21d0619dab4521dca22e5fe144c59156a8f06d8592291684c49b2f37" -} diff --git a/backend/.sqlx/query-27b0c827467cc92979f094620957bc0edfa295d6c2292e509a5536765d120bd8.json b/backend/.sqlx/query-27b0c827467cc92979f094620957bc0edfa295d6c2292e509a5536765d120bd8.json deleted file mode 100644 index 6d127f4c83..0000000000 --- a/backend/.sqlx/query-27b0c827467cc92979f094620957bc0edfa295d6c2292e509a5536765d120bd8.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT draft_only FROM app WHERE path = $1 AND workspace_id = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "draft_only", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - true - ] - }, - "hash": "27b0c827467cc92979f094620957bc0edfa295d6c2292e509a5536765d120bd8" -} diff --git a/backend/.sqlx/query-285c136fc92ce63417e4c65e657d914a6e158d636b854248e4028ed35326f3c6.json b/backend/.sqlx/query-285c136fc92ce63417e4c65e657d914a6e158d636b854248e4028ed35326f3c6.json deleted file mode 100644 index 8b353e43e1..0000000000 --- a/backend/.sqlx/query-285c136fc92ce63417e4c65e657d914a6e158d636b854248e4028ed35326f3c6.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n path\n FROM\n flow_version\n WHERE\n id = $1 AND\n workspace_id = $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Int8", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "285c136fc92ce63417e4c65e657d914a6e158d636b854248e4028ed35326f3c6" -} diff --git a/backend/.sqlx/query-28f1ecca40c8b81cc59dffb75e2913c889b374999ece04173b2e67dc74005f60.json b/backend/.sqlx/query-28f1ecca40c8b81cc59dffb75e2913c889b374999ece04173b2e67dc74005f60.json deleted file mode 100644 index a5c5e9427e..0000000000 --- a/backend/.sqlx/query-28f1ecca40c8b81cc59dffb75e2913c889b374999ece04173b2e67dc74005f60.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT draft_only FROM flow WHERE path = $1 AND workspace_id = $2 AND archived = false", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "draft_only", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - true - ] - }, - "hash": "28f1ecca40c8b81cc59dffb75e2913c889b374999ece04173b2e67dc74005f60" -} diff --git a/backend/.sqlx/query-2b5fc0500beb2f4c7cf5997f9aea48f77e2abe4523180c507a9a90570127be6d.json b/backend/.sqlx/query-2b5fc0500beb2f4c7cf5997f9aea48f77e2abe4523180c507a9a90570127be6d.json deleted file mode 100644 index fd205d6750..0000000000 --- a/backend/.sqlx/query-2b5fc0500beb2f4c7cf5997f9aea48f77e2abe4523180c507a9a90570127be6d.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes, workspace_id FROM token WHERE email = $1\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "label", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "token_prefix", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "expiration", - "type_info": "Timestamptz" - }, - { - "ordinal": 3, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "last_used_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "scopes", - "type_info": "TextArray" - }, - { - "ordinal": 6, - "name": "workspace_id", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text", - "Int8", - "Int8" - ] - }, - "nullable": [ - true, - false, - true, - false, - false, - true, - true - ] - }, - "hash": "2b5fc0500beb2f4c7cf5997f9aea48f77e2abe4523180c507a9a90570127be6d" -} diff --git a/backend/.sqlx/query-2cb84c274a3e8f7c6ec91c5d86b885dac4de6171463ac0d1c45909da9f91b3a4.json b/backend/.sqlx/query-2bed492ef32edf36e60e8a03268fa25bfb67dd641153d1ca23f7d0d2ae73624e.json similarity index 74% rename from backend/.sqlx/query-2cb84c274a3e8f7c6ec91c5d86b885dac4de6171463ac0d1c45909da9f91b3a4.json rename to backend/.sqlx/query-2bed492ef32edf36e60e8a03268fa25bfb67dd641153d1ca23f7d0d2ae73624e.json index 19fbdd36f9..7e13b3aa44 100644 --- a/backend/.sqlx/query-2cb84c274a3e8f7c6ec91c5d86b885dac4de6171463ac0d1c45909da9f91b3a4.json +++ b/backend/.sqlx/query-2bed492ef32edf36e60e8a03268fa25bfb67dd641153d1ca23f7d0d2ae73624e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND email = $2\n AND path = $3\n AND typ = $4\n AND ($6::bool = true\n OR $5::timestamptz IS NULL\n OR created_at <= $5::timestamptz)\n RETURNING now() as \"now!\"", + "query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND email IS NOT DISTINCT FROM (CASE WHEN $7::bool THEN NULL::text ELSE $2 END)\n AND path = $3\n AND typ = $4\n AND ($6::bool = true\n OR $5::timestamptz IS NULL\n OR created_at <= $5::timestamptz)\n RETURNING now() as \"now!\"", "describe": { "columns": [ { @@ -48,6 +48,7 @@ } }, "Timestamptz", + "Bool", "Bool" ] }, @@ -55,5 +56,5 @@ null ] }, - "hash": "2cb84c274a3e8f7c6ec91c5d86b885dac4de6171463ac0d1c45909da9f91b3a4" + "hash": "2bed492ef32edf36e60e8a03268fa25bfb67dd641153d1ca23f7d0d2ae73624e" } diff --git a/backend/.sqlx/query-311de4a5d2fb3066dc9e49693b9a1dd8e8e4a09200768a73c844810975741894.json b/backend/.sqlx/query-311de4a5d2fb3066dc9e49693b9a1dd8e8e4a09200768a73c844810975741894.json deleted file mode 100644 index 2b5e787553..0000000000 --- a/backend/.sqlx/query-311de4a5d2fb3066dc9e49693b9a1dd8e8e4a09200768a73c844810975741894.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n path\n FROM\n flow_version\n WHERE\n id = $1 AND\n workspace_id = $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Int8", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "311de4a5d2fb3066dc9e49693b9a1dd8e8e4a09200768a73c844810975741894" -} diff --git a/backend/.sqlx/query-39a72ff9bd2ab9bdf59a73ea32821645ce8b1bbcaccee13fb4f2c0eed28a6096.json b/backend/.sqlx/query-39a72ff9bd2ab9bdf59a73ea32821645ce8b1bbcaccee13fb4f2c0eed28a6096.json deleted file mode 100644 index 60bb866c1c..0000000000 --- a/backend/.sqlx/query-39a72ff9bd2ab9bdf59a73ea32821645ce8b1bbcaccee13fb4f2c0eed28a6096.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "39a72ff9bd2ab9bdf59a73ea32821645ce8b1bbcaccee13fb4f2c0eed28a6096" -} diff --git a/backend/.sqlx/query-40f0bc9a2555a7c90b3985a190bf3fce18c09693b645f2ac520de6936366f3c8.json b/backend/.sqlx/query-40f0bc9a2555a7c90b3985a190bf3fce18c09693b645f2ac520de6936366f3c8.json deleted file mode 100644 index a977f306ab..0000000000 --- a/backend/.sqlx/query-40f0bc9a2555a7c90b3985a190bf3fce18c09693b645f2ac520de6936366f3c8.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes, workspace_id FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL)\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "label", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "token_prefix", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "expiration", - "type_info": "Timestamptz" - }, - { - "ordinal": 3, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 4, - "name": "last_used_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "scopes", - "type_info": "TextArray" - }, - { - "ordinal": 6, - "name": "workspace_id", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text", - "Int8", - "Int8" - ] - }, - "nullable": [ - true, - false, - true, - false, - false, - true, - true - ] - }, - "hash": "40f0bc9a2555a7c90b3985a190bf3fce18c09693b645f2ac520de6936366f3c8" -} diff --git a/backend/.sqlx/query-464c51a8ea8c06232d33c45b8e59008b382c445476117a2311030734bc6d6d53.json b/backend/.sqlx/query-464c51a8ea8c06232d33c45b8e59008b382c445476117a2311030734bc6d6d53.json deleted file mode 100644 index df664b5b8b..0000000000 --- a/backend/.sqlx/query-464c51a8ea8c06232d33c45b8e59008b382c445476117a2311030734bc6d6d53.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE v2_job_runtime SET ping = now() WHERE id = $1 AND ping < now()", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "464c51a8ea8c06232d33c45b8e59008b382c445476117a2311030734bc6d6d53" -} diff --git a/backend/.sqlx/query-48efd8f89df9c1dd8f8a4eb2b2640fcb380f2470c8488371b207a42cb7d4cd26.json b/backend/.sqlx/query-48efd8f89df9c1dd8f8a4eb2b2640fcb380f2470c8488371b207a42cb7d4cd26.json deleted file mode 100644 index 4621e0cc11..0000000000 --- a/backend/.sqlx/query-48efd8f89df9c1dd8f8a4eb2b2640fcb380f2470c8488371b207a42cb7d4cd26.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO variable\n (workspace_id, path, value, is_secret, description, account, is_oauth, expires_at, labels)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Bool", - "Varchar", - "Int4", - "Bool", - "Timestamptz", - "TextArray" - ] - }, - "nullable": [] - }, - "hash": "48efd8f89df9c1dd8f8a4eb2b2640fcb380f2470c8488371b207a42cb7d4cd26" -} diff --git a/backend/.sqlx/query-4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c.json b/backend/.sqlx/query-4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c.json deleted file mode 100644 index e432c394b9..0000000000 --- a/backend/.sqlx/query-4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT u.username as \"username?\"\n FROM draft d\n LEFT JOIN usr u\n ON u.workspace_id = d.workspace_id\n AND u.email = d.email\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ = $3\n AND (d.email IS NULL OR d.email <> $4)\n ORDER BY d.email NULLS LAST", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "username?", - "type_info": "Varchar" - } - ], - "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" - ] - } - } - }, - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "4b8c73961b17e1fd8e3f4d3f424d8e9353bc083724fa5e4530fd715fb237dc0c" -} diff --git a/backend/.sqlx/query-4c333861e736b8138162f0ff3bf2844c71b95c75bd8f173eac51336ab176ccc0.json b/backend/.sqlx/query-4c333861e736b8138162f0ff3bf2844c71b95c75bd8f173eac51336ab176ccc0.json deleted file mode 100644 index f1de82e5e6..0000000000 --- a/backend/.sqlx/query-4c333861e736b8138162f0ff3bf2844c71b95c75bd8f173eac51336ab176ccc0.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE variable SET labels = $1 WHERE path = $2 AND workspace_id = $3", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "TextArray", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "4c333861e736b8138162f0ff3bf2844c71b95c75bd8f173eac51336ab176ccc0" -} diff --git a/backend/.sqlx/query-5104cf045dc9b7b82d0028af11cfb5c2f6fd58e518085caf8e8d189951f7c4d8.json b/backend/.sqlx/query-5104cf045dc9b7b82d0028af11cfb5c2f6fd58e518085caf8e8d189951f7c4d8.json deleted file mode 100644 index 46025de086..0000000000 --- a/backend/.sqlx/query-5104cf045dc9b7b82d0028af11cfb5c2f6fd58e518085caf8e8d189951f7c4d8.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO draft\n (workspace_id, path, value, typ)\n VALUES ($1, $2, $3::text::json, $4)\n ON CONFLICT (workspace_id, path, typ)\n DO UPDATE SET value = EXCLUDED.value, created_at = now()", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Text", - { - "Custom": { - "name": "draft_type", - "kind": { - "Enum": [ - "script", - "flow", - "app" - ] - } - } - } - ] - }, - "nullable": [] - }, - "hash": "5104cf045dc9b7b82d0028af11cfb5c2f6fd58e518085caf8e8d189951f7c4d8" -} diff --git a/backend/.sqlx/query-54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2.json b/backend/.sqlx/query-54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2.json deleted file mode 100644 index de1b71a1e6..0000000000 --- a/backend/.sqlx/query-54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin)\n VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Text", - "Bool" - ] - }, - "nullable": [] - }, - "hash": "54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2" -} diff --git a/backend/.sqlx/query-ee783aeeb2eba7446995ca8467ff271cc64e5a3c9901c2e2f806ab3acbb4aa75.json b/backend/.sqlx/query-560539adbad0ecfa57fa477c3b82d82c350857166fc27fe9eecc88bcc4b229bc.json similarity index 83% rename from backend/.sqlx/query-ee783aeeb2eba7446995ca8467ff271cc64e5a3c9901c2e2f806ab3acbb4aa75.json rename to backend/.sqlx/query-560539adbad0ecfa57fa477c3b82d82c350857166fc27fe9eecc88bcc4b229bc.json index b30207b10e..0191cf8bfd 100644 --- a/backend/.sqlx/query-ee783aeeb2eba7446995ca8467ff271cc64e5a3c9901c2e2f806ab3acbb4aa75.json +++ b/backend/.sqlx/query-560539adbad0ecfa57fa477c3b82d82c350857166fc27fe9eecc88bcc4b229bc.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT created_at FROM draft\n WHERE workspace_id = $1 AND email = $2 AND path = $3 AND typ = $4", + "query": "SELECT created_at FROM draft\n WHERE workspace_id = $1\n AND email IS NOT DISTINCT FROM (CASE WHEN $5::bool THEN NULL::text ELSE $2 END)\n AND path = $3 AND typ = $4", "describe": { "columns": [ { @@ -46,12 +46,13 @@ ] } } - } + }, + "Bool" ] }, "nullable": [ false ] }, - "hash": "ee783aeeb2eba7446995ca8467ff271cc64e5a3c9901c2e2f806ab3acbb4aa75" + "hash": "560539adbad0ecfa57fa477c3b82d82c350857166fc27fe9eecc88bcc4b229bc" } diff --git a/backend/.sqlx/query-56741ef2a510917f6c460968117b26e3ef9171f478e7ab3bd68eb09663b393c4.json b/backend/.sqlx/query-56741ef2a510917f6c460968117b26e3ef9171f478e7ab3bd68eb09663b393c4.json deleted file mode 100644 index 37f6872cc9..0000000000 --- a/backend/.sqlx/query-56741ef2a510917f6c460968117b26e3ef9171f478e7ab3bd68eb09663b393c4.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT path,\n value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND typ = 'script'\n AND email = $2\n AND NOT EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = draft.workspace_id\n AND s.path = draft.path\n )", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "value!: sqlx::types::Json>", - "type_info": "Json" - }, - { - "ordinal": 2, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false, - false, - false - ] - }, - "hash": "56741ef2a510917f6c460968117b26e3ef9171f478e7ab3bd68eb09663b393c4" -} diff --git a/backend/.sqlx/query-567ded2a717af9370a80c00bdb50d965fba9a3422c58e67ed2ed06dd107ae139.json b/backend/.sqlx/query-567ded2a717af9370a80c00bdb50d965fba9a3422c58e67ed2ed06dd107ae139.json deleted file mode 100644 index 4bebcfa038..0000000000 --- a/backend/.sqlx/query-567ded2a717af9370a80c00bdb50d965fba9a3422c58e67ed2ed06dd107ae139.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM script WHERE hash = $1 AND workspace_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Text" - ] - }, - "nullable": [] - }, - "hash": "567ded2a717af9370a80c00bdb50d965fba9a3422c58e67ed2ed06dd107ae139" -} diff --git a/backend/.sqlx/query-65c2ecb52cc777f17ffeb77be597ea87026bb4e56c0ccfb12f2feaf1a6124c86.json b/backend/.sqlx/query-65c2ecb52cc777f17ffeb77be597ea87026bb4e56c0ccfb12f2feaf1a6124c86.json deleted file mode 100644 index 2492cbc015..0000000000 --- a/backend/.sqlx/query-65c2ecb52cc777f17ffeb77be597ea87026bb4e56c0ccfb12f2feaf1a6124c86.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs)\n SELECT $1, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs\n FROM flow WHERE workspace_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "65c2ecb52cc777f17ffeb77be597ea87026bb4e56c0ccfb12f2feaf1a6124c86" -} diff --git a/backend/.sqlx/query-665de8d7956cf034d2d009e6e3241a82b26105c1bf7833c6e5d6178e65974067.json b/backend/.sqlx/query-665de8d7956cf034d2d009e6e3241a82b26105c1bf7833c6e5d6178e65974067.json deleted file mode 100644 index 7ecba622e6..0000000000 --- a/backend/.sqlx/query-665de8d7956cf034d2d009e6e3241a82b26105c1bf7833c6e5d6178e65974067.json +++ /dev/null @@ -1,125 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT resource.workspace_id, resource.path, resource.value, resource.description,\n resource.resource_type, resource.extra_perms, resource.created_by, resource.edited_at,\n resource.labels,\n (now() > account.expires_at) as is_expired, account.refresh_token != '' as is_refreshed,\n account.refresh_error,\n variable.path IS NOT NULL as is_linked,\n variable.is_oauth as \"is_oauth?\",\n variable.account,\n ws_specific.path IS NOT NULL as ws_specific,\n null::bool as draft_only,\n null::bool as is_draft\n FROM resource\n LEFT JOIN variable ON variable.path = resource.path AND variable.workspace_id = $2\n LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $2\n LEFT JOIN ws_specific ON ws_specific.path = resource.path AND ws_specific.workspace_id = $2 AND ws_specific.item_kind = 'resource'\n WHERE resource.path = $1 AND resource.workspace_id = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "workspace_id", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "path", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "value", - "type_info": "Jsonb" - }, - { - "ordinal": 3, - "name": "description", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "resource_type", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "extra_perms", - "type_info": "Jsonb" - }, - { - "ordinal": 6, - "name": "created_by", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "edited_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 8, - "name": "labels", - "type_info": "TextArray" - }, - { - "ordinal": 9, - "name": "is_expired", - "type_info": "Bool" - }, - { - "ordinal": 10, - "name": "is_refreshed", - "type_info": "Bool" - }, - { - "ordinal": 11, - "name": "refresh_error", - "type_info": "Text" - }, - { - "ordinal": 12, - "name": "is_linked", - "type_info": "Bool" - }, - { - "ordinal": 13, - "name": "is_oauth?", - "type_info": "Bool" - }, - { - "ordinal": 14, - "name": "account", - "type_info": "Int4" - }, - { - "ordinal": 15, - "name": "ws_specific", - "type_info": "Bool" - }, - { - "ordinal": 16, - "name": "draft_only", - "type_info": "Bool" - }, - { - "ordinal": 17, - "name": "is_draft", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false, - false, - true, - true, - false, - false, - true, - true, - true, - null, - null, - true, - null, - false, - true, - null, - null, - null - ] - }, - "hash": "665de8d7956cf034d2d009e6e3241a82b26105c1bf7833c6e5d6178e65974067" -} diff --git a/backend/.sqlx/query-66a9cb11fcc4757a3b35154840b703b87daa538cf3a72007649bbb074f56131e.json b/backend/.sqlx/query-66a9cb11fcc4757a3b35154840b703b87daa538cf3a72007649bbb074f56131e.json deleted file mode 100644 index e9919c7da4..0000000000 --- a/backend/.sqlx/query-66a9cb11fcc4757a3b35154840b703b87daa538cf3a72007649bbb074f56131e.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT app.summary, app.policy, app_version.value\n FROM app\n JOIN app_version\n ON app_version.id = app.versions[array_upper(app.versions, 1)]\n WHERE app.workspace_id = $1 AND app.path = $2 AND COALESCE(app.draft_only, false) = false", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "summary", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "policy", - "type_info": "Jsonb" - }, - { - "ordinal": 2, - "name": "value", - "type_info": "Json" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false, - false, - false - ] - }, - "hash": "66a9cb11fcc4757a3b35154840b703b87daa538cf3a72007649bbb074f56131e" -} diff --git a/backend/.sqlx/query-684e025167e097e60a3fc436ec8b0128c02bc44461ccc9cd9fb29de567762f63.json b/backend/.sqlx/query-684e025167e097e60a3fc436ec8b0128c02bc44461ccc9cd9fb29de567762f63.json deleted file mode 100644 index 2b92062ad0..0000000000 --- a/backend/.sqlx/query-684e025167e097e60a3fc436ec8b0128c02bc44461ccc9cd9fb29de567762f63.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT DISTINCT ON (path)\n path,\n value as \"value!: sqlx::types::Json>\",\n created_at,\n typ::text as \"typ!\"\n FROM draft\n WHERE workspace_id = $1\n AND typ IN ('app', 'raw_app')\n AND email = $2\n AND NOT EXISTS (\n SELECT 1 FROM app a\n WHERE a.workspace_id = draft.workspace_id\n AND a.path = draft.path\n )\n ORDER BY path, created_at DESC", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "value!: sqlx::types::Json>", - "type_info": "Json" - }, - { - "ordinal": 2, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 3, - "name": "typ!", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false, - false, - false, - null - ] - }, - "hash": "684e025167e097e60a3fc436ec8b0128c02bc44461ccc9cd9fb29de567762f63" -} diff --git a/backend/.sqlx/query-71c945f93c0a1b561a85e8462b1687a54bd098cf6e84f57e5755eb84e1552345.json b/backend/.sqlx/query-71c945f93c0a1b561a85e8462b1687a54bd098cf6e84f57e5755eb84e1552345.json deleted file mode 100644 index da3b4cc1b9..0000000000 --- a/backend/.sqlx/query-71c945f93c0a1b561a85e8462b1687a54bd098cf6e84f57e5755eb84e1552345.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT draft_only FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "draft_only", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - true - ] - }, - "hash": "71c945f93c0a1b561a85e8462b1687a54bd098cf6e84f57e5755eb84e1552345" -} diff --git a/backend/.sqlx/query-755a9c2f19d3befe68ebffca43abae28b5ba639731cb4867ab2a8e0acdcc9c32.json b/backend/.sqlx/query-755a9c2f19d3befe68ebffca43abae28b5ba639731cb4867ab2a8e0acdcc9c32.json deleted file mode 100644 index 112b8fe253..0000000000 --- a/backend/.sqlx/query-755a9c2f19d3befe68ebffca43abae28b5ba639731cb4867ab2a8e0acdcc9c32.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO flow (\n workspace_id, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, dependency_job, draft_only, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, versions, on_behalf_of_email, lock_error_logs\n )\n SELECT $2, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, NULL, draft_only, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, ARRAY[]::bigint[], on_behalf_of_email, lock_error_logs\n FROM flow\n WHERE workspace_id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "755a9c2f19d3befe68ebffca43abae28b5ba639731cb4867ab2a8e0acdcc9c32" -} diff --git a/backend/.sqlx/query-825ca00bd011b220f47da175d1d6e0783acf9bdc1a6e058060bd4a1703f747c3.json b/backend/.sqlx/query-825ca00bd011b220f47da175d1d6e0783acf9bdc1a6e058060bd4a1703f747c3.json deleted file mode 100644 index 876bc39b48..0000000000 --- a/backend/.sqlx/query-825ca00bd011b220f47da175d1d6e0783acf9bdc1a6e058060bd4a1703f747c3.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id, workspace_id, path, summary, policy, versions, extra_perms, draft_only, custom_path\n FROM app\n WHERE workspace_id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "workspace_id", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "path", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "summary", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "policy", - "type_info": "Jsonb" - }, - { - "ordinal": 5, - "name": "versions", - "type_info": "Int8Array" - }, - { - "ordinal": 6, - "name": "extra_perms", - "type_info": "Jsonb" - }, - { - "ordinal": 7, - "name": "draft_only", - "type_info": "Bool" - }, - { - "ordinal": 8, - "name": "custom_path", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - false, - true, - true - ] - }, - "hash": "825ca00bd011b220f47da175d1d6e0783acf9bdc1a6e058060bd4a1703f747c3" -} diff --git a/backend/.sqlx/query-8bba7bec4f5f09a90da3eecaab4cf4386eb97e4e00c7431ff2860b225d212780.json b/backend/.sqlx/query-8bba7bec4f5f09a90da3eecaab4cf4386eb97e4e00c7431ff2860b225d212780.json deleted file mode 100644 index 00b6e712d5..0000000000 --- a/backend/.sqlx/query-8bba7bec4f5f09a90da3eecaab4cf4386eb97e4e00c7431ff2860b225d212780.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app' AND (email = $3 OR email IS NULL)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "8bba7bec4f5f09a90da3eecaab4cf4386eb97e4e00c7431ff2860b225d212780" -} diff --git a/backend/.sqlx/query-93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9.json b/backend/.sqlx/query-93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9.json deleted file mode 100644 index c9faa982ba..0000000000 --- a/backend/.sqlx/query-93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE token SET last_used_at = now() WHERE\n token_hash = $1\n AND (expiration > NOW() OR expiration IS NULL)\n AND (workspace_id IS NULL OR workspace_id = $2)\n RETURNING owner, email, super_admin, scopes, label", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "owner", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "email", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "super_admin", - "type_info": "Bool" - }, - { - "ordinal": 3, - "name": "scopes", - "type_info": "TextArray" - }, - { - "ordinal": 4, - "name": "label", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - true, - true, - false, - true, - true - ] - }, - "hash": "93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9" -} diff --git a/backend/.sqlx/query-9517395ac7230ab7c40c03ddd2a95fd6118b329a4421c9e8022df90ff7e775c8.json b/backend/.sqlx/query-9517395ac7230ab7c40c03ddd2a95fd6118b329a4421c9e8022df90ff7e775c8.json deleted file mode 100644 index cc551bed42..0000000000 --- a/backend/.sqlx/query-9517395ac7230ab7c40c03ddd2a95fd6118b329a4421c9e8022df90ff7e775c8.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT COUNT(*) FROM script s WHERE s.workspace_id = $1 AND s.hash NOT IN (\n SELECT DISTINCT ON (path) hash FROM script\n WHERE workspace_id = $1 AND deleted = false AND draft_only IS NOT TRUE\n ORDER BY path, created_at DESC\n )", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "9517395ac7230ab7c40c03ddd2a95fd6118b329a4421c9e8022df90ff7e775c8" -} diff --git a/backend/.sqlx/query-97966407e9f1fa80fd227f75686cc9ecbb767c69ee294638c1c0957f29fd440f.json b/backend/.sqlx/query-97966407e9f1fa80fd227f75686cc9ecbb767c69ee294638c1c0957f29fd440f.json deleted file mode 100644 index f14f3e5c61..0000000000 --- a/backend/.sqlx/query-97966407e9f1fa80fd227f75686cc9ecbb767c69ee294638c1c0957f29fd440f.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT draft_only FROM script WHERE path = $1 AND workspace_id = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "draft_only", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - true - ] - }, - "hash": "97966407e9f1fa80fd227f75686cc9ecbb767c69ee294638c1c0957f29fd440f" -} diff --git a/backend/.sqlx/query-9a7f4786fc29ed2b561d9eb96c274c66da29fdfb3e862e06c10c5deb4a2b5771.json b/backend/.sqlx/query-9a7f4786fc29ed2b561d9eb96c274c66da29fdfb3e862e06c10c5deb4a2b5771.json deleted file mode 100644 index ed3cf85466..0000000000 --- a/backend/.sqlx/query-9a7f4786fc29ed2b561d9eb96c274c66da29fdfb3e862e06c10c5deb4a2b5771.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, draft_only)\n VALUES ('test-workspace', 'f/shared/dashboard', 'Dashboard app', '{}', ARRAY[1::bigint], '{}', false)", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "9a7f4786fc29ed2b561d9eb96c274c66da29fdfb3e862e06c10c5deb4a2b5771" -} diff --git a/backend/.sqlx/query-9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73.json b/backend/.sqlx/query-9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73.json deleted file mode 100644 index 163dc2285b..0000000000 --- a/backend/.sqlx/query-9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9\n WHERE $9::varchar IS NULL OR NOT EXISTS(\n SELECT 1 FROM workspace WHERE id = $9 AND deleted = true\n )", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Timestamptz", - "Bool", - "TextArray", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73" -} diff --git a/backend/.sqlx/query-a4e759ec0c5ff37fe5c280c2d2f816db2dad8fdf02aecbceac0979ce5a7982c8.json b/backend/.sqlx/query-a4e759ec0c5ff37fe5c280c2d2f816db2dad8fdf02aecbceac0979ce5a7982c8.json deleted file mode 100644 index 83efc5d35c..0000000000 --- a/backend/.sqlx/query-a4e759ec0c5ff37fe5c280c2d2f816db2dad8fdf02aecbceac0979ce5a7982c8.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH inserted AS (\n INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs)\n SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3\n RETURNING 1\n ) SELECT COUNT(*) FROM inserted", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "a4e759ec0c5ff37fe5c280c2d2f816db2dad8fdf02aecbceac0979ce5a7982c8" -} diff --git a/backend/.sqlx/query-a5fd115e7be5129d623543bbfa7b5b31f0efc6d8ef73f691009c73f833dcee10.json b/backend/.sqlx/query-a5fd115e7be5129d623543bbfa7b5b31f0efc6d8ef73f691009c73f833dcee10.json deleted file mode 100644 index dab14d9f1d..0000000000 --- a/backend/.sqlx/query-a5fd115e7be5129d623543bbfa7b5b31f0efc6d8ef73f691009c73f833dcee10.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source, disabled\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n ORDER BY super_admin DESC, devops DESC\n LIMIT $1 OFFSET $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "email", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "operator_only", - "type_info": "Bool" - }, - { - "ordinal": 2, - "name": "login_type", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "verified", - "type_info": "Bool" - }, - { - "ordinal": 4, - "name": "super_admin", - "type_info": "Bool" - }, - { - "ordinal": 5, - "name": "devops", - "type_info": "Bool" - }, - { - "ordinal": 6, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "company", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "username", - "type_info": "Varchar" - }, - { - "ordinal": 9, - "name": "first_time_user", - "type_info": "Bool" - }, - { - "ordinal": 10, - "name": "role_source", - "type_info": "Varchar" - }, - { - "ordinal": 11, - "name": "disabled", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Int8", - "Int8" - ] - }, - "nullable": [ - false, - null, - null, - false, - false, - false, - true, - true, - true, - false, - false, - false - ] - }, - "hash": "a5fd115e7be5129d623543bbfa7b5b31f0efc6d8ef73f691009c73f833dcee10" -} diff --git a/backend/.sqlx/query-ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761.json b/backend/.sqlx/query-ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761.json deleted file mode 100644 index 4bb689c519..0000000000 --- a/backend/.sqlx/query-ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n (elem->>'installation_id')::bigint as installation_id,\n elem->>'account_id' as account_id,\n elem->>'github_base_url' as github_base_url\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "installation_id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "account_id", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "github_base_url", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - null, - null, - null - ] - }, - "hash": "ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761" -} diff --git a/backend/.sqlx/query-b474ae4401b3d4c95add2d3353eb66512801c20ad685e8ed544fe4b86601aaa8.json b/backend/.sqlx/query-b474ae4401b3d4c95add2d3353eb66512801c20ad685e8ed544fe4b86601aaa8.json deleted file mode 100644 index 1341337cc5..0000000000 --- a/backend/.sqlx/query-b474ae4401b3d4c95add2d3353eb66512801c20ad685e8ed544fe4b86601aaa8.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO draft\n (workspace_id, path, value, typ)\n VALUES ($1, $2, $3::text::json, $4)\n ON CONFLICT (workspace_id, path, typ) DO UPDATE SET value = EXCLUDED.value", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Text", - { - "Custom": { - "name": "draft_type", - "kind": { - "Enum": [ - "script", - "flow", - "app" - ] - } - } - } - ] - }, - "nullable": [] - }, - "hash": "b474ae4401b3d4c95add2d3353eb66512801c20ad685e8ed544fe4b86601aaa8" -} diff --git a/backend/.sqlx/query-b5860f6a7672a368d740dcd367a8d5ab98fa93e0382a57a698564695db6c40ac.json b/backend/.sqlx/query-b5860f6a7672a368d740dcd367a8d5ab98fa93e0382a57a698564695db6c40ac.json deleted file mode 100644 index a08c31e743..0000000000 --- a/backend/.sqlx/query-b5860f6a7672a368d740dcd367a8d5ab98fa93e0382a57a698564695db6c40ac.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT content FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false ORDER BY created_at DESC LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "content", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "b5860f6a7672a368d740dcd367a8d5ab98fa93e0382a57a698564695db6c40ac" -} diff --git a/backend/.sqlx/query-b6f95b3fd1d0431d96d0409424dbb20cb3020f93454978d078082482b86cbebe.json b/backend/.sqlx/query-b6f95b3fd1d0431d96d0409424dbb20cb3020f93454978d078082482b86cbebe.json deleted file mode 100644 index 3c45fe92ba..0000000000 --- a/backend/.sqlx/query-b6f95b3fd1d0431d96d0409424dbb20cb3020f93454978d078082482b86cbebe.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO flow (\n workspace_id, path, summary, description,\n dependency_job, lock_error_logs, draft_only, tag,\n dedicated_worker, visible_to_runner_only, on_behalf_of_email,\n ws_error_handler_muted,\n value, schema, edited_by, edited_at, labels\n ) VALUES (\n $1, $2, $3, $4,\n NULL, '', $5, $6,\n $7, $8, $9,\n $10,\n $11, $12::text::json, $13, now(), $14\n )", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Text", - "Text", - "Bool", - "Varchar", - "Bool", - "Bool", - "Text", - "Bool", - "Jsonb", - "Text", - "Varchar", - "TextArray" - ] - }, - "nullable": [] - }, - "hash": "b6f95b3fd1d0431d96d0409424dbb20cb3020f93454978d078082482b86cbebe" -} diff --git a/backend/.sqlx/query-bf945c34c18a9fe5f0af8f54042df39178f5111119d50f2975dd1180502c0c52.json b/backend/.sqlx/query-bf945c34c18a9fe5f0af8f54042df39178f5111119d50f2975dd1180502c0c52.json deleted file mode 100644 index a4ad3fd76b..0000000000 --- a/backend/.sqlx/query-bf945c34c18a9fe5f0af8f54042df39178f5111119d50f2975dd1180502c0c52.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT path,\n value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND typ = 'flow'\n AND email = $2\n AND NOT EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = draft.workspace_id\n AND f.path = draft.path\n )", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "value!: sqlx::types::Json>", - "type_info": "Json" - }, - { - "ordinal": 2, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false, - false, - false - ] - }, - "hash": "bf945c34c18a9fe5f0af8f54042df39178f5111119d50f2975dd1180502c0c52" -} diff --git a/backend/.sqlx/query-c06796e8647cf278c6e0809562aaaa872403890eae3e606f379343671c2fff02.json b/backend/.sqlx/query-c06796e8647cf278c6e0809562aaaa872403890eae3e606f379343671c2fff02.json deleted file mode 100644 index 1b1a8da18c..0000000000 --- a/backend/.sqlx/query-c06796e8647cf278c6e0809562aaaa872403890eae3e606f379343671c2fff02.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE\n flow\n SET\n path = $1,\n summary = $2,\n description = $3,\n dependency_job = NULL,\n lock_error_logs = '',\n draft_only = NULL,\n tag = $4,\n dedicated_worker = $5,\n visible_to_runner_only = $6,\n on_behalf_of_email = $7,\n ws_error_handler_muted = $8,\n value = $9,\n schema = $10::text::json,\n edited_by = $11,\n edited_at = now(),\n labels = COALESCE($14, labels)\n WHERE\n path = $12 AND workspace_id = $13", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text", - "Text", - "Varchar", - "Bool", - "Bool", - "Text", - "Bool", - "Jsonb", - "Text", - "Varchar", - "Text", - "Text", - "TextArray" - ] - }, - "nullable": [] - }, - "hash": "c06796e8647cf278c6e0809562aaaa872403890eae3e606f379343671c2fff02" -} diff --git a/backend/.sqlx/query-c17c39add3f70218dbae38595a909d02cfabe7e0864af577df6968428ac448ef.json b/backend/.sqlx/query-c17c39add3f70218dbae38595a909d02cfabe7e0864af577df6968428ac448ef.json deleted file mode 100644 index 36be033396..0000000000 --- a/backend/.sqlx/query-c17c39add3f70218dbae38595a909d02cfabe7e0864af577df6968428ac448ef.json +++ /dev/null @@ -1,95 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email as \"email!\", (email NOT IN (SELECT email FROM authors)) as operator_only, login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n UNION ALL\n SELECT email as \"email!\", true as operator_only, 'service_account'::text as login_type, true as \"verified!\", false as \"super_admin!\", false as \"devops!\", NULL::text as name, NULL::text as company, username, false as \"first_time_user!\", 'service_account'::text as \"role_source!\", disabled as \"disabled!\", workspace_id\n FROM usr\n WHERE is_service_account IS true\n ORDER BY \"super_admin!\" DESC, \"devops!\" DESC\n LIMIT $1 OFFSET $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "email!", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "operator_only", - "type_info": "Bool" - }, - { - "ordinal": 2, - "name": "login_type", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "verified!", - "type_info": "Bool" - }, - { - "ordinal": 4, - "name": "super_admin!", - "type_info": "Bool" - }, - { - "ordinal": 5, - "name": "devops!", - "type_info": "Bool" - }, - { - "ordinal": 6, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "company", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "username", - "type_info": "Varchar" - }, - { - "ordinal": 9, - "name": "first_time_user!", - "type_info": "Bool" - }, - { - "ordinal": 10, - "name": "role_source!", - "type_info": "Varchar" - }, - { - "ordinal": 11, - "name": "disabled!", - "type_info": "Bool" - }, - { - "ordinal": 12, - "name": "workspace_id", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Int8", - "Int8" - ] - }, - "nullable": [ - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null - ] - }, - "hash": "c17c39add3f70218dbae38595a909d02cfabe7e0864af577df6968428ac448ef" -} diff --git a/backend/.sqlx/query-c269f14ae9ae4e96eff9483eb84cccbdfa316e5db051f1d52085a2d5447c81ae.json b/backend/.sqlx/query-c269f14ae9ae4e96eff9483eb84cccbdfa316e5db051f1d52085a2d5447c81ae.json deleted file mode 100644 index e566e02ac4..0000000000 --- a/backend/.sqlx/query-c269f14ae9ae4e96eff9483eb84cccbdfa316e5db051f1d52085a2d5447c81ae.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at)\n SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "c269f14ae9ae4e96eff9483eb84cccbdfa316e5db051f1d52085a2d5447c81ae" -} diff --git a/backend/.sqlx/query-cd6a2559d76e7c6462bd18c8f0bc07c31ca9bdd96f8f92ba19d90e6060721354.json b/backend/.sqlx/query-cd6a2559d76e7c6462bd18c8f0bc07c31ca9bdd96f8f92ba19d90e6060721354.json deleted file mode 100644 index f11e9daf54..0000000000 --- a/backend/.sqlx/query-cd6a2559d76e7c6462bd18c8f0bc07c31ca9bdd96f8f92ba19d90e6060721354.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE v2_job_status\n SET flow_status = JSONB_SET(flow_status, ARRAY['modules', flow_status->>'step', 'progress'], $1)\n WHERE id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Jsonb", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "cd6a2559d76e7c6462bd18c8f0bc07c31ca9bdd96f8f92ba19d90e6060721354" -} diff --git a/backend/.sqlx/query-cf8baf59f9e87058dbf2b2335c00529ca2731a3a00110709024efc80f5b25cc5.json b/backend/.sqlx/query-cf8baf59f9e87058dbf2b2335c00529ca2731a3a00110709024efc80f5b25cc5.json deleted file mode 100644 index 425b4d8502..0000000000 --- a/backend/.sqlx/query-cf8baf59f9e87058dbf2b2335c00529ca2731a3a00110709024efc80f5b25cc5.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms, draft_only, custom_path)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Jsonb", - "Int8Array", - "Jsonb", - "Bool", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "cf8baf59f9e87058dbf2b2335c00529ca2731a3a00110709024efc80f5b25cc5" -} diff --git a/backend/.sqlx/query-d8e293c0c8fd7e329b921822ffab8d305bd9ad73858e8f7b4269097b5ef4cf73.json b/backend/.sqlx/query-d8e293c0c8fd7e329b921822ffab8d305bd9ad73858e8f7b4269097b5ef4cf73.json deleted file mode 100644 index 7447facfe7..0000000000 --- a/backend/.sqlx/query-d8e293c0c8fd7e329b921822ffab8d305bd9ad73858e8f7b4269097b5ef4cf73.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM resource WHERE path = ANY($1) AND workspace_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "TextArray", - "Text" - ] - }, - "nullable": [] - }, - "hash": "d8e293c0c8fd7e329b921822ffab8d305bd9ad73858e8f7b4269097b5ef4cf73" -} diff --git a/backend/.sqlx/query-dafc503a5f3adc5c7db7c11096775cacfffd2d3173dfdddcf37589cba356791e.json b/backend/.sqlx/query-dafc503a5f3adc5c7db7c11096775cacfffd2d3173dfdddcf37589cba356791e.json deleted file mode 100644 index e327857d3f..0000000000 --- a/backend/.sqlx/query-dafc503a5f3adc5c7db7c11096775cacfffd2d3173dfdddcf37589cba356791e.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Int8", - "Varchar", - "Int8Array", - "Text", - "Text", - "Text", - "Varchar", - "Text", - "Bool", - "Jsonb", - "Text", - { - "Custom": { - "name": "script_lang", - "kind": { - "Enum": [ - "python3", - "deno", - "go", - "bash", - "postgresql", - "nativets", - "bun", - "mysql", - "bigquery", - "snowflake", - "graphql", - "powershell", - "mssql", - "php", - "bunnative", - "rust", - "ansible", - "csharp", - "oracledb", - "nu", - "java", - "duckdb", - "ruby", - "rlang" - ] - } - } - }, - { - "Custom": { - "name": "script_kind", - "kind": { - "Enum": [ - "script", - "trigger", - "failure", - "command", - "approval", - "preprocessor" - ] - } - } - }, - "Varchar", - "Bool", - "VarcharArray", - "Int4", - "Int4", - "Int4", - "Bool", - "Bool", - "Int2", - "Bool", - "Bool", - "Int4", - "Int4", - "Varchar", - "Bool", - "Varchar", - "Varchar", - "Bool", - "Text", - "Bool", - "Jsonb", - "Varchar", - "Int4", - "Bool", - "Int8", - "Jsonb", - "TextArray" - ] - }, - "nullable": [] - }, - "hash": "dafc503a5f3adc5c7db7c11096775cacfffd2d3173dfdddcf37589cba356791e" -} diff --git a/backend/.sqlx/query-ddda19024473b4e5b1d450bf56c667056f6b3696dea7d73758e53f825bfce13e.json b/backend/.sqlx/query-ddda19024473b4e5b1d450bf56c667056f6b3696dea7d73758e53f825bfce13e.json deleted file mode 100644 index be8864a85d..0000000000 --- a/backend/.sqlx/query-ddda19024473b4e5b1d450bf56c667056f6b3696dea7d73758e53f825bfce13e.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels)\n SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels\n FROM flow\n WHERE path = $2 AND workspace_id = $3", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "ddda19024473b4e5b1d450bf56c667056f6b3696dea7d73758e53f825bfce13e" -} diff --git a/backend/.sqlx/query-e4836a1ee97e4723bddc28b94e23a54b6aa1f458f3ecca8f35432153e54b143d.json b/backend/.sqlx/query-e4836a1ee97e4723bddc28b94e23a54b6aa1f458f3ecca8f35432153e54b143d.json deleted file mode 100644 index b101845b0e..0000000000 --- a/backend/.sqlx/query-e4836a1ee97e4723bddc28b94e23a54b6aa1f458f3ecca8f35432153e54b143d.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO app\n (workspace_id, path, summary, policy, versions, draft_only, custom_path, labels)\n VALUES ($1, $2, $3, $4, '{}', $5, $6, $7) RETURNING id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Jsonb", - "Bool", - "Text", - "TextArray" - ] - }, - "nullable": [ - false - ] - }, - "hash": "e4836a1ee97e4723bddc28b94e23a54b6aa1f458f3ecca8f35432153e54b143d" -} diff --git a/backend/.sqlx/query-ea1637af410b48f3673f64ae783dda41f946268be4ffbaacc7fc1865c8974628.json b/backend/.sqlx/query-ea1637af410b48f3673f64ae783dda41f946268be4ffbaacc7fc1865c8974628.json deleted file mode 100644 index 6dd90f0961..0000000000 --- a/backend/.sqlx/query-ea1637af410b48f3673f64ae783dda41f946268be4ffbaacc7fc1865c8974628.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM draft WHERE path = $1 AND typ = $2 AND workspace_id = $3", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - { - "Custom": { - "name": "draft_type", - "kind": { - "Enum": [ - "script", - "flow", - "app" - ] - } - } - }, - "Text" - ] - }, - "nullable": [] - }, - "hash": "ea1637af410b48f3673f64ae783dda41f946268be4ffbaacc7fc1865c8974628" -} diff --git a/backend/.sqlx/query-ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa.json b/backend/.sqlx/query-ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa.json deleted file mode 100644 index 319c192fae..0000000000 --- a/backend/.sqlx/query-ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT value as \"value!: sqlx::types::Json>\",\n created_at\n FROM draft\n WHERE workspace_id = $1\n AND email = $2\n AND path = $3\n AND typ = $4", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "value!: sqlx::types::Json>", - "type_info": "Json" - }, - { - "ordinal": 1, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text", - "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": [ - false, - false - ] - }, - "hash": "ed47601f88cd92d422555b8a4eee6697e20385b9a1f222382d7bf9e540b0b9aa" -} diff --git a/backend/.sqlx/query-f0c9c54740cc1c0c2a6fa4e79d4d504b7b5cb7a39538ab9abeb44f781c711493.json b/backend/.sqlx/query-f0c9c54740cc1c0c2a6fa4e79d4d504b7b5cb7a39538ab9abeb44f781c711493.json deleted file mode 100644 index c1d113ee27..0000000000 --- a/backend/.sqlx/query-f0c9c54740cc1c0c2a6fa4e79d4d504b7b5cb7a39538ab9abeb44f781c711493.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password WHERE email = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "email", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "login_type", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "super_admin", - "type_info": "Bool" - }, - { - "ordinal": 3, - "name": "devops", - "type_info": "Bool" - }, - { - "ordinal": 4, - "name": "verified", - "type_info": "Bool" - }, - { - "ordinal": 5, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "company", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "username", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "operator_only", - "type_info": "Bool" - }, - { - "ordinal": 9, - "name": "first_time_user", - "type_info": "Bool" - }, - { - "ordinal": 10, - "name": "role_source", - "type_info": "Varchar" - }, - { - "ordinal": 11, - "name": "disabled", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - null, - false, - false, - false, - true, - true, - true, - null, - false, - false, - false - ] - }, - "hash": "f0c9c54740cc1c0c2a6fa4e79d4d504b7b5cb7a39538ab9abeb44f781c711493" -} diff --git a/backend/.sqlx/query-f0fcc355334f4d715b366e1b3be88b2b5e316536efae31da09217176b59db030.json b/backend/.sqlx/query-f0fcc355334f4d715b366e1b3be88b2b5e316536efae31da09217176b59db030.json deleted file mode 100644 index bddbe43ad8..0000000000 --- a/backend/.sqlx/query-f0fcc355334f4d715b366e1b3be88b2b5e316536efae31da09217176b59db030.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO script (\n workspace_id, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag, draft_only,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of_email, assets, modules\n )\n SELECT\n $1, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag, draft_only,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of_email, assets, modules\n FROM script\n WHERE workspace_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "f0fcc355334f4d715b366e1b3be88b2b5e316536efae31da09217176b59db030" -} diff --git a/backend/.sqlx/query-f175f0eda0dcdb26c08b743de80e73344dff5b98a33daaee144ffaccaa8a0bad.json b/backend/.sqlx/query-f175f0eda0dcdb26c08b743de80e73344dff5b98a33daaee144ffaccaa8a0bad.json deleted file mode 100644 index 2393837f07..0000000000 --- a/backend/.sqlx/query-f175f0eda0dcdb26c08b743de80e73344dff5b98a33daaee144ffaccaa8a0bad.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT path AS \"path!\" FROM (\n (SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND archived = false AND deleted = false AND draft_only IS NOT true LIMIT 5000)\n UNION\n (SELECT path FROM flow WHERE workspace_id = $1 AND archived = false AND draft_only IS NOT true LIMIT 5000)\n UNION\n (SELECT path FROM app WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM raw_app WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM variable WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM resource WHERE workspace_id = $1 LIMIT 5000)\n ) t\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path!", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "f175f0eda0dcdb26c08b743de80e73344dff5b98a33daaee144ffaccaa8a0bad" -} diff --git a/backend/.sqlx/query-f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853.json b/backend/.sqlx/query-f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853.json deleted file mode 100644 index 117a8bdc1b..0000000000 --- a/backend/.sqlx/query-f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO usr\n (workspace_id, email, username, is_admin, operator, is_service_account)\n VALUES ($1, $2, $3, false, true, true)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "f8654d5f50a80d862edbf57355502a9bd039d16f7dfb11e22d16ff9090456853" -} diff --git a/backend/.sqlx/query-fc9753f501974c4b570d710c7621ff1f6787a852bb27acf49593af963a6aacca.json b/backend/.sqlx/query-fc9753f501974c4b570d710c7621ff1f6787a852bb27acf49593af963a6aacca.json deleted file mode 100644 index b699cc582a..0000000000 --- a/backend/.sqlx/query-fc9753f501974c4b570d710c7621ff1f6787a852bb27acf49593af963a6aacca.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT path FROM flow_version WHERE id = $1 AND workspace_id = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Int8", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "fc9753f501974c4b570d710c7621ff1f6787a852bb27acf49593af963a6aacca" -} diff --git a/backend/migrations/20260615204237_fix_migrated_draft_only_created_at.down.sql b/backend/migrations/20260615204237_fix_migrated_draft_only_created_at.down.sql new file mode 100644 index 0000000000..3074d34959 --- /dev/null +++ b/backend/migrations/20260615204237_fix_migrated_draft_only_created_at.down.sql @@ -0,0 +1,4 @@ +-- Irreversible: the original `created_at` values were already lost by +-- 20260609165313 (it defaulted them to the migration's now()); this migration +-- only changed them from the migration timestamp to the epoch, so there is +-- nothing to restore. diff --git a/backend/migrations/20260615204237_fix_migrated_draft_only_created_at.up.sql b/backend/migrations/20260615204237_fix_migrated_draft_only_created_at.up.sql new file mode 100644 index 0000000000..2459dae9cf --- /dev/null +++ b/backend/migrations/20260615204237_fix_migrated_draft_only_created_at.up.sql @@ -0,0 +1,33 @@ +-- Repair `created_at` for the draft-only items migrated by +-- 20260609165313_remove_draft_only. That migration inserted the legacy +-- (email IS NULL) draft stubs without an explicit `created_at`, so every row +-- it created defaulted to the migration's `now()` — which is +-- `transaction_timestamp()`, constant for the whole transaction. They all +-- landed at the migration instant and, sorted newest-first, flooded the top +-- of the home list. +-- +-- The original per-item timestamps are unrecoverable (the source script/flow/ +-- app rows were deleted by that migration and the draft value carries no +-- timestamp), so reset them to the epoch: these never-deployed, never-resaved +-- stubs sort to the bottom instead of the top. Editing one later bumps its +-- `created_at` to now() and it floats back up naturally. +-- +-- Identification is exact and safe. sqlx applies a transactional migration and +-- inserts its `_sqlx_migrations` bookkeeping row in ONE transaction, both via +-- `DEFAULT now()`, so that row's `installed_on` is byte-identical to the +-- `created_at` of every row the migration inserted. Matching on it touches +-- only those rows and skips any edited since (their `created_at` was bumped, +-- so it no longer equals `installed_on`). If the values somehow don't match, +-- this updates nothing — it can never clobber a real draft. +-- +-- Both columns are TIMESTAMPTZ (draft.created_at since +-- 20260514233244_convert_draft_created_at_to_timestamptz), so this is an exact +-- instant comparison — no implicit timestamp/timestamptz cast or timezone +-- sensitivity. +UPDATE draft d +SET created_at = 'epoch' +FROM _sqlx_migrations m +WHERE m.version = 20260609165313 + AND d.email IS NULL + AND d.typ IN ('script', 'flow', 'app', 'raw_app') + AND d.created_at = m.installed_on; diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 153c5aab8c..74c0448cdb 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -7915,13 +7915,19 @@ paths: summary: 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 - required: [kind, path, draft_only, created_at] + required: [kind, path, draft_only, legacy_draft, created_at] /w/{workspace}/drafts/get/{kind}/{path}: get: @@ -7990,6 +7996,9 @@ 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 diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index 6ab1a296c5..c6146fb47a 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -35,9 +35,20 @@ pub struct DraftListItem { /// Best-effort, read from the draft JSON's `summary` field when present. #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, + /// User-typed friendly path read from the draft JSON's `draft_path` (set by + /// the editors when it differs from the storage path, e.g. a never-deployed + /// item parked at `u/{user}/draft_{uuid}`). `None` when absent. Lets the + /// review page show the friendly name instead of the storage path, like the + /// home-page list endpoints. + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_path: Option, /// No deployed counterpart exists at this path — the draft is the whole /// item. Kinds without a per-path backing table report `true`. pub draft_only: bool, + /// The listed row is a legacy workspace-level draft (`email IS NULL`), + /// predating the per-user drafts migration. Only `true` when no per-user + /// row exists at this (path, kind) — the DISTINCT ON prefers an owned row. + pub legacy_draft: bool, pub created_at: chrono::DateTime, } @@ -99,6 +110,20 @@ fn list_drafts_query() -> String { d.typ AS kind, d.created_at, d.value ->> 'summary' AS summary, + -- 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`; + -- flows/apps/raw-apps carry a separate `draft_path`. NULLIF + -- drops it when empty or equal to the storage path. + NULLIF( + NULLIF( + CASE WHEN d.typ::text = 'script' + THEN d.value ->> 'path' + ELSE d.value ->> 'draft_path' END, + ''), + d.path + ) AS draft_path, + (d.email IS NULL) AS legacy_draft, {case} AS draft_only FROM draft d WHERE d.workspace_id = $1 AND (d.email = $2 OR d.email IS NULL) @@ -121,6 +146,12 @@ pub struct SaveDraftRequest { /// copy. Use after the client has resolved the conflict locally. #[serde(default)] pub force: bool, + /// Delete-only: target the legacy workspace-level row (`email IS NULL`) + /// rather than the authed user's row. An upsert ignores it (always writes + /// the user's own row). Lets the review page discard a legacy draft, which + /// the email-scoped delete otherwise can't reach. + #[serde(default)] + pub legacy: bool, } #[derive(Serialize, Debug)] @@ -185,11 +216,11 @@ async fn update_draft( } else { // Delete, same conflict rule in the WHERE clause. Returns NULL when // the row was too new (conflict) OR already absent (idempotent) — - // disambiguated below. + // disambiguated below. `legacy` ($7) retargets to the NULL-email row. sqlx::query_scalar!( r#"DELETE FROM draft WHERE workspace_id = $1 - AND email = $2 + AND email IS NOT DISTINCT FROM (CASE WHEN $7::bool THEN NULL::text ELSE $2 END) AND path = $3 AND typ = $4 AND ($6::bool = true @@ -202,6 +233,7 @@ async fn update_draft( kind as UserDraftItemKind, req.last_sync, req.force, + req.legacy, ) .fetch_optional(&db) .await? @@ -219,11 +251,14 @@ async fn update_draft( // by re-reading. let existing = sqlx::query_scalar!( r#"SELECT created_at FROM draft - WHERE workspace_id = $1 AND email = $2 AND path = $3 AND typ = $4"#, + WHERE workspace_id = $1 + AND email IS NOT DISTINCT FROM (CASE WHEN $5::bool THEN NULL::text ELSE $2 END) + AND path = $3 AND typ = $4"#, &w_id, email, path, kind as UserDraftItemKind, + req.legacy, ) .fetch_optional(&db) .await?; diff --git a/frontend/src/lib/components/CompareDrafts.svelte b/frontend/src/lib/components/CompareDrafts.svelte index 264efb73a0..7269e54a3b 100644 --- a/frontend/src/lib/components/CompareDrafts.svelte +++ b/frontend/src/lib/components/CompareDrafts.svelte @@ -3,6 +3,7 @@ import DiffDrawer from './DiffDrawer.svelte' import WorkspaceDeployItemSummary from './WorkspaceDeployItemSummary.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' @@ -59,8 +60,12 @@ kind: LayoutKind draftKind: DraftItem['kind'] path: string + /** Friendly path for display (storage `path` stays the key for all + * fetch/deploy/discard calls — the draft is keyed by it server-side). */ + draft_path?: string summary?: string draft_only: boolean + legacy_draft: boolean raw_app: boolean key: string } @@ -102,7 +107,9 @@ ...d, key: getItemKey(d.kind, d.path), kind: toLayoutKind(d.kind), - draftKind: d.kind + draftKind: d.kind, + draft_path: d.draft_path, + legacy_draft: d.legacy_draft })) ) @@ -300,7 +307,13 @@ const item = discardTarget discardTarget = undefined if (!item) return - const res = await discardDraft(item.draftKind, item.path, currentWorkspaceId, item.draft_only) + const res = await discardDraft( + item.draftKind, + item.path, + currentWorkspaceId, + item.draft_only, + item.legacy_draft + ) if (res.success) { sendUserToast(item.draft_only ? `Deleted ${item.path}` : `Discarded draft of ${item.path}`) // discardDraft invalidated the Draft list; refresh the fork comparison. @@ -340,6 +353,28 @@ ) } + // Auto-generated draft slot: `u/{user}/draft_{uuid}` (uuid dashes → underscores), + // minted for a never-named draft. We don't surface this synthetic id as a row's + // bold title. + const AUTO_GEN_DRAFT_RE = /(^|\/)draft_[0-9a-f]{8}(_[0-9a-f]{4}){3}_[0-9a-f]{12}$/ + + // Bold title for a row: the friendly typed path if the draft carries one, else + // the storage path — with `{user}` truncated at `@` (the admins workspace and + // email-as-username setups put the full email in the namespace). The real + // path/key (used for fetch/deploy/discard) is left untouched. Returns '' for an + // auto-generated `draft_{uuid}` path so it isn't shown in bold (the row still + // shows the storage path in its secondary line). + function displayPath(d: Row): string { + const path = d.draft_path ?? d.path + if (AUTO_GEN_DRAFT_RE.test(path)) return '' + const segs = path.split('/') + if (segs[0] === 'u' && segs.length >= 2) { + const at = segs[1].indexOf('@') + if (at > 0) segs[1] = segs[1].slice(0, at) + } + return segs.join('/') + } + // Human label for the kind badge on each row — without it a variable // draft and a script draft at the same path are indistinguishable. function kindLabel(kind: Row['draftKind']): string { @@ -403,7 +438,7 @@ {@const oldSummary = cache?.deployed ?? draftItem.summary} {@const newSummary = cache?.draft ?? draftItem.summary} 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. + {/snippet} + + {/if} {#if deploymentStatus[draftItem.key]?.status !== 'deployed'} - {:else if diffType === 'deployed'} - - {/if} + {/if} {#if data} {#if contentType} {@const content = - data.mode === 'normal' - ? diffType === 'draft' - ? data.draft?.content - : data.deployed?.content - : data.original?.content} + data.mode === 'normal' ? data.deployed?.content : data.original?.content} {@const metadata = - data.mode === 'normal' - ? diffType === 'draft' - ? data.draft?.metadata - : data.deployed?.metadata - : data.original?.metadata} - {@const lang = - data.mode === 'normal' - ? diffType === 'draft' - ? data.draft?.lang - : data.deployed?.lang - : data.original?.lang} + data.mode === 'normal' ? data.deployed?.metadata : data.original?.metadata} + {@const lang = data.mode === 'normal' ? data.deployed?.lang : data.original?.lang}
{#if data.current.content !== undefined} @@ -260,11 +212,9 @@
{:else} - {#if diffType === 'draft'} - There are no differences between latest saved draft and current - {:else if diffType === 'deployed'} + {#if diffType === 'deployed'} There are no differences between deployed and current - {:else if diffType === 'custom'} + {:else} There are no differences {/if} diff --git a/frontend/src/lib/components/DraftBadge.svelte b/frontend/src/lib/components/DraftBadge.svelte index 99c5af259b..46c24578a4 100644 --- a/frontend/src/lib/components/DraftBadge.svelte +++ b/frontend/src/lib/components/DraftBadge.svelte @@ -7,6 +7,7 @@ * (no deployed row), else "Draft". Renders nothing when there's no draft. */ import Popover from './meltComponents/Popover.svelte' + import Tooltip from './meltComponents/Tooltip.svelte' import { Badge } from './common' import Button from './common/button/Button.svelte' import Modal2 from './common/modal/Modal2.svelte' @@ -219,8 +220,17 @@ > {initials(u)} - + {fullLabel(u)}{isSelf ? ' (you)' : ''} + {#if !u.username} + + {#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. + {/snippet} + + {/if} {#if actionsEnabled && !isSelf} +
+
    + {#each draftMigrationErrors.list as error (error.key)} +
  • +
    +
    {error.path}
    +
    + {error.itemKind} · {error.workspace} +
    +
    + + +
  • + {/each} +
+ {/if} + +
+ +
+
+ + + + {#snippet headerRight()} + + {/snippet} +
+
{JSON.stringify(jsonView?.value ?? {}, null, 2)}
+
+
diff --git a/frontend/src/lib/userDraftDbMigration.ts b/frontend/src/lib/userDraftDbMigration.ts index 533961c859..e6bca477a7 100644 --- a/frontend/src/lib/userDraftDbMigration.ts +++ b/frontend/src/lib/userDraftDbMigration.ts @@ -13,6 +13,10 @@ import { DraftService } from './gen' import type { UserDraftItemKind } from './gen' import { sendUserToast } from './toast' +import { + openDraftMigrationErrorModal, + reportDraftMigrationError +} from './userDraftMigrationErrors.svelte' import { getUsernameForNamespace } from './userNamespace' import { randomUUID } from './utils/uuid' @@ -176,8 +180,11 @@ export async function migrateUserDraftsToDb(): Promise { } if (toMigrate.length === 0) return - // Legacy drafts detected — tell the user the one-off upload is running. - sendUserToast('Migrating local storage drafts ...', 'info') + // Legacy drafts detected — tell the user the one-off upload is running, with + // an escape hatch to the modal where any failures show up as they happen. + sendUserToast('Migrating local storage drafts ...', 'info', [ + { label: 'See more', callback: openDraftMigrationErrorModal } + ]) for (const { key, parsed, path, value, lastWrittenAt } of toMigrate) { try { @@ -203,18 +210,13 @@ export async function migrateUserDraftsToDb(): Promise { // surface it so the user isn't silently stuck, with an escape // hatch to drop the un-migratable draft. console.error('UserDraft LS→DB migration: failed for', key, e) - sendUserToast(`Could not migrate draft ${path} in workspace ${parsed.workspace}`, 'error', [ - { - label: 'Delete draft', - callback: () => { - try { - localStorage.removeItem(key) - } catch { - // ignore - } - } - } - ]) + reportDraftMigrationError({ + key, + workspace: parsed.workspace, + itemKind: parsed.itemKind, + path, + value + }) } } } diff --git a/frontend/src/lib/userDraftMigrationErrors.svelte.ts b/frontend/src/lib/userDraftMigrationErrors.svelte.ts new file mode 100644 index 0000000000..4059c4f990 --- /dev/null +++ b/frontend/src/lib/userDraftMigrationErrors.svelte.ts @@ -0,0 +1,75 @@ +/** + * Reactive registry of drafts that `migrateUserDraftsToDb` could not push to + * the server. The migration runs on every layout mount, so a persistently + * un-migratable draft would re-fail (and re-report) each time — keying by the + * LS key dedupes those repeats. A SINGLE toast fires on the empty→non-empty + * transition (never per-failure, never when there's nothing wrong); its action + * opens `DraftMigrationErrorModal`, which reads `list` live so failures that + * surface while the modal is already open just appear in place. + */ +import { SvelteMap } from 'svelte/reactivity' +import type { UserDraftItemKind } from '$lib/gen' +import { sendUserToast } from './toast' + +export type DraftMigrationError = { + /** The source `userdraft/...` localStorage key — identity and delete target. */ + key: string + workspace: string + itemKind: UserDraftItemKind + path: string + /** The draft payload, surfaced verbatim by the modal's "View JSON". */ + value: unknown +} + +const errors = new SvelteMap() +let modalOpen = $state(false) + +export const draftMigrationErrors = { + get list(): DraftMigrationError[] { + return [...errors.values()] + }, + get modalOpen(): boolean { + return modalOpen + }, + set modalOpen(open: boolean) { + modalOpen = open + } +} + +/** Open the modal listing the failed migrations. */ +export function openDraftMigrationErrorModal(): void { + modalOpen = true +} + +/** + * Record a failed draft migration. Idempotent per `key`; the toast only fires + * on the first failure of a batch (empty→non-empty) and is suppressed when the + * modal is already open, since the user is already resolving issues there. + */ +export function reportDraftMigrationError(error: DraftMigrationError): void { + if (errors.has(error.key)) return + const wasEmpty = errors.size === 0 + errors.set(error.key, error) + if (wasEmpty && !modalOpen) { + sendUserToast('Some local storage drafts could not be migrated', 'error', [ + { label: 'Resolve issues', callback: openDraftMigrationErrorModal } + ]) + } +} + +/** Drop the un-migratable draft from localStorage and clear its error entry. */ +export function deleteDraftMigrationError(key: string): void { + try { + localStorage.removeItem(key) + } catch { + // Best-effort; the entry leaves the list regardless. + } + errors.delete(key) +} + +/** Drop every un-migratable draft at once. */ +export function deleteAllDraftMigrationErrors(): void { + for (const key of [...errors.keys()]) { + deleteDraftMigrationError(key) + } +} diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 86712720d8..fa9bf2122d 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -60,6 +60,7 @@ import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte' import { migrateLegacyUserDrafts } from '$lib/userDraftLegacyMigration' import { migrateUserDraftsToDb } from '$lib/userDraftDbMigration' + import DraftMigrationErrorModal from '$lib/components/DraftMigrationErrorModal.svelte' import { setContext, untrack } from 'svelte' import { base } from '$app/paths' import { Menubar } from '$lib/components/meltComponents' @@ -441,7 +442,7 @@ // on success. The order matters — the second step only sees what // the first one normalized. $effect(() => { - if ($workspaceStore) { + if ($workspaceStore && $userStore) { untrack(() => { migrateLegacyUserDrafts($workspaceStore!) void migrateUserDraftsToDb() @@ -501,6 +502,7 @@ + {#if page.status == 404} {:else if $userStore} From 41562c7d7c708d7d056d9b3d0c39b994a6f4a016 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 16 Jun 2026 14:39:36 +0200 Subject: [PATCH 057/246] fix(nativets): respect custom CA certs in in-process fetch runtime (#9615) * fix(nativets): respect custom CA certs in in-process fetch runtime Co-Authored-By: Claude Opus 4.8 (1M context) * fix(nativets): dedupe CA file paths and clarify DENO_TLS_CA_STORE semantics Co-Authored-By: Claude Opus 4.8 (1M context) * fix(nativets): resolve CA env vars from worker-group config too Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- backend/Cargo.lock | 1 + backend/windmill-runtime-nativets/Cargo.toml | 3 + .../src/cert_tests.rs | 184 ++++++++++++++++++ backend/windmill-runtime-nativets/src/lib.rs | 124 +++++++++++- 4 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 backend/windmill-runtime-nativets/src/cert_tests.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 910e3b24ba..cdd9f6089e 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -15206,6 +15206,7 @@ dependencies = [ "futures", "itertools 0.14.0", "lazy_static", + "rcgen", "regex", "reqwest 0.13.1", "rustls 0.23.35", diff --git a/backend/windmill-runtime-nativets/Cargo.toml b/backend/windmill-runtime-nativets/Cargo.toml index edad615ae5..d3686d2264 100644 --- a/backend/windmill-runtime-nativets/Cargo.toml +++ b/backend/windmill-runtime-nativets/Cargo.toml @@ -48,6 +48,9 @@ futures.workspace = true sqlx.workspace = true rustls.workspace = true +[dev-dependencies] +rcgen = "0.13.2" + [build-dependencies] deno_fetch.workspace = true deno_webidl.workspace = true diff --git a/backend/windmill-runtime-nativets/src/cert_tests.rs b/backend/windmill-runtime-nativets/src/cert_tests.rs new file mode 100644 index 0000000000..677dc1be9b --- /dev/null +++ b/backend/windmill-runtime-nativets/src/cert_tests.rs @@ -0,0 +1,184 @@ +//! Regression tests for custom CA support in the in-process nativets fetch +//! runtime (WIN-2055). +//! +//! `deno_fetch` with `root_cert_store_provider: None` trusts only the Mozilla +//! webpki roots, so scripts calling internal APIs fronted by a corporate CA +//! failed with `invalid peer certificate: UnknownIssuer`. The provider built by +//! `build_native_root_cert_store_provider` merges CAs from `DENO_CERT` / +//! `SSL_CERT_FILE` / `NODE_EXTRA_CA_CERTS` / `DENO_TLS_CA_STORE=system` into the +//! default store. These tests pin that behaviour. + +use crate::{build_native_root_cert_store_provider, load_pem_certs_from_path}; + +/// The CA-related env vars the provider inspects. Cleared around each test so a +/// CI runner that happens to set one of them can't perturb the result. +const CA_ENV_VARS: &[&str] = &[ + "DENO_CERT", + "SSL_CERT_FILE", + "NODE_EXTRA_CA_CERTS", + "DENO_TLS_CA_STORE", +]; + +fn write_test_ca(suffix: &str) -> std::path::PathBuf { + let cert = rcgen::generate_simple_self_signed(vec!["windmill-test-ca".to_string()]) + .expect("generate self-signed cert"); + let pem = cert.cert.pem(); + let path = std::env::temp_dir().join(format!( + "windmill-nativets-ca-{}-{}.pem", + std::process::id(), + suffix + )); + std::fs::write(&path, pem).expect("write cert"); + path +} + +/// Serializes the env-mutating tests against each other — env is process-global, +/// so concurrent `set_var`/`remove_var` would otherwise interleave. +static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn with_cleared_ca_env(f: impl FnOnce() -> T) -> T { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let saved: Vec<(&str, Option)> = CA_ENV_VARS + .iter() + .map(|k| (*k, std::env::var(k).ok())) + .collect(); + for k in CA_ENV_VARS { + std::env::remove_var(k); + } + let out = f(); + for (k, v) in saved { + match v { + Some(v) => std::env::set_var(k, v), + None => std::env::remove_var(k), + } + } + out +} + +#[test] +fn load_pem_certs_parses_self_signed_cert() { + let path = write_test_ca("load"); + let certs = load_pem_certs_from_path(path.to_str().unwrap()).expect("load certs"); + assert_eq!(certs.len(), 1, "expected exactly one cert in the bundle"); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn load_pem_certs_errors_on_missing_file() { + let missing = std::env::temp_dir().join("windmill-nativets-does-not-exist.pem"); + assert!(load_pem_certs_from_path(missing.to_str().unwrap()).is_err()); +} + +#[test] +fn no_ca_env_yields_no_provider() { + // serialize against the env-mutating tests via the shared guard + with_cleared_ca_env(|| { + assert!( + build_native_root_cert_store_provider().is_none(), + "without any CA env var the provider must stay None (default-only behaviour)" + ); + }); +} + +#[test] +fn ssl_cert_file_adds_custom_root() { + let path = write_test_ca("ssl"); + with_cleared_ca_env(|| { + std::env::set_var("SSL_CERT_FILE", &path); + let provider = build_native_root_cert_store_provider() + .expect("a custom CA was configured, provider must be Some"); + let store = provider.get_or_try_init().expect("store init"); + let default_len = deno_tls::create_default_root_cert_store().len(); + assert_eq!( + store.len(), + default_len + 1, + "custom CA should be added on top of the Mozilla defaults" + ); + }); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn node_extra_ca_certs_adds_custom_root() { + let path = write_test_ca("node"); + with_cleared_ca_env(|| { + std::env::set_var("NODE_EXTRA_CA_CERTS", &path); + let provider = build_native_root_cert_store_provider() + .expect("provider must be Some for NODE_EXTRA_CA_CERTS"); + let store = provider.get_or_try_init().expect("store init"); + assert_eq!( + store.len(), + deno_tls::create_default_root_cert_store().len() + 1 + ); + }); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn multiple_ca_env_vars_pointing_at_same_file_dedupe_to_one_root() { + // The tracing proxy points SSL_CERT_FILE, NODE_EXTRA_CA_CERTS and DENO_CERT at + // the same bundle; the path dedupe in build_native_root_cert_store_provider + // loads it once, so the store grows by exactly one (rustls' add does not dedupe). + let path = write_test_ca("dupe"); + with_cleared_ca_env(|| { + std::env::set_var("SSL_CERT_FILE", &path); + std::env::set_var("NODE_EXTRA_CA_CERTS", &path); + std::env::set_var("DENO_CERT", &path); + let provider = build_native_root_cert_store_provider().expect("provider must be Some"); + let store = provider.get_or_try_init().expect("store init"); + assert_eq!( + store.len(), + deno_tls::create_default_root_cert_store().len() + 1 + ); + }); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn worker_config_env_var_adds_custom_root() { + // A CA configured only through the worker-group config (DB `env_vars_static` + // / allowlisted forwarded vars) lands in `WORKER_CONFIG.env_vars`, not the + // worker's own process env. Child Deno/Bun jobs receive it via `.envs(...)`; + // nativets must pick it up from the same place. Regression for the in-process + // path missing that source. + use windmill_common::worker::WORKER_CONFIG; + + let path = write_test_ca("workercfg"); + with_cleared_ca_env(|| { + let prev = WORKER_CONFIG.load_full(); + let mut cfg = (*prev).clone(); + cfg.env_vars.insert( + "SSL_CERT_FILE".to_string(), + path.to_string_lossy().into_owned(), + ); + WORKER_CONFIG.store(std::sync::Arc::new(cfg)); + + let provider = build_native_root_cert_store_provider(); + // restore before asserting so a failure can't leak the mutated global + WORKER_CONFIG.store(prev); + + let provider = provider.expect("worker-config CA must produce a provider"); + let store = provider.get_or_try_init().expect("store init"); + assert_eq!( + store.len(), + deno_tls::create_default_root_cert_store().len() + 1 + ); + }); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn deno_cert_adds_custom_root() { + let path = write_test_ca("deno"); + with_cleared_ca_env(|| { + std::env::set_var("DENO_CERT", &path); + let provider = + build_native_root_cert_store_provider().expect("provider must be Some for DENO_CERT"); + let store = provider.get_or_try_init().expect("store init"); + assert_eq!( + store.len(), + deno_tls::create_default_root_cert_store().len() + 1 + ); + }); + let _ = std::fs::remove_file(&path); +} diff --git a/backend/windmill-runtime-nativets/src/lib.rs b/backend/windmill-runtime-nativets/src/lib.rs index 52e23f11da..2058268256 100644 --- a/backend/windmill-runtime-nativets/src/lib.rs +++ b/backend/windmill-runtime-nativets/src/lib.rs @@ -18,6 +18,9 @@ pub use dedicated::{ExecutingIsolate, PrewarmedIsolate, PrewarmedResult}; #[cfg(test)] mod smoke_tests; +#[cfg(test)] +mod cert_tests; + use std::{ borrow::Cow, cell::RefCell, @@ -35,8 +38,10 @@ use deno_core::{ v8::{self, IsolateHandle}, Extension, JsRuntime, OpState, PollEventLoopOptions, RuntimeOptions, }; +use deno_error::JsErrorBox; use deno_fetch::FetchPermissions; use deno_net::NetPermissions; +use deno_tls::{rustls::pki_types::CertificateDer, rustls::RootCertStore, RootCertStoreProvider}; use deno_web::{BlobStore, TimersPermission}; use itertools::Itertools; use lazy_static::lazy_static; @@ -214,6 +219,123 @@ lazy_static! { Regex::new(r"^(https?)://(([^:@\s]+):([^:@\s]+)@)?([^:@\s]+)(:(\d+))?$").unwrap(); } +lazy_static! { + /// Root cert store for the in-process nativets fetch runtime. + /// + /// Unlike the Deno/Bun executors, nativets never spawns a child process, so + /// the CA env vars those executors forward (`DENO_CERT`, `DENO_TLS_CA_STORE`, + /// `SSL_CERT_FILE`/`NODE_EXTRA_CA_CERTS`) are never consumed by deno's CLI + /// layer. `deno_fetch` with `root_cert_store_provider: None` falls back to + /// the Mozilla webpki roots only, so corporate CAs fail with `UnknownIssuer`. + /// We read those env vars here and merge the certs into the default store. + /// + /// Snapshotted once for the process lifetime, like the Deno executor's + /// `DENO_CERT`/`DENO_TLS_CA_STORE` lazy statics (`deno_executor.rs`): the + /// fetch root store is shared across all (potentially prewarmed) isolates, so + /// per-job CA reconfiguration is out of scope. A later `WORKER_CONFIG` reload + /// is not picked up until the process restarts. + static ref NATIVE_ROOT_CERT_STORE_PROVIDER: Option> = + build_native_root_cert_store_provider(); +} + +struct NativeRootCertStoreProvider { + store: RootCertStore, +} + +impl RootCertStoreProvider for NativeRootCertStoreProvider { + fn get_or_try_init(&self) -> Result<&RootCertStore, JsErrorBox> { + Ok(&self.store) + } +} + +/// Resolve a CA-related env var the same way the child executors see it: the +/// worker's own process env, then the worker-group config (`env_vars_allowlist` +/// forwarded values + DB `env_vars_static` literals, resolved into +/// `WORKER_CONFIG.env_vars`). Child Deno/Bun jobs receive that config map via +/// `.envs(...)`, so nativets must consult it too or a CA set only through worker +/// config would silently not apply in-process. +fn resolve_ca_env_var(name: &str) -> Option { + if let Ok(v) = std::env::var(name) { + if !v.is_empty() { + return Some(v); + } + } + windmill_common::worker::WORKER_CONFIG + .load() + .env_vars + .get(name) + .filter(|v| !v.is_empty()) + .cloned() +} + +/// Build a root cert store seeded with the Mozilla webpki roots plus any custom +/// CAs configured via env. Returns `None` when no custom CA is configured, which +/// preserves the previous default-only behaviour. +fn build_native_root_cert_store_provider() -> Option> { + let mut store = deno_tls::create_default_root_cert_store(); + let mut added = 0usize; + + // File-path env vars, each pointing at a PEM bundle of one or more certs. + // `DENO_CERT` mirrors the Deno CLI; `SSL_CERT_FILE` is the OpenSSL standard + // also honoured by Bun/Node (via NODE_EXTRA_CA_CERTS). Dedupe by path because + // the tracing proxy points several of these at the same bundle, and rustls' + // RootCertStore::add does not dedupe — we'd otherwise trust the same root N times. + let mut seen_paths = std::collections::HashSet::new(); + for var in ["DENO_CERT", "SSL_CERT_FILE", "NODE_EXTRA_CA_CERTS"] { + let Some(path) = resolve_ca_env_var(var).filter(|p| !p.is_empty()) else { + continue; + }; + if !seen_paths.insert(path.clone()) { + continue; + } + match load_pem_certs_from_path(&path) { + Ok(certs) => { + for cert in certs { + if let Err(e) = store.add(cert) { + tracing::warn!("nativets: failed to add cert from {var}={path}: {e}"); + } else { + added += 1; + } + } + } + Err(e) => tracing::warn!("nativets: failed to read CA file {var}={path}: {e}"), + } + } + + // `DENO_TLS_CA_STORE=system` (comma-separated, may also contain `mozilla`) + // pulls in the OS trust store. Unlike the Deno CLI — where the list selects + // and orders the stores — this is purely additive: the Mozilla defaults are + // always seeded above, and `system` augments them. That is a strict superset + // of the public roots, which is what the corporate-CA use case needs. + if resolve_ca_env_var("DENO_TLS_CA_STORE") + .map(|v| v.split(',').any(|s| s.trim() == "system")) + .unwrap_or(false) + { + match deno_tls::deno_native_certs::load_native_certs() { + Ok(certs) => { + for cert in certs { + if store.add(CertificateDer::from(cert.0)).is_ok() { + added += 1; + } + } + } + Err(e) => tracing::warn!("nativets: failed to load system CA store: {e}"), + } + } + + if added == 0 { + return None; + } + tracing::info!("nativets: loaded {added} custom CA cert(s) into fetch root store"); + Some(Arc::new(NativeRootCertStoreProvider { store })) +} + +fn load_pem_certs_from_path(path: &str) -> anyhow::Result>> { + let file = std::fs::File::open(path)?; + let mut reader = std::io::BufReader::new(file); + deno_tls::load_certs(&mut reader).map_err(|e| anyhow::anyhow!(e)) +} + // ── Public interface ───────────────────────────────────────────────── /// Set up the deno_core/V8 runtime. Idempotent — safe to call multiple times. @@ -433,7 +555,7 @@ pub(crate) fn create_nativets_runtime( let ext = Extension { name: "windmill", ops: ops.into(), ..Default::default() }; let fetch_options = deno_fetch::Options { - root_cert_store_provider: None, + root_cert_store_provider: NATIVE_ROOT_CERT_STORE_PROVIDER.clone(), user_agent: ann.useragent.unwrap_or_else(|| "windmill/beta".to_string()), proxy: ann.proxy.map(|x| deno_tls::Proxy::Http { url: x.0, From 611c70acd211cf4b8f8308da4a264c670a2f5f43 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 16 Jun 2026 15:20:19 +0200 Subject: [PATCH 058/246] feat(frontend): adapt AI-chat/sessions drafts to DB-backed model (#9601) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(frontend): adapt AI-chat/sessions drafts to DB-backed model PR #9351 dropped UserDraft's localStorage layer; the chat adapter's synchronous save->read-back threw "Could not read written draft". The adapter now treats the backend as source of truth (in-tab cell used opportunistically for live-preview coherence) with conflict-on-save, and read tools fall back to the backend. Collapses the six writeXDraft functions onto one generic writeDraft + typed per-kind WriteSpec constants. Terminology: "local draft" -> "draft" (drafts are server-side). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(frontend): autosave indicator + draft-only diff guard in session editors Thread an explicit (workspace, path) autosave target to the cloud AutosaveIndicator in the Script/Flow/RawApp session previews so it watches the same key saves land on (it previously watched an empty path and never animated). Disable the Diff button with a hint for draft-only (no_deployed) items consistently across the three editors. Adjust the script topbar compact breakpoint/layout so the cloud icon is part of the bar, and stop splitpanes over-constraining session panes on reload. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): session draft diff viewer for schedule/resource/variable Canonicalize both sides of the draft diff onto one field set and strip runtime-only fields so rows aren't spuriously marked all-changed; mask secret values. Map draft itemKinds to deploy-style kinds so the DiffRow shows the correct icon/label. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): uniform diff-viewer row height regardless of summary Diff-viewer leaf rows (WorkspaceItemRow) drew two lines when an item had a summary and one line otherwise, giving unequal heights. Add an opt-in `uniformHeight` prop that gives the text wrapper a shared min-height and vertically centers the one-line case; enable it only from the diff viewer. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(frontend): address review nits on the drafts diff/guard changes - Reuse the exported TRIGGER_RUNTIME_IGNORE from utils_deployable instead of a verbatim copy, so the runtime-field ignore list has one source of truth. - Drop the now-redundant `(savedApp as any)` cast in RawAppEditorHeader; the prop type already carries `no_deployed`. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): add description parameter to the write_flow chat tool write_flow had no way to set a flow's top-level description (the sibling of summary in OpenFlow); patch_flow_json only edits the compact value, so the field was unreachable from the AI chat. Thread an optional description end-to-end: tool schema -> persisted draft -> read-back -> deploy body. Structural patches (patch_flow_json/set_flow_module_code) pass no description, so a previously-set description is preserved. Adds a deployRequests regression test asserting a draft description reaches the deploy body, overriding the deployed one. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): round-trip top-level fields in session preview draft sync The session preview's two-way draft sync dedups on a per-kind signature and mirrors fields between the editor store and the shared UserDraft cell. Both omitted fields the chat can set, so with the preview open a change to only that field was swallowed (identical signature) and then clobbered by the editor's outbound save: - flow: the signature and applyDraftToStore ignored top-level `description`. - script: the signature keyed on `content` alone, dropping `summary`/`language`. Add the missing fields to flowDraftSig and the script codec signature, and copy `description` in the flow codec's applyDraftToStore (mirroring `summary`). Raw-app already stringifies the whole draft, so it was unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): deploy draft-only flow from the session preview Deploying a draft-only flow (a draft with no deployed row) from the session preview hit two gaps the full-page flow editor already handled: - create vs update: newFlow keyed on `!savedFlow.val`, but a draft-only flow has a synthesized savedFlow (no_deployed=true), so deploy took updateFlow against the draft path and 404'd "Flow not found". Key it on no_deployed too. - friendly name: a brand-new flow is stored under a `draft_` path with its intended name in `draft_path`. Seed the builder's initialPath from `draft_path` (as the full-page editor does) so the Path widget and deploy use the friendly name instead of creating a flow named draft_. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): deploy draft-only raw app from the session preview Same create-vs-update bug as the flow session preview: newApp keyed on `!savedRawApp.val`, but a draft-only app has a truthy synthesized savedApp (getAppByPath with rawApp:true resolves to the draft kind instead of 404ing, carrying no_deployed=true), so deploy took updateApp against a path with no deployed row and 404'd "not found". Key newApp on no_deployed too so a never-deployed app deploys via createApp. More reachable than the flow case: it hit any never-deployed app, including chat-created ones at friendly paths. Keying newApp on no_deployed also exposed that newEditedPath (the breadcrumb path AND the createApp target) used newApp to mean "brand-new, generate a random name". A draft-only app is newApp=true but already has a real path (empty newPath at init, but appPath is set), so it showed and would deploy a random `*_app` name. Prefer the real appPath before the random fallback, so only a genuinely new app (appPath === '') still gets a generated suggestion; the full-page editor is unaffected (it always sets newPath). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): don't re-save a draft after deploying from the session preview Deploying from a session preview reloaded the editor (expected) but then immediately POSTed a fresh draft. The full-page editor guards deploy with discardDraftAfterDeploy (stopSync + arm-restart-on-first-interaction), but the shared editor header skips that in a session pane (inSessionPane) and routes post-deploy cleanup through sessionRuntime.syncPreviewWithDeployed, which did discard + reload without the stopSync guard. UserDraft.discard keeps the cell entry, so the reload's UserDraft.save fired the cell's reactive effect and re-POSTed the just-deployed value as a draft. Wrap the discard + reload in the same UserDraft.stopSync + armRestartOnFirst- Interaction bracket. One place fixes all three kinds (script/flow/raw_app), since they all funnel through syncPreviewWithDeployed; autosave resumes on the next genuine edit. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(frontend): address review findings on the session-preview drafts work - Type `no_deployed` via the GetXByPathResponse/UserDraftOverlay types instead of `(result as any)`/`(saved as any)` casts at the sites this branch added (sessionRuntime, ScriptBuilder, FlowBuilder, + widened the script/flow builder prop types). Pre-existing trigger/variable/resource-editor casts left untouched. - Drop a history-narrating comment parenthetical per the AGENTS.md comment policy (RawAppEditorView). - Add a unit test covering persistGlobalDraft's conflict-on-save / override path (conflict-capable updateDraft mock; inert for existing tests). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): keep the friendly generated path for a brand-new raw app The earlier draft-only newApp fix made newEditedPath prefer `appPath` before the random suggestion, but a brand-new app is parked at the storage placeholder `u/{user}/draft_{uuid}` (the /apps_raw/add redirect target), so it surfaced that uuid instead of a friendly `_app` suggestion. Reject a `draft_` placeholder segment when choosing the path: a real named/draft-only path is still kept, a placeholder falls through to the generated suggestion. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): show the Diff-button tooltip when it's disabled A disabled + +
+ +
{/if} {#if !compactTopbar} {@render previewButtons()} diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index d77d0bf366..063a17f9d1 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -109,6 +109,8 @@ fullyLoaded = true, initialPath = $bindable(''), userDraftPath = '', + autosaveWorkspace = undefined, + autosavePath = undefined, template = $bindable('script'), initialArgs = {}, lockedLanguage = false, @@ -159,9 +161,19 @@ let deployedBy: string | undefined = $state(undefined) // Author let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning - // Top-bar responsive collapse — container width, not viewport. + // Top-bar responsive collapse — container width, not viewport. Collapse the + // right group (hide the ~200px tag select, icon-only Diff/Settings) before the + // full group crowds the path into heavy truncation; ~900 is where the path + // keeps a usable width given the right group's natural ~440px. let topbarWidth = $state(0) - const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 720) + const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 900) + + // AutosaveIndicator watch key. Falls back to the full-page editor's + // global store + URL draft path; the sessions preview overrides both so the + // icon tracks the session's (forked) workspace + target path where autosave + // actually happens. + const indicatorWorkspace = $derived(autosaveWorkspace ?? $workspaceStore) + const indicatorPath = $derived(autosavePath ?? userDraftPath) function getCompactMenuItems(): Item[] { const hasTags = ($workerTags?.length ?? 0) > 0 @@ -728,7 +740,10 @@ }) } - function computeDropdownItems(initialPath: string, savedScript: Script | NewScript | undefined) { + function computeDropdownItems( + initialPath: string, + savedScript: ((Script | NewScript) & { no_deployed?: boolean }) | undefined + ) { let dropdownItems: { label: string; onClick: () => void }[] = initialPath != '' && customUi?.topBar?.extraDeployOptions != false ? [ @@ -759,7 +774,7 @@ ] : []), ...(!inSessionPane && - (savedScript as any)?.no_deployed !== true && + savedScript?.no_deployed !== true && script.kind === 'script' && !script.auto_kind ? [ @@ -1831,7 +1846,7 @@ {hasPreprocessor} canHavePreprocessor={canHavePreprocessor(script.language)} args={hasPreprocessor && selectedInputTab !== 'preprocessor' ? {} : args} - isDeployed={savedScript && (savedScript as any)?.no_deployed !== true} + isDeployed={savedScript && savedScript?.no_deployed !== true} schema={script.schema} runnableVersion={script.parent_hash} onDeployTrigger={handleDeployTrigger} @@ -1849,7 +1864,7 @@
-
+
{#if customUi?.topBar?.path != false} - onNavigate?.(item)} - /> +
+ onNavigate?.(item)} + /> +
{/if} - {#if $workspaceStore} + {#if indicatorWorkspace} openDiffDrawer()} - disabled={!savedScript || !diffDrawer || isDraftOnly} - iconOnly={compactTopbar} - title={isDraftOnly - ? 'Deploy this script once to compare against the deployed version' - : 'Diff'} - startIcon={{ icon: DiffIcon }} - > - Diff - + {@const isDraftOnly = savedScript?.no_deployed === true} + {@const diffDisabled = !savedScript || !diffDrawer || isDraftOnly} + {@const diffTitle = isDraftOnly + ? 'Deploy this script once to compare against the deployed version' + : 'Diff'} + +
+ +
{/if} {/snippet} {#if compactTopbar} diff --git a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte index 5f2039e353..f9c538b50a 100644 --- a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte +++ b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte @@ -23,6 +23,7 @@ would be surprising. import { buildWorkspaceTree, legacyScopeToPath, relativizeWorkspacePath } from './workspaceTree' import { listGlobalDrafts } from '$lib/components/copilot/chat/global/userDraftAdapter' import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate' + import { resource } from 'runed' type Kind = WorkspaceItemKind type ScopeKind = Kind | 'all' @@ -82,11 +83,15 @@ would be surprising. // be surprising (they'd appear as navigable items that 404 on the backend // draft fetch). const KIND_TO_DRAFT_TYPE = { flow: 'flow', script: 'script', app: 'app' } as const + // `listGlobalDrafts` is backend-backed (async); fetch once and derive the + // per-kind lists synchronously from the resolved snapshot. + const globalDraftsResource = resource( + () => ({ ws: $workspaceStore, enabled: isGlobalAiEnabled() }), + async ({ ws, enabled }) => (enabled && ws ? await listGlobalDrafts(ws) : []) + ) function aiDraftsForKind(k: Kind): WorkspaceItem[] { - if (!isGlobalAiEnabled()) return [] - if (!$workspaceStore) return [] const targetType = KIND_TO_DRAFT_TYPE[k] - return listGlobalDrafts($workspaceStore) + return (globalDraftsResource.current ?? []) .filter((d) => d.type === targetType) .map((d) => ({ path: d.path, diff --git a/frontend/src/lib/components/WorkspaceItemRow.svelte b/frontend/src/lib/components/WorkspaceItemRow.svelte index 8ddd3fa317..bb5fc9b74f 100644 --- a/frontend/src/lib/components/WorkspaceItemRow.svelte +++ b/frontend/src/lib/components/WorkspaceItemRow.svelte @@ -44,6 +44,9 @@ doesn't steal focus from a sibling search input (matches the picker). navKey?: string /** Per-row vertical padding class (e.g. `py-1` / `py-1.5`). */ baseClass?: string + /** Reserve two lines of height and vertically center the content so + * summary and summary-less rows are the same height (diff viewer). */ + uniformHeight?: boolean /** Extra left padding (px) for tree-view indentation. Adds to the * default `px-3` horizontal padding. */ indent?: number @@ -76,12 +79,19 @@ doesn't steal focus from a sibling search input (matches the picker). href, onclick, onmouseenter, - extras + extras, + uniformHeight = false }: Props = $props() const rootClass = $derived( `group w-full text-left flex items-center gap-2 px-3 transition-colors ${baseClass} ${highlighted ? 'bg-surface-hover' : ''} ${current ? 'cursor-default text-emphasis font-medium' : ''}` ) + + // Same min-height + centering for both branches so a row with a summary + // (two lines) and one without (one line) end up identical in height. + const contentClass = $derived( + `min-w-0 flex-1${uniformHeight ? ' flex flex-col justify-center min-h-[2.25rem]' : ''}` + ) {#if href} @@ -101,7 +111,7 @@ doesn't steal focus from a sibling search input (matches the picker). {onmouseenter} > -
+
{#if summary}
{summary}
{secondary}
@@ -131,7 +141,7 @@ doesn't steal focus from a sibling search input (matches the picker). {onmouseenter} > -
+
{#if summary}
{summary}
{secondary}
diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 536abff952..12442426f0 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -26,6 +26,22 @@ vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ vi.mock('$lib/components/vscode', () => ({})) +// In-memory stand-in for the per-user draft backend. The chat now persists/reads +// drafts through DraftService (no in-tab cell in unit tests), so this Map is the +// source of truth the write/read tools round-trip against. `vi.hoisted` makes it +// available inside the hoisted `vi.mock` factory and the test body alike. +const { backendDrafts, serverTimestamps, failingWrites, failingReads } = vi.hoisted(() => ({ + backendDrafts: new Map(), + // Per-row server timestamp, only set by tests that want to simulate a + // concurrent writer advancing the row; otherwise empty, so the conflict + // branch in `updateDraft` stays inert for every pre-existing test. + serverTimestamps: new Map(), + // Keys whose `updateDraft` / `getDraftForUser` throw a non-404 (network/5xx); + // only set by the error-handling tests, empty otherwise. + failingWrites: new Set(), + failingReads: new Set() +})) + vi.mock('$lib/gen', async () => { const actual = await vi.importActual('$lib/gen') @@ -140,6 +156,48 @@ vi.mock('$lib/gen', async () => { }), createVariable: vi.fn(async () => 'created'), updateVariable: vi.fn(async () => 'updated') + }), + DraftService: wrapService(actual.DraftService, { + updateDraft: vi.fn(async ({ kind, path, requestBody }: any) => { + const key = `${kind}:${path}` + if (failingWrites.has(key)) throw Object.assign(new Error('server error'), { status: 500 }) + // A non-force save whose last_sync no longer matches the row's + // server timestamp is rejected (optimistic concurrency). Inert + // unless a test set serverTimestamps for this key. + const serverTs = serverTimestamps.get(key) + if ( + !requestBody?.force && + requestBody?.last_sync != null && + serverTs != null && + requestBody.last_sync !== serverTs + ) { + return { status: 'conflict', current_timestamp: serverTs } + } + if (requestBody?.value == null) backendDrafts.delete(key) + else backendDrafts.set(key, requestBody.value) + return { status: 'saved', current_timestamp: '2026-06-15T00:00:00Z' } + }), + getDraftForUser: vi.fn(async ({ kind, path }: any) => { + const key = `${kind}:${path}` + if (failingReads.has(key)) throw Object.assign(new Error('server error'), { status: 500 }) + // 404-shaped (status) like the real ApiError, so the adapter's + // narrowed catch treats it as "no draft" rather than re-throwing. + if (!backendDrafts.has(key)) + throw Object.assign(new Error('no draft for that owner at that path'), { status: 404 }) + return { value: backendDrafts.get(key), created_at: '2026-06-15T00:00:00Z' } + }), + listDrafts: vi.fn(async () => + Array.from(backendDrafts.entries()).map(([key, value]) => { + const idx = key.indexOf(':') + return { + kind: key.slice(0, idx), + path: key.slice(idx + 1), + summary: (value as any)?.summary, + draft_only: true, + created_at: '2026-06-15T00:00:00Z' + } + }) + ) }) } }) @@ -163,7 +221,14 @@ import { setOpenPreviewHandler } from './core' import { UserDraft, __resetUserDraftForTesting } from '$lib/userDraft.svelte' -import { clearGlobalDrafts } from './userDraftAdapter' +import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' +import { + clearGlobalDrafts, + deleteGlobalDraft, + persistGlobalDraft, + readGlobalDraftValue, + saveGlobalAppDraft +} from './userDraftAdapter' import { bundleRawAppDraft } from './rawAppBundlerBridge' import { AppService, @@ -179,6 +244,17 @@ import type { Tool, ToolCallbacks } from '../shared' const WORKSPACE = 'global-core-test' +// Seed/read the backend draft store directly (keyed exactly like the syncer: +// `${itemKind}:${storagePath}`). Drop-in replacements for the old in-tab +// `UserDraft.save`/`UserDraft.get` round-trip the tests used before the drafts +// moved to the backend. Extra opts arg is ignored (kept for call-site parity). +function seedBackendDraft(kind: string, path: string, value: unknown, _opts?: unknown): void { + backendDrafts.set(`${kind}:${path}`, value) +} +function getBackendDraft(kind: string, path: string, _opts?: unknown): V | undefined { + return backendDrafts.get(`${kind}:${path}`) as V | undefined +} + const toolCallbacks: ToolCallbacks = { setToolStatus: vi.fn(), removeToolStatus: vi.fn() @@ -231,6 +307,10 @@ describe('global AI tools', () => { beforeEach(() => { __resetUserDraftForTesting() localStorage.clear() + backendDrafts.clear() + serverTimestamps.clear() + failingWrites.clear() + failingReads.clear() clearGlobalDrafts(WORKSPACE) vi.clearAllMocks() }) @@ -406,7 +486,7 @@ describe('global AI tools', () => { resource_type: 'postgresql' }) - expect(UserDraft.get('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({ + expect(getBackendDraft('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({ path: 'f/resources/db', description: 'existing database', args: { host: 'new.example.com', port: 5432 }, @@ -438,19 +518,21 @@ describe('global AI tools', () => { description: 'new description' }) - expect(UserDraft.get('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toEqual({ - path: 'f/secrets/api_key', - variable: { - value: '', - is_secret: true, - description: 'new description' - }, - labels: ['prod'], - wsSpecific: true, - account: 123, - is_oauth: true, - expires_at: '2026-06-22T09:30:00Z' - }) + expect(getBackendDraft('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toEqual( + { + path: 'f/secrets/api_key', + variable: { + value: '', + is_secret: true, + description: 'new description' + }, + labels: ['prod'], + wsSpecific: true, + account: 123, + is_oauth: true, + expires_at: '2026-06-22T09:30:00Z' + } + ) expect(localStorageSnapshot()).not.toContain('new-secret-token') }) @@ -463,7 +545,7 @@ describe('global AI tools', () => { }) expect( - UserDraft.get('variable', 'f/secrets/api_key', { workspace: WORKSPACE }) + getBackendDraft('variable', 'f/secrets/api_key', { workspace: WORKSPACE }) ).toMatchObject({ path: 'f/secrets/api_key', variable: { @@ -490,12 +572,14 @@ describe('global AI tools', () => { ws_specific: false }) }) - expect(UserDraft.get('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toBeUndefined() + expect( + getBackendDraft('variable', 'f/secrets/api_key', { workspace: WORKSPACE }) + ).toBeUndefined() expect(localStorageSnapshot()).not.toContain('new-secret-token') }) it('does not deploy a secret variable draft when the ephemeral value is gone', async () => { - UserDraft.save( + seedBackendDraft( 'variable', 'f/secrets/api_key', { @@ -531,17 +615,17 @@ describe('global AI tools', () => { content }) - expect(UserDraft.get('script', 'f/scripts/hello', { workspace: WORKSPACE })).toMatchObject( - { - path: 'f/scripts/hello', - summary: 'Hello script', - language: 'bun', - content - } - ) + expect( + getBackendDraft('script', 'f/scripts/hello', { workspace: WORKSPACE }) + ).toMatchObject({ + path: 'f/scripts/hello', + summary: 'Hello script', + language: 'bun', + content + }) }) - it('applies path_prefix to local drafts before enforcing the result limit', async () => { + it('applies path_prefix to drafts before enforcing the result limit', async () => { await callGlobalTool('write_script', { path: 'f/other/outside', summary: 'Outside draft', @@ -571,7 +655,7 @@ describe('global AI tools', () => { }) it('lists and edits the live script editor draft through its effective path', async () => { - UserDraft.save( + seedBackendDraft( 'script', '', { @@ -609,17 +693,17 @@ describe('global AI tools', () => { new_string: 'return a * b' }) - expect(UserDraft.get('script', '', { workspace: WORKSPACE })).toMatchObject({ + expect(getBackendDraft('script', '', { workspace: WORKSPACE })).toMatchObject({ path: 'u/admin/amazed_script', content: 'export async function main(a: number, b: number) {\n\treturn a * b\n}' }) expect( - UserDraft.get('script', 'u/admin/amazed_script', { workspace: WORKSPACE }) + getBackendDraft('script', 'u/admin/amazed_script', { workspace: WORKSPACE }) ).toBeUndefined() }) it('lists and writes the live flow editor draft through its effective path', async () => { - UserDraft.save( + seedBackendDraft( 'flow', '', { @@ -657,16 +741,16 @@ describe('global AI tools', () => { modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }]) }) - expect(UserDraft.get('flow', '', { workspace: WORKSPACE })).toMatchObject({ + expect(getBackendDraft('flow', '', { workspace: WORKSPACE })).toMatchObject({ path: 'u/admin/live_flow', summary: 'Updated live flow', value: { modules: [{ id: 'step', value: { type: 'identity' } }] } }) - expect(UserDraft.get('flow', 'u/admin/live_flow', { workspace: WORKSPACE })).toBeUndefined() + expect(getBackendDraft('flow', 'u/admin/live_flow', { workspace: WORKSPACE })).toBeUndefined() }) it('writes the live raw app editor draft through its effective path', async () => { - UserDraft.save( + seedBackendDraft( 'raw_app', '', { @@ -690,16 +774,16 @@ describe('global AI tools', () => { content: 'export default function New() { return null }' }) - expect(UserDraft.get('raw_app', '', { workspace: WORKSPACE })).toMatchObject({ + expect(getBackendDraft('raw_app', '', { workspace: WORKSPACE })).toMatchObject({ files: { '/src/App.tsx': 'export default function App() { return null }', '/src/New.tsx': 'export default function New() { return null }' } }) - expect(UserDraft.get('raw_app', 'u/admin/live_app', { workspace: WORKSPACE })).toBeUndefined() + expect(getBackendDraft('raw_app', 'u/admin/live_app', { workspace: WORKSPACE })).toBeUndefined() }) - it('discards a local draft without deleting the workspace item', async () => { + it('discards a draft without deleting the workspace item', async () => { await callGlobalTool('write_script', { path: 'f/scripts/discard-me', summary: 'Temporary draft', @@ -707,7 +791,9 @@ describe('global AI tools', () => { content: 'export async function main() { return 1 }' }) - expect(UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })).toBeDefined() + expect( + getBackendDraft('script', 'f/scripts/discard-me', { workspace: WORKSPACE }) + ).toBeDefined() const raw = await callGlobalTool('discard_local_draft', { type: 'script', @@ -721,10 +807,134 @@ describe('global AI tools', () => { }) expect(raw).toContain('The deployed workspace item was not changed') expect( - UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE }) + getBackendDraft('script', 'f/scripts/discard-me', { workspace: WORKSPACE }) ).toBeUndefined() }) + // Covers the conflict-on-save / override branch of `persistGlobalDraft` + // directly: a non-force save whose recorded baseline is older than the + // server row is rejected with `status:'conflict'`, and `override` (force) + // pushes our version through. NB: this targets persistGlobalDraft, not the + // write_* tools — those re-read the backend first (readGlobalDraftValue -> + // recordRemoteSync), which re-seeds the baseline and so can only surface a + // conflict when a live editor cell is mounted (not the case in unit tests). + it('persistGlobalDraft surfaces a conflict on a stale baseline and override forces it', async () => { + const path = 'f/scripts/conflicted' + const key = `script:${path}` + const v1 = { + path, + summary: 'v1', + description: '', + content: 'export function main() {}', + language: 'bun' + } + seedBackendDraft('script', path, v1) + // A concurrent writer advanced the row past the baseline we recorded. + serverTimestamps.set(key, '2026-06-15T00:01:00Z') + UserDraftDbSyncer.recordRemoteSync( + { workspace: WORKSPACE, itemKind: 'script', path }, + '2026-06-15T00:00:00Z' + ) + + const v2 = { ...v1, summary: 'v2', content: 'export function main() { return 1 }' } + const conflict = await persistGlobalDraft(WORKSPACE, 'script', path, v2) + expect(conflict.status).toBe('conflict') + if (conflict.status === 'conflict') { + expect(conflict.serverTimestamp).toBe('2026-06-15T00:01:00Z') + } + // The rejected write left the stored draft untouched. + expect(getBackendDraft('script', path, { workspace: WORKSPACE })).toMatchObject({ + summary: 'v1' + }) + + // override:true bypasses the check and persists our version. + const forced = await persistGlobalDraft(WORKSPACE, 'script', path, v2, { force: true }) + expect(forced.status).toBe('saved') + expect(getBackendDraft('script', path, { workspace: WORKSPACE })).toMatchObject({ + summary: 'v2', + content: 'export function main() { return 1 }' + }) + }) + + // A backend save failure (network/5xx) is recorded in the syncer's failure + // map, not thrown — persistGlobalDraft must report 'error', never 'saved'. + it('persistGlobalDraft reports an error (not saved) when the backend save fails', async () => { + const path = 'f/scripts/savefail' + failingWrites.add(`script:${path}`) + const v = { + path, + summary: 's', + description: '', + content: 'export function main() {}', + language: 'bun' + } + const res = await persistGlobalDraft(WORKSPACE, 'script', path, v) + expect(res.status).toBe('error') + if (res.status === 'error') expect(res.message).toBeTruthy() + // Nothing was persisted. + expect(getBackendDraft('script', path, { workspace: WORKSPACE })).toBeUndefined() + }) + + // A non-404 read failure must propagate, not collapse to "no draft" — else + // the write merge falls through to the deployed item, losing draft edits. + it('a non-404 backend read failure propagates instead of returning undefined', async () => { + const path = 'f/scripts/readfail' + failingReads.add(`script:${path}`) + await expect(readGlobalDraftValue(WORKSPACE, 'script', path)).rejects.toThrow() + }) + + // Raw-app writes go through saveGlobalAppDraft, which must carry the conflict + // status so write_app_* tools don't report a stale write as saved. + it('saveGlobalAppDraft surfaces a conflict on a stale baseline', async () => { + const path = 'u/admin/conflictedapp' + const key = `raw_app:${path}` + seedBackendDraft('raw_app', path, { summary: 'v1', files: {}, runnables: {} }) + serverTimestamps.set(key, '2026-06-15T00:01:00Z') + UserDraftDbSyncer.recordRemoteSync( + { workspace: WORKSPACE, itemKind: 'raw_app', path }, + '2026-06-15T00:00:00Z' + ) + const res = await saveGlobalAppDraft(WORKSPACE, path, { + summary: 'v2', + files: {}, + runnables: {} + } as any) + expect(res.status).toBe('conflict') + }) + + // A failed server delete must surface (throw), not silently report removed — + // the same guard the write path got, applied to the delete path. + it('deleteGlobalDraft throws when the server delete fails', async () => { + const path = 'f/scripts/delfail' + seedBackendDraft('script', path, { + path, + summary: 's', + content: 'export function main() {}', + language: 'bun' + }) + failingWrites.add(`script:${path}`) + await expect(deleteGlobalDraft(WORKSPACE, 'script', path)).rejects.toThrow() + }) + + // `override` is a tool-only conflict flag and must not leak into the persisted + // schedule draft value. + it('does not persist the tool-only override flag into a schedule draft', async () => { + await callGlobalTool('write_schedule', { + path: 'f/schedules/ov', + schedule: '0 0 9 * * *', + timezone: 'UTC', + script_path: 'f/scripts/run', + is_flow: false, + args: {}, + override: true + }) + const draft = getBackendDraft('trigger_schedule', 'f/schedules/ov', { + workspace: WORKSPACE + }) + expect(draft).toBeTruthy() + expect(draft).not.toHaveProperty('override') + }) + it('requires trigger_kind when discarding a trigger draft', async () => { await expect( callGlobalTool('discard_local_draft', { @@ -754,7 +964,7 @@ describe('global AI tools', () => { }) expect( - UserDraft.get('script', 'f/scripts/existing', { workspace: WORKSPACE }) + getBackendDraft('script', 'f/scripts/existing', { workspace: WORKSPACE }) ).toMatchObject({ path: 'f/scripts/existing', parent_hash: 'deployed-hash', @@ -786,7 +996,9 @@ describe('global AI tools', () => { modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }]) }) - expect(UserDraft.get('flow', 'f/flows/existing', { workspace: WORKSPACE })).toMatchObject({ + expect( + getBackendDraft('flow', 'f/flows/existing', { workspace: WORKSPACE }) + ).toMatchObject({ path: 'f/flows/existing', summary: 'new summary', description: 'deployed description', @@ -825,7 +1037,7 @@ describe('global AI tools', () => { }) expect( - UserDraft.get('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE }) + getBackendDraft('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE }) ).toMatchObject({ path: 'f/schedules/nightly', schedule: '0 15 0 * * *', @@ -840,7 +1052,7 @@ describe('global AI tools', () => { no_flow_overlap: true }) expect( - UserDraft.get('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE }) + getBackendDraft('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE }) ).not.toMatchObject({ edited_by: expect.anything() }) @@ -883,7 +1095,7 @@ describe('global AI tools', () => { } }) - const draft = UserDraft.get('trigger_http', 'f/routes/api', { workspace: WORKSPACE }) + const draft = getBackendDraft('trigger_http', 'f/routes/api', { workspace: WORKSPACE }) expect(draft).toMatchObject({ path: 'f/routes/api', script_path: 'f/flows/new', @@ -927,7 +1139,7 @@ describe('global AI tools', () => { content: 'export default function New() { return null }' }) - const draft = UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE }) + const draft = getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE }) expect(draft).toMatchObject({ summary: 'deployed app', files: { @@ -947,7 +1159,7 @@ describe('global AI tools', () => { }) it('summarizes local raw app drafts in read_workspace_item', async () => { - UserDraft.save( + seedBackendDraft( 'raw_app', 'f/apps/local', { @@ -1058,10 +1270,10 @@ describe('global AI tools', () => { file_path: '/src/Helper.tsx' }) ).resolves.toBe('helper content') - expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() + expect(getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() }) - it('reads raw app files without creating a local draft', async () => { + it('reads raw app files without creating a draft', async () => { vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({ path: 'f/apps/report', summary: 'deployed app', @@ -1079,7 +1291,7 @@ describe('global AI tools', () => { file_path: '/src/App.tsx' }) ).resolves.toBe('deployed content') - expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() + expect(getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() }) it('does not persist a raw app draft when patch_app_file validation fails', async () => { @@ -1103,7 +1315,7 @@ describe('global AI tools', () => { replace_all: false }) ).rejects.toThrow() - expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() + expect(getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() }) it('does not persist a raw app draft when delete_app_file validation fails', async () => { @@ -1124,7 +1336,7 @@ describe('global AI tools', () => { file_path: '/src/Missing.tsx' }) ).rejects.toThrow('Frontend file "/src/Missing.tsx" not found in app "f/apps/report".') - expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() + expect(getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() }) it('does not persist a raw app draft when delete_app_runnable validation fails', async () => { @@ -1150,11 +1362,11 @@ describe('global AI tools', () => { key: 'missing' }) ).rejects.toThrow('Backend runnable "missing" not found in app "f/apps/report".') - expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() + expect(getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() }) it('deploys a new raw app draft by bundling files and creating a raw app', async () => { - UserDraft.save( + seedBackendDraft( 'raw_app', 'f/apps/report', { @@ -1206,7 +1418,7 @@ describe('global AI tools', () => { } }) expect(AppService.updateAppRaw).not.toHaveBeenCalled() - expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() + expect(getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() expect(JSON.parse(raw)).toMatchObject({ success: true, type: 'app', @@ -1216,7 +1428,7 @@ describe('global AI tools', () => { it('deploys an existing raw app draft by bundling files and updating the raw app', async () => { vi.mocked(AppService.existsApp).mockResolvedValueOnce(true) - UserDraft.save( + seedBackendDraft( 'raw_app', 'f/apps/report', { @@ -1255,14 +1467,14 @@ describe('global AI tools', () => { } }) expect(AppService.createAppRaw).not.toHaveBeenCalled() - expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() + expect(getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() }) it('notifies the session preview (as raw_app) after deploying a raw app', async () => { const onDeployed = vi.fn() setDeployedInSessionHandler(onDeployed) try { - UserDraft.save( + seedBackendDraft( 'raw_app', 'f/apps/report', { @@ -1388,7 +1600,7 @@ describe('global AI tools', () => { expect(item.value.value).toBeUndefined() }) - it('test_run_script previews local draft script content by path', async () => { + it('test_run_script previews draft script content by path', async () => { const content = 'export async function main(name: string) {\n\treturn `hello ${name}`\n}' await callGlobalTool('write_script', { path: 'f/scripts/draft-test', @@ -1418,7 +1630,7 @@ describe('global AI tools', () => { expect(result).toContain('test logs') }) - it('test_run_script previews deployed script content when no local draft exists', async () => { + it('test_run_script previews deployed script content when no draft exists', async () => { vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({ path: 'f/scripts/deployed-test', summary: 'Deployed test script', @@ -1448,7 +1660,7 @@ describe('global AI tools', () => { }) }) - it('test_run_flow previews local draft flow content by path', async () => { + it('test_run_flow previews draft flow content by path', async () => { const modules = [{ id: 'start', value: { type: 'identity' } }] await callGlobalTool('write_flow', { path: 'f/flows/draft-test', @@ -1474,7 +1686,7 @@ describe('global AI tools', () => { }) }) - it('test_run_flow previews deployed flow content when no local draft exists', async () => { + it('test_run_flow previews deployed flow content when no draft exists', async () => { const modules = [{ id: 'deployed_start', value: { type: 'identity' } }] vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({ path: 'f/flows/deployed-test', @@ -1505,7 +1717,7 @@ describe('global AI tools', () => { }) it('test_run_flow uses the live flow editor test hook when the active editor matches the path', async () => { - UserDraft.save( + seedBackendDraft( 'flow', '', { @@ -1547,7 +1759,7 @@ describe('global AI tools', () => { }) it('test_run_flow falls back to preview when the live flow editor test hook returns undefined', async () => { - UserDraft.save( + seedBackendDraft( 'flow', '', { @@ -1594,7 +1806,7 @@ describe('global AI tools', () => { }) }) - it('test_run_step previews rawscript steps from the local draft flow', async () => { + it('test_run_step previews rawscript steps from the draft flow', async () => { const content = 'export async function main(name: string) {\n\treturn name.toUpperCase()\n}' await callGlobalTool('write_flow', { path: 'f/flows/rawscript-step', @@ -1673,7 +1885,7 @@ describe('global AI tools', () => { }) }) - it('test_run_step previews local draft subflows for flow steps', async () => { + it('test_run_step previews draft subflows for flow steps', async () => { const nestedModules = [{ id: 'nested_start', value: { type: 'identity' } }] await callGlobalTool('write_flow', { path: 'f/flows/nested-draft', @@ -1864,9 +2076,9 @@ describe('prepareGlobalSystemMessage', () => { const message = prepareGlobalSystemMessage() const content = message.content - expect(content).toContain('Draft tools create or update local drafts only') + expect(content).toContain('Draft tools create or update drafts only') expect(content).toContain( - 'Use discard_local_draft to remove an unsaved local draft, including the matching open editor draft' + 'Use discard_local_draft to remove a draft, including the matching open editor draft' ) expect(content).toContain( 'After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step' @@ -1883,7 +2095,7 @@ describe('prepareGlobalSystemMessage', () => { const deleteItem = getGlobalTool('delete_workspace_item') expect(discard.def.function.description).toBe( - 'Discard a local draft only. Does not mutate deployed workspace items, but clears the matching open editor draft if one is mounted.' + 'Discard a draft only. Does not mutate deployed workspace items, but clears the matching open editor draft if one is mounted.' ) expect(deleteItem.def.function.description).toBe( 'Delete a deployed workspace item. Mutates the workspace.' @@ -1984,7 +2196,8 @@ describe('prepareGlobalSystemMessage', () => { const handler = vi.fn(() => ({ aiResult: 'runs output. Next step: call get_job_logs.', uiMessage: 'Listed 1 app run', - toolResult: '[{"job_id":"job-1","component":"backend.1","status":"completed","created_at":1718000000000,"started_at":1718000000000,"duration_ms":1000}]' + toolResult: + '[{"job_id":"job-1","component":"backend.1","status":"completed","created_at":1718000000000,"started_at":1718000000000,"duration_ms":1000}]' })) setListAppRunsHandler(handler) const result = await callGlobalTool('list_app_runs', {}, callbacks, { @@ -2003,7 +2216,8 @@ describe('prepareGlobalSystemMessage', () => { const handler = vi.fn(() => ({ aiResult: 'runs output', uiMessage: 'Listed app runs', - toolResult: '[{"job_id":"job-1","component":"backend.1","status":"completed","created_at":1718000000000,"started_at":1718000000000,"duration_ms":1000}]' + toolResult: + '[{"job_id":"job-1","component":"backend.1","status":"completed","created_at":1718000000000,"started_at":1718000000000,"duration_ms":1000}]' })) setListAppRunsHandler(handler) await callGlobalTool('list_app_runs', { limit: 5 }, toolCallbacks, { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 581ac3ca4b..724af63e42 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -106,9 +106,11 @@ import { getGlobalDraft, getGlobalDraftStoragePath, listGlobalDrafts, + persistGlobalDraft, + readGlobalDraftValue, saveGlobalAppDraft, setEphemeralSecretVariableDraftValue, - triggerKindToUserDraftKind + type DraftPersistResult } from './userDraftAdapter' const ITEM_TYPES = [ @@ -211,11 +213,19 @@ const readWorkspaceItemSchema = z.object({ .describe('Required when type is trigger. Identifies which trigger service to call.') }) +const draftOverrideField = z + .boolean() + .optional() + .describe( + 'Overwrite the server draft even if it changed externally since you last read it (resolve a save conflict, your version wins).' + ) + const writeScriptSchema = z.object({ path: z.string().describe('Workspace path of the script, e.g. f/folder/name or u/user/name.'), summary: z.string().optional().describe('Short human-readable summary.'), language: scriptLangSchema.describe('Script language.'), - content: z.string().describe('Full script source code.') + content: z.string().describe('Full script source code.'), + override: draftOverrideField }) const readFlowModuleCodeSchema = z.object({ @@ -245,6 +255,12 @@ const setFlowModuleCodeSchema = z.object({ const writeFlowSchema = z.object({ path: z.string().describe('Workspace path of the flow, e.g. f/folder/name or u/user/name.'), summary: z.string().optional().describe('Short human-readable summary.'), + description: z + .string() + .optional() + .describe( + 'Longer human-readable description of what the flow does. Top-level flow metadata, separate from the modules — not part of the compact value patched by patch_flow_json.' + ), modules: z.string().describe('JSON string containing the complete flow modules array.'), schema: z .string() @@ -267,7 +283,8 @@ const writeFlowSchema = z.object({ .nullable() .describe( 'JSON string containing the optional array of semantic flow groups. Pass null to clear groups.' - ) + ), + override: draftOverrideField }) function parseOptionalJsonArg(value: unknown, field: string): unknown { @@ -310,7 +327,7 @@ function flowDraftAsEditableInput(flowDraft: FlowDraftValue): { } } -const writeScheduleSchema = scheduleRequestSchema +const writeScheduleSchema = scheduleRequestSchema.extend({ override: draftOverrideField }) const writeTriggerSchema = z.object({ kind: triggerKindSchema.describe('Trigger kind. Determines which fields are valid in config.'), @@ -328,12 +345,13 @@ const writeTriggerSchema = z.object({ ]) .describe( 'Full trigger configuration. Must include path, script_path, is_flow plus the kind-specific fields.' - ) + ), + override: draftOverrideField }) -const writeResourceSchema = resourceRequestSchema +const writeResourceSchema = resourceRequestSchema.extend({ override: draftOverrideField }) -const writeVariableSchema = variableRequestSchema +const writeVariableSchema = variableRequestSchema.extend({ override: draftOverrideField }) const searchResourceTypesSchema = z.object({ query: z.string().describe('Substring to match against resource type names.'), @@ -378,7 +396,7 @@ const deleteWorkspaceItemSchema = z.object({ const discardLocalDraftSchema = z.object({ type: itemTypeSchema, - path: z.string().describe('Workspace path of the local draft to discard.'), + path: z.string().describe('Workspace path of the draft to discard.'), trigger_kind: triggerKindSchema .optional() .describe('Required when type is trigger. Must match the draft trigger kind.') @@ -439,7 +457,7 @@ const testRunScriptSchema = z.object({ const testRunScriptToolDef = createToolDef( testRunScriptSchema, 'test_run_script', - 'Execute a preview-style test run of a script by path, preferring local draft content when it exists.', + 'Execute a preview-style test run of a script by path, preferring draft content when it exists.', { strict: false } ) @@ -451,7 +469,7 @@ const testRunFlowSchema = z.object({ const testRunFlowToolDef = createToolDef( testRunFlowSchema, 'test_run_flow', - 'Execute a preview-style test run of a flow by path, preferring local draft content when it exists.', + 'Execute a preview-style test run of a flow by path, preferring draft content when it exists.', { strict: false } ) @@ -464,7 +482,7 @@ const testRunStepSchema = z.object({ const testRunStepToolDef = createToolDef( testRunStepSchema, 'test_run_step', - 'Execute a test run of one step in a flow by path, preferring local draft flow/script content when it exists.', + 'Execute a test run of one step in a flow by path, preferring draft flow/script content when it exists.', { strict: false } ) @@ -628,7 +646,7 @@ const buildGlobalSystemPrompt = ( The current user's workspace username is "${username}". -Use tools to inspect workspace items and create local drafts for scripts, flows, schedules, triggers, resources, variables, and raw apps. +Use tools to inspect workspace items and create per-user drafts (saved server-side, visible only to this user — not deployed) for scripts, flows, schedules, triggers, resources, variables, and raw apps. Path conventions: - Every workspace path has exactly three segments and starts with one of two namespaces: @@ -639,15 +657,15 @@ Path conventions: - Only use an \`f//\` path when the user explicitly named the folder or you confirmed it exists. Rules: -- Draft tools create or update local drafts only; they do not deploy or mutate deployed workspace items. +- Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items. - Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind. - If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor". -- Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a local draft to the workspace. -- Use discard_local_draft to remove an unsaved local draft, including the matching open editor draft. Use delete_workspace_item only to delete a deployed workspace item. +- Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace. +- Use discard_local_draft to remove a draft, including the matching open editor draft. Use delete_workspace_item only to delete a deployed workspace item. - Variable values are never readable. For secrets, create a secret variable and reference it from resources as "$var:path/to/variable". - Use search_resource_types before write_resource. - Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language. -- After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer local drafts, so testing does not require deployment. +- After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment. - Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_job_logs with a returned id to inspect a specific run's logs — without starting a new test run. - When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. - Keep context targeted.${ @@ -1087,7 +1105,7 @@ function appSourceToDraftValue(app: any, fallback?: any): AppDraftValue { } async function loadAppValueForRead(path: string, workspace: string): Promise { - const draft = getGlobalDraft(workspace, 'app', path) + const draft = await getGlobalDraft(workspace, 'app', path) if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) { return draft.value as AppDraftValue } @@ -1097,7 +1115,7 @@ async function loadAppValueForRead(path: string, workspace: string): Promise { - const draft = getGlobalDraft(workspace, 'app', path) + const draft = await getGlobalDraft(workspace, 'app', path) if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) { return { value: draft.value as AppDraftValue } } @@ -1106,7 +1124,11 @@ async function loadAppDraftValue(path: string, workspace: string): Promise { return saveGlobalAppDraft(workspace, path, value) } @@ -1366,7 +1388,7 @@ function getFlowInstructions(): string { - Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata. - Paths follow the conventions in the system prompt: default to \`u//\` when the user gave a bare name; only use \`f//\` when the folder is known to exist. Never invent a folder. -- \`write_flow\` mirrors flow mode's \`set_flow_json\`: pass \`path\`, optional \`summary\`, required \`modules\`, and optional \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`. The flow-structure arguments are JSON strings, matching the tool schema descriptions. +- \`write_flow\` mirrors flow mode's \`set_flow_json\`: pass \`path\`, optional \`summary\`, optional \`description\`, required \`modules\`, and optional \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`. \`summary\` and \`description\` are top-level flow metadata (not part of the compact value \`patch_flow_json\` edits); the flow-structure arguments are JSON strings, matching the tool schema descriptions. - \`read_workspace_item\` returns a compact flow \`value\` object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`. - \`modules\` contains normal sequential modules. Use top-level \`preprocessor_module\` and \`failure_module\` for special modules; do not put \`preprocessor\` or \`failure\` in \`modules\`. - Every module needs a stable unique \`id\` and a useful \`summary\` when the schema supports it. @@ -1378,7 +1400,7 @@ function getFlowInstructions(): string { - \`read_workspace_item\` and \`patch_flow_json\` operate on a **compact view** of the flow: every rawscript module's \`value.content\` is replaced with the placeholder \`"inline_script."\` so inline script bodies don't bloat tool I/O. Schema, groups, preprocessor_module and failure_module are all shown in this view. - Inline rawscript content is **not** part of the JSON \`patch_flow_json\` sees. Edits to inline bodies happen via dedicated tools: - \`read_flow_module_code(path, module_id)\` — returns the raw inline script content for one module. - - \`set_flow_module_code(path, module_id, code)\` — overwrites that module's inline script content; saves to the local draft. + - \`set_flow_module_code(path, module_id, code)\` — overwrites that module's inline script content; saves to the draft. - Use \`patch_flow_json\` for *structural* edits: module ids, paths, input_transforms, branch arrangement, summaries, preprocessor/failure swaps, schema/groups. Use \`set_flow_module_code\` for changes inside a specific rawscript body. - \`write_flow\` is for full overwrites / create-from-scratch. Its \`modules\`, \`preprocessor_module\`, and \`failure_module\` arguments use **non-compact** flow modules (rawscript content is the actual code, not a placeholder). @@ -1530,7 +1552,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( listWorkspaceItemsSchema, 'list_workspace_items', - 'List workspace items and local drafts. Returns metadata only.' + 'List workspace items and drafts. Returns metadata only.' ), fn: async ({ args, workspace, toolId, toolCallbacks }) => { const parsed = listWorkspaceItemsSchema.parse(args) @@ -1549,7 +1571,7 @@ export const globalTools: Tool<{}>[] = [ byKey.set(getWorkspaceItemKey(item.type, item.path, item.triggerKind), item) } - for (const draft of listGlobalDrafts(workspace)) { + for (const draft of await listGlobalDrafts(workspace)) { if (!types.includes(draft.type)) continue if (parsed.path_prefix && !draft.path.startsWith(parsed.path_prefix)) continue byKey.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), { @@ -1572,7 +1594,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( readWorkspaceItemSchema, 'read_workspace_item', - 'Read one workspace item or local draft.' + 'Read one workspace item or draft.' ), fn: async ({ args, workspace, toolId, toolCallbacks }) => { const parsed = readWorkspaceItemSchema.parse(args) @@ -1581,10 +1603,10 @@ export const globalTools: Tool<{}>[] = [ toolCallbacks.setToolStatus(toolId, { content: message, error: message }) return JSON.stringify({ success: false, error: message }) } - const draft = getGlobalDraft(workspace, parsed.type, parsed.path, parsed.trigger_kind) + const draft = await getGlobalDraft(workspace, parsed.type, parsed.path, parsed.trigger_kind) if (draft) { toolCallbacks.setToolStatus(toolId, { - content: `Read local draft ${parsed.type} "${parsed.path}"` + content: `Read draft ${parsed.type} "${parsed.path}"` }) return JSON.stringify(serializeWorkspaceItemForRead(draft), null, 2) } @@ -1598,11 +1620,7 @@ export const globalTools: Tool<{}>[] = [ } }, { - def: createToolDef( - writeScriptSchema, - 'write_script', - 'Create or overwrite a local draft script.' - ), + def: createToolDef(writeScriptSchema, 'write_script', 'Create or overwrite a draft script.'), showDetails: true, streamArguments: true, showFade: true, @@ -1612,7 +1630,7 @@ export const globalTools: Tool<{}>[] = [ } }, { - def: createToolDef(writeFlowSchema, 'write_flow', 'Create or overwrite a local draft flow.'), + def: createToolDef(writeFlowSchema, 'write_flow', 'Create or overwrite a draft flow.'), showDetails: true, streamArguments: true, showFade: true, @@ -1632,6 +1650,7 @@ export const globalTools: Tool<{}>[] = [ { path: parsed.path, summary: parsed.summary, + description: parsed.description, flow: editableFlowToDraftValue(editable) }, ctx @@ -1642,7 +1661,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( writeScheduleSchema, 'write_schedule', - 'Create or overwrite a local draft schedule.', + 'Create or overwrite a draft schedule.', { strict: false } ), showDetails: true, @@ -1657,7 +1676,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( writeTriggerSchema, 'write_trigger', - 'Create or overwrite a local draft trigger.', + 'Create or overwrite a draft trigger.', { strict: false } ), showDetails: true, @@ -1672,7 +1691,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( editScriptSchema, 'edit_script', - 'Find/replace exact text in a script and save a local draft.' + 'Find/replace exact text in a script and save a draft.' ), showDetails: true, streamArguments: true, @@ -1686,7 +1705,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( patchFlowJsonSchema, 'patch_flow_json', - 'Find/replace exact text in compact flow JSON and save a local draft.' + 'Find/replace exact text in compact flow JSON and save a draft.' ), showDetails: true, streamArguments: true, @@ -1792,13 +1811,13 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( deployWorkspaceItemSchema, 'deploy_workspace_item', - 'Deploy a local draft to the workspace. Mutates the workspace.', + 'Deploy a draft to the workspace. Mutates the workspace.', { strict: false } ), showDetails: true, showFade: true, requiresConfirmation: true, - confirmationMessage: 'Deploy local draft to workspace', + confirmationMessage: 'Deploy draft to workspace', fn: async (ctx) => { const parsed = deployWorkspaceItemSchema.parse(ctx.args) return deployDraft(parsed, { ...ctx, sessionId: sessionIdFromCtx(ctx) }) @@ -1823,12 +1842,12 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( discardLocalDraftSchema, 'discard_local_draft', - 'Discard a local draft only. Does not mutate deployed workspace items, but clears the matching open editor draft if one is mounted.' + 'Discard a draft only. Does not mutate deployed workspace items, but clears the matching open editor draft if one is mounted.' ), showDetails: true, showFade: true, requiresConfirmation: true, - confirmationMessage: 'Discard local draft', + confirmationMessage: 'Discard draft', fn: async (ctx) => { const parsed = discardLocalDraftSchema.parse(ctx.args) return discardLocalDraft(parsed, ctx) @@ -1838,7 +1857,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( writeResourceSchema, 'write_resource', - 'Create or overwrite a local draft resource.', + 'Create or overwrite a draft resource.', { strict: false } ), showDetails: true, @@ -1853,7 +1872,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( writeVariableSchema, 'write_variable', - 'Create or overwrite a local draft variable.', + 'Create or overwrite a draft variable.', { strict: false } ), showDetails: true, @@ -1908,7 +1927,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( setFlowModuleCodeSchema, 'set_flow_module_code', - 'Overwrite inline script code in one flow module and save a local draft.' + 'Overwrite inline script code in one flow module and save a draft.' ), showDetails: true, streamArguments: true, @@ -1922,7 +1941,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( initAppSchema, 'init_app', - 'Initialize a local draft raw app from a framework template.', + 'Initialize a draft raw app from a framework template.', { strict: false } ), showDetails: true, @@ -1972,7 +1991,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( patchAppFileSchema, 'patch_app_file', - 'Find/replace exact text in a raw app file and save a local draft.' + 'Find/replace exact text in a raw app file and save a draft.' ), showDetails: true, streamArguments: true, @@ -2345,7 +2364,7 @@ function buildVariableDeployRequestBody( const secretValue = getEphemeralSecretVariableDraftValue(workspace, storagePath) if (secretValue === undefined) { throw new Error( - `Secret value for local draft variable "${path}" is no longer available because secret draft values are kept only in memory. Run write_variable again before deploying this secret.` + `Secret value for draft variable "${path}" is no longer available because secret draft values are kept only in memory. Run write_variable again before deploying this secret.` ) } @@ -2358,20 +2377,66 @@ function startDraftWrite(ctx: WriteDraftCtx, type: WorkspaceItemType, path: stri }) } -function getRequiredGlobalDraft( - workspace: string, - type: WorkspaceItemType, - path: string, - triggerKind?: TriggerKind -): WorkspaceItem { - const draft = getGlobalDraft(workspace, type, path, triggerKind) - if (!draft) { - throw new Error(`Could not read written draft ${type} "${path}".`) +// Conflict / save-failure handling shared by the kind write tools and the app +// write tools. Returns the JSON tool-result for a non-saved persist, or undefined +// when the save succeeded (the caller then emits its own success payload). +function draftWriteFailure(result: DraftPersistResult, ctx: WriteDraftCtx): string | undefined { + const stored = result.item + if (result.status === 'conflict') { + ctx.toolCallbacks.setToolStatus(ctx.toolId, { + content: `Draft ${stored.type} "${stored.path}" changed externally`, + result: `Conflict` + }) + return JSON.stringify( + { + success: false, + conflict: true, + message: `The ${stored.type} draft "${stored.path}" changed externally since you last read it. Re-run this tool to merge onto the latest version, or pass override:true to overwrite. If an editor for it is open, a conflict dialog is also shown there.` + }, + null, + 2 + ) } - return draft + if (result.status === 'error') { + ctx.toolCallbacks.setToolStatus(ctx.toolId, { + content: `Failed to save ${stored.type} "${stored.path}"`, + result: `Save failed` + }) + return JSON.stringify( + { + success: false, + error: true, + message: `The ${stored.type} draft "${stored.path}" could NOT be saved (${result.message}). The change was not persisted — retry; do not assume it succeeded.` + }, + null, + 2 + ) + } + return undefined } -function finishDraftWrite(stored: WorkspaceItem, existed: boolean, ctx: WriteDraftCtx): string { +// App write tools build varied success messages but share the same conflict / +// save-failure handling; `onSaved` supplies the per-tool status + message. +function finishAppDraftWrite( + result: DraftPersistResult, + ctx: WriteDraftCtx, + onSaved: (item: WorkspaceItem) => { content: string; message: string } +): string { + const failure = draftWriteFailure(result, ctx) + if (failure) return failure + const { content, message } = onSaved(result.item) + ctx.toolCallbacks.setToolStatus(ctx.toolId, { content, result: 'Saved as draft' }) + return JSON.stringify({ success: true, message, item: result.item }, null, 2) +} + +function finishDraftWrite( + result: DraftPersistResult, + existed: boolean, + ctx: WriteDraftCtx +): string { + const failure = draftWriteFailure(result, ctx) + if (failure) return failure + const stored = result.item const verb = existed ? 'Updated' : 'Created' // Don't echo the flow value back: the model just sent it in the write call, // so reflecting the (large) compact flow JSON only burns tokens. Variables @@ -2398,258 +2463,205 @@ function finishDraftWrite(stored: WorkspaceItem, existed: boolean, ctx: WriteDra ) } -async function writeScriptDraft( - args: { path: string; summary?: string; language: ScriptLang; content: string }, - ctx: WriteDraftCtx -): Promise { - const { workspace } = ctx - startDraftWrite(ctx, 'script', args.path) - const storagePath = getGlobalDraftStoragePath(workspace, 'script', args.path) - - const existingDraft = UserDraft.get('script', storagePath, { workspace }) - const backendExists = existingDraft - ? false - : await ScriptService.existsScriptByPath({ workspace, path: args.path }) - - if (existingDraft) { - const draft: NewScript = { - ...structuredClone(existingDraft), - path: args.path, - summary: args.summary ?? existingDraft.summary, - content: args.content, - language: args.language - } - UserDraft.save('script', storagePath, draft, { workspace }) - } else if (backendExists) { - const existing = await ScriptService.getScriptByPath({ - workspace, - path: args.path - }) - const base = existing as unknown as NewScript - const draft: NewScript = { - ...structuredClone(base), - parent_hash: existing.hash, - path: args.path, - summary: args.summary ?? base.summary, - content: args.content, - language: args.language - } - UserDraft.save('script', storagePath, draft, { workspace }) - } else { - const draft: NewScript = { - path: args.path, - summary: args.summary ?? '', - description: '', - content: args.content, - schema: emptySchema(), - is_template: false, - language: args.language, - kind: 'script' - } - UserDraft.save('script', storagePath, draft, { workspace }) - } - - return finishDraftWrite( - getRequiredGlobalDraft(workspace, 'script', args.path), - existingDraft !== undefined || backendExists, - ctx - ) +// Per-draft-kind knowledge for the shared write skeleton. `fetchDeployed` returns +// the deployed item already shaped as a draft value (e.g. script with parent_hash) +// so `buildDraft` treats a draft base and a deployed base identically; a `base` of +// undefined is the create-from-scratch case. `beforePersist` is a kind-local side +// effect run after the value is built (only variable, for its in-memory secret). +type WriteSpec = { + probe: (workspace: string, path: string) => Promise + fetchDeployed: (workspace: string, path: string) => Promise + buildDraft: (base: T | undefined, args: A, path: string) => T + beforePersist?: (workspace: string, args: A) => void } -async function writeFlowDraft( - args: { path: string; summary?: string; flow: FlowDraftValue }, - ctx: WriteDraftCtx +async function writeDraft( + spec: WriteSpec, + type: WorkspaceItemType, + path: string, + args: A, + ctx: WriteDraftCtx, + opts: { triggerKind?: TriggerKind; override?: boolean } = {} ): Promise { const { workspace } = ctx - startDraftWrite(ctx, 'flow', args.path) - const storagePath = getGlobalDraftStoragePath(workspace, 'flow', args.path) + startDraftWrite(ctx, type, path) - const draftValue = args.flow - const value = structuredClone(draftValue.value) - if (draftValue.groups !== undefined && draftValue.groups !== null) { - value.groups = structuredClone(draftValue.groups) + const existingDraft = await readGlobalDraftValue(workspace, type, path, opts.triggerKind) + let base = existingDraft + let existed = existingDraft !== undefined + if (base === undefined && (await spec.probe(workspace, path))) { + base = await spec.fetchDeployed(workspace, path) + existed = true } - const existingDraft = UserDraft.get('flow', storagePath, { workspace }) - const backendExists = existingDraft - ? false - : await FlowService.existsFlowByPath({ workspace, path: args.path }) + const draft = spec.buildDraft(base, args, path) + spec.beforePersist?.(workspace, args) - if (existingDraft) { - const draft: Flow = { - ...structuredClone(existingDraft), - path: args.path, - summary: args.summary ?? existingDraft.summary, - value, - schema: draftValue.schema ?? existingDraft.schema - } - UserDraft.save('flow', storagePath, draft, { workspace }) - } else if (backendExists) { - const existing = await FlowService.getFlowByPath({ workspace, path: args.path }) - const draft: Flow = { - ...structuredClone(existing), - path: args.path, - summary: args.summary ?? existing.summary, - value, - schema: draftValue.schema ?? existing.schema - } - UserDraft.save('flow', storagePath, draft, { workspace }) - } else { - const draft: Flow = { - path: args.path, - summary: args.summary ?? '', - value, - schema: draftValue.schema ?? emptySchema(), - edited_by: '', - edited_at: '', - archived: false, - extra_perms: {} - } - UserDraft.save('flow', storagePath, draft, { workspace }) - } - - return finishDraftWrite( - getRequiredGlobalDraft(workspace, 'flow', args.path), - existingDraft !== undefined || backendExists, - ctx - ) -} - -async function writeScheduleDraft(args: NewSchedule, ctx: WriteDraftCtx): Promise { - const { workspace } = ctx - startDraftWrite(ctx, 'schedule', args.path) - - const existingDraft = UserDraft.get('trigger_schedule', args.path, { - workspace + const result = await persistGlobalDraft(workspace, type, path, draft, { + triggerKind: opts.triggerKind, + force: opts.override }) - const backendExists = existingDraft - ? false - : await ScheduleService.existsSchedule({ workspace, path: args.path }) - - const base = existingDraft - ? existingDraft - : backendExists - ? ((await ScheduleService.getSchedule({ - workspace, - path: args.path - })) as ScheduleDraftConfig) - : undefined - const draft = mergeDraftConfig(base, args as DraftConfig, args.path) - - UserDraft.save('trigger_schedule', args.path, draft, { workspace }) - - return finishDraftWrite( - getRequiredGlobalDraft(workspace, 'schedule', args.path), - existingDraft !== undefined || backendExists, - ctx - ) + return finishDraftWrite(result, existed, ctx) } -async function writeTriggerDraft( - args: { kind: TriggerKind; config: unknown }, +type ScriptDraftArgs = { + path: string + summary?: string + language: ScriptLang + content: string + override?: boolean +} + +const SCRIPT_SPEC: WriteSpec = { + probe: (workspace, path) => ScriptService.existsScriptByPath({ workspace, path }), + fetchDeployed: async (workspace, path) => { + const existing = await ScriptService.getScriptByPath({ workspace, path }) + return { ...(existing as unknown as NewScript), parent_hash: existing.hash } + }, + buildDraft: (base, args, path) => + base + ? { + ...structuredClone(base), + path, + summary: args.summary ?? base.summary, + content: args.content, + language: args.language + } + : { + path, + summary: args.summary ?? '', + description: '', + content: args.content, + schema: emptySchema(), + is_template: false, + language: args.language, + kind: 'script' + } +} + +function writeScriptDraft(args: ScriptDraftArgs, ctx: WriteDraftCtx): Promise { + return writeDraft(SCRIPT_SPEC, 'script', args.path, args, ctx, { override: args.override }) +} + +type FlowDraftArgs = { + path: string + summary?: string + description?: string + flow: FlowDraftValue + override?: boolean +} + +const FLOW_SPEC: WriteSpec = { + probe: (workspace, path) => FlowService.existsFlowByPath({ workspace, path }), + fetchDeployed: (workspace, path) => FlowService.getFlowByPath({ workspace, path }), + buildDraft: (base, args, path) => { + const value = structuredClone(args.flow.value) + if (args.flow.groups !== undefined && args.flow.groups !== null) { + value.groups = structuredClone(args.flow.groups) + } + return base + ? { + ...structuredClone(base), + path, + summary: args.summary ?? base.summary, + description: args.description ?? base.description, + value, + schema: args.flow.schema ?? base.schema + } + : { + path, + summary: args.summary ?? '', + description: args.description ?? '', + value, + schema: args.flow.schema ?? emptySchema(), + edited_by: '', + edited_at: '', + archived: false, + extra_perms: {} + } + } +} + +function writeFlowDraft(args: FlowDraftArgs, ctx: WriteDraftCtx): Promise { + return writeDraft(FLOW_SPEC, 'flow', args.path, args, ctx, { override: args.override }) +} + +const SCHEDULE_SPEC: WriteSpec = { + probe: (workspace, path) => ScheduleService.existsSchedule({ workspace, path }), + fetchDeployed: async (workspace, path) => + (await ScheduleService.getSchedule({ workspace, path })) as ScheduleDraftConfig, + buildDraft: (base, args, path) => { + // `override` is a tool-only conflict-resolution flag, not schedule config — + // strip it so mergeDraftConfig doesn't clone it into the persisted draft. + const { override: _override, ...config } = args + return mergeDraftConfig(base, config as DraftConfig, path) + } +} + +function writeScheduleDraft( + args: NewSchedule & { override?: boolean }, + ctx: WriteDraftCtx +): Promise { + return writeDraft(SCHEDULE_SPEC, 'schedule', args.path, args, ctx, { override: args.override }) +} + +function triggerWriteSpec(kind: TriggerKind): WriteSpec { + const service = triggerServices[kind] + return { + probe: (workspace, path) => service.exists({ workspace, path }), + fetchDeployed: async (workspace, path) => + (await service.get({ workspace, path })) as TriggerDraftConfig, + buildDraft: (base, config, path) => mergeDraftConfig(base, config, path) + } +} + +function writeTriggerDraft( + args: { kind: TriggerKind; config: unknown; override?: boolean }, ctx: WriteDraftCtx ): Promise { - const { workspace } = ctx const config = args.config as TriggerDraftConfig - const path = config.path - const itemKind = triggerKindToUserDraftKind(args.kind) - startDraftWrite(ctx, 'trigger', path) - - const existingDraft = UserDraft.get(itemKind, path, { workspace }) - const backendExists = existingDraft - ? false - : await triggerServices[args.kind].exists({ workspace, path }) - - const base = existingDraft - ? existingDraft - : backendExists - ? ((await triggerServices[args.kind].get({ workspace, path })) as TriggerDraftConfig) - : undefined - const draft = mergeDraftConfig(base, config, path) - - UserDraft.save(itemKind, path, draft, { workspace }) - - return finishDraftWrite( - getRequiredGlobalDraft(workspace, 'trigger', path, args.kind), - existingDraft !== undefined || backendExists, - ctx - ) + return writeDraft(triggerWriteSpec(args.kind), 'trigger', config.path, config, ctx, { + triggerKind: args.kind, + override: args.override + }) } -async function writeResourceDraft(args: CreateResource, ctx: WriteDraftCtx): Promise { - const { workspace } = ctx - startDraftWrite(ctx, 'resource', args.path) - - const existingDraft = UserDraft.get('resource', args.path, { workspace }) - const backendExists = existingDraft - ? false - : await ResourceService.existsResource({ workspace, path: args.path }) - - if (existingDraft) { - UserDraft.save('resource', args.path, createResourceToDraftState(args, existingDraft), { - workspace - }) - } else if (backendExists) { - const existing = await ResourceService.getResource({ workspace, path: args.path }) - UserDraft.save( - 'resource', - args.path, - createResourceToDraftState(args, resourceToDraftState(existing)), - { workspace } - ) - } else { - UserDraft.save('resource', args.path, createResourceToDraftState(args), { workspace }) - } - - return finishDraftWrite( - getRequiredGlobalDraft(workspace, 'resource', args.path), - existingDraft !== undefined || backendExists, - ctx - ) +const RESOURCE_SPEC: WriteSpec = { + probe: (workspace, path) => ResourceService.existsResource({ workspace, path }), + fetchDeployed: async (workspace, path) => + resourceToDraftState(await ResourceService.getResource({ workspace, path })), + buildDraft: (base, args) => createResourceToDraftState(args, base) } -async function writeVariableDraft(args: CreateVariable, ctx: WriteDraftCtx): Promise { - const { workspace } = ctx - startDraftWrite(ctx, 'variable', args.path) +function writeResourceDraft( + args: CreateResource & { override?: boolean }, + ctx: WriteDraftCtx +): Promise { + return writeDraft(RESOURCE_SPEC, 'resource', args.path, args, ctx, { override: args.override }) +} - const existingDraft = UserDraft.get('variable', args.path, { workspace }) - const backendExists = existingDraft - ? false - : await VariableService.existsVariable({ workspace, path: args.path }) +const VARIABLE_SPEC: WriteSpec = { + probe: (workspace, path) => VariableService.existsVariable({ workspace, path }), + fetchDeployed: async (workspace, path) => + variableToDraftState( + await VariableService.getVariable({ workspace, path, decryptSecret: false }) + ), + buildDraft: (base, args) => createVariableToDraftState(args, base), + beforePersist: (workspace, args) => syncEphemeralSecretVariableDraftValue(workspace, args) +} - if (existingDraft) { - UserDraft.save('variable', args.path, createVariableToDraftState(args, existingDraft), { - workspace - }) - } else if (backendExists) { - const existing = await VariableService.getVariable({ - workspace, - path: args.path, - decryptSecret: false - }) - UserDraft.save( - 'variable', - args.path, - createVariableToDraftState(args, variableToDraftState(existing)), - { workspace } - ) - } else { - UserDraft.save('variable', args.path, createVariableToDraftState(args), { workspace }) - } - syncEphemeralSecretVariableDraftValue(workspace, args) - - return finishDraftWrite( - getRequiredGlobalDraft(workspace, 'variable', args.path), - existingDraft !== undefined || backendExists, - ctx - ) +function writeVariableDraft( + args: CreateVariable & { override?: boolean }, + ctx: WriteDraftCtx +): Promise { + return writeDraft(VARIABLE_SPEC, 'variable', args.path, args, ctx, { override: args.override }) } async function loadScriptForEdit( path: string, workspace: string ): Promise<{ content: string; language: ScriptLang; summary?: string }> { - const draft = getGlobalDraft(workspace, 'script', path) + const draft = await getGlobalDraft(workspace, 'script', path) if (draft) { if (typeof draft.value !== 'string' || !draft.language) { throw new Error(`Draft script "${path}" is missing content or language.`) @@ -2684,7 +2696,7 @@ async function loadFlowDraftValue( path: string, workspace: string ): Promise<{ flow: FlowDraftValue; summary?: string }> { - const draft = getGlobalDraft(workspace, 'flow', path) + const draft = await getGlobalDraft(workspace, 'flow', path) if (draft) { if (draft.value === undefined || typeof draft.value === 'string') { throw new Error(`Draft flow "${path}" has no value.`) @@ -2809,7 +2821,7 @@ async function loadScriptForFlowStep( moduleValue: { path: string; hash?: string }, workspace: string ): Promise<{ content: string; language: ScriptLang }> { - const draft = getGlobalDraft(workspace, 'script', moduleValue.path) + const draft = await getGlobalDraft(workspace, 'script', moduleValue.path) if (draft) { if (typeof draft.value !== 'string' || !draft.language) { throw new Error(`Draft script "${moduleValue.path}" is missing content or language.`) @@ -2827,7 +2839,7 @@ async function loadDraftFlowPreviewValue( path: string, workspace: string ): Promise { - if (!getGlobalDraft(workspace, 'flow', path)) { + if (!(await getGlobalDraft(workspace, 'flow', path))) { return undefined } const nestedFlow = await loadFlowDraftValue(path, workspace) @@ -2947,9 +2959,9 @@ async function initApp( const { workspace, toolId, toolCallbacks } = ctx const { path, summary, framework } = args - if (getGlobalDraft(workspace, 'app', path)) { + if (await getGlobalDraft(workspace, 'app', path)) { throw new Error( - `A local draft for app "${path}" already exists. Use write_app_file / write_app_runnable to modify it, or delete the existing draft first.` + `A draft for app "${path}" already exists. Use write_app_file / write_app_runnable to modify it, or delete the existing draft first.` ) } if (await AppService.existsApp({ workspace, path })) { @@ -2969,21 +2981,11 @@ async function initApp( runnables: { [STARTER_RUNNABLE_KEY]: { ...STARTER_RUNNABLE } } } await recomputeAppPolicy(value) - const stored = saveAppDraft(workspace, path, value) - - toolCallbacks.setToolStatus(toolId, { + const result = await saveAppDraft(workspace, path, value) + return finishAppDraftWrite(result, ctx, () => ({ content: `Saved app "${path}" draft (${framework})`, - result: 'Saved as draft' - }) - return JSON.stringify( - { - success: true, - message: `Initialized a per-user draft app "${path}" from the ${framework} template with a starter runnable "${STARTER_RUNNABLE_KEY}" (saved server-side, not a deployed workspace item). Use write_app_file / write_app_runnable to evolve it.`, - item: stored - }, - null, - 2 - ) + message: `Initialized a per-user draft app "${path}" from the ${framework} template with a starter runnable "${STARTER_RUNNABLE_KEY}" (saved server-side, not a deployed workspace item). Use write_app_file / write_app_runnable to evolve it.` + })) } async function readAppFile( @@ -3031,21 +3033,11 @@ async function writeAppFile( const { value } = await loadAppDraftValue(args.path, workspace) value.files = { ...value.files, [target.filePath]: args.content } - const stored = saveAppDraft(workspace, args.path, value) - - toolCallbacks.setToolStatus(toolId, { + const result = await saveAppDraft(workspace, args.path, value) + return finishAppDraftWrite(result, ctx, () => ({ content: `Updated ${target.filePath} in app "${args.path}"`, - result: 'Saved as draft' - }) - return JSON.stringify( - { - success: true, - message: `Updated draft app "${args.path}" with frontend file "${target.filePath}".`, - item: stored - }, - null, - 2 - ) + message: `Updated draft app "${args.path}" with frontend file "${target.filePath}".` + })) } async function deleteAppFile( @@ -3071,21 +3063,11 @@ async function deleteAppFile( } const { [target.filePath]: _removed, ...remaining } = value.files value.files = remaining - const stored = saveAppDraft(workspace, args.path, value) - - toolCallbacks.setToolStatus(toolId, { + const result = await saveAppDraft(workspace, args.path, value) + return finishAppDraftWrite(result, ctx, () => ({ content: `Removed ${target.filePath} from app "${args.path}"`, - result: 'Saved as draft' - }) - return JSON.stringify( - { - success: true, - message: `Removed "${target.filePath}" from draft app "${args.path}".`, - item: stored - }, - null, - 2 - ) + message: `Removed "${target.filePath}" from draft app "${args.path}".` + })) } async function patchAppFile( @@ -3155,20 +3137,11 @@ async function patchAppFile( } } - const stored = saveAppDraft(workspace, path, value) - toolCallbacks.setToolStatus(toolId, { + const result = await saveAppDraft(workspace, path, value) + return finishAppDraftWrite(result, ctx, () => ({ content: `Patched ${target.filePath} in app "${path}"`, - result: 'Saved as draft' - }) - return JSON.stringify( - { - success: true, - message: `Patched "${target.filePath}" in draft app "${path}".`, - item: stored - }, - null, - 2 - ) + message: `Patched "${target.filePath}" in draft app "${path}".` + })) } async function recomputeAppPolicy(value: AppDraftValue): Promise { @@ -3197,21 +3170,11 @@ async function writeAppRunnable( const persisted = buildPersistedRunnable(input, existing) value.runnables = { ...value.runnables, [key]: persisted } await recomputeAppPolicy(value) - const stored = saveAppDraft(workspace, path, value) - - toolCallbacks.setToolStatus(toolId, { + const result = await saveAppDraft(workspace, path, value) + return finishAppDraftWrite(result, ctx, () => ({ content: `Updated runnable "${key}" in app "${path}"`, - result: 'Saved as draft' - }) - return JSON.stringify( - { - success: true, - message: `Updated draft app "${path}" with runnable "${key}".`, - item: stored - }, - null, - 2 - ) + message: `Updated draft app "${path}" with runnable "${key}".` + })) } async function deleteAppRunnable( @@ -3231,21 +3194,11 @@ async function deleteAppRunnable( const { [key]: _removed, ...remaining } = value.runnables value.runnables = remaining await recomputeAppPolicy(value) - const stored = saveAppDraft(workspace, path, value) - - toolCallbacks.setToolStatus(toolId, { + const result = await saveAppDraft(workspace, path, value) + return finishAppDraftWrite(result, ctx, () => ({ content: `Removed runnable "${key}" from app "${path}"`, - result: 'Saved as draft' - }) - return JSON.stringify( - { - success: true, - message: `Removed runnable "${key}" from draft app "${path}".`, - item: stored - }, - null, - 2 - ) + message: `Removed runnable "${key}" from draft app "${path}".` + })) } const triggerLabels: Record = { @@ -3318,12 +3271,12 @@ async function discardLocalDraft( throw new Error('trigger_kind is required when discarding a trigger draft.') } - const draft = getGlobalDraft(workspace, type, path, triggerKind) + const draft = await getGlobalDraft(workspace, type, path, triggerKind) if (!draft) { - throw new Error(`No local draft found for ${type} "${path}".`) + throw new Error(`No draft found for ${type} "${path}".`) } - deleteGlobalDraft(workspace, type, path, triggerKind) + await deleteGlobalDraft(workspace, type, path, triggerKind) toolCallbacks.setToolStatus(toolId, { content: `Discarded ${type} "${path}" draft`, @@ -3332,7 +3285,7 @@ async function discardLocalDraft( return JSON.stringify( { success: true, - message: `Discarded the local-storage draft for ${type} "${path}". The deployed workspace item was not changed.`, + message: `Discarded the draft for ${type} "${path}". The deployed workspace item was not changed.`, type, path, triggerKind @@ -3358,9 +3311,9 @@ async function deployDraft( throw new Error('trigger_kind is required when deploying a trigger.') } - const draft = getGlobalDraft(workspace, type, path, triggerKind) + const draft = await getGlobalDraft(workspace, type, path, triggerKind) if (!draft) { - throw new Error(`No local draft found for ${type} "${path}".`) + throw new Error(`No draft found for ${type} "${path}".`) } if (draft.value === undefined) { throw new Error(`Draft ${type} "${path}" has no value to deploy.`) @@ -3538,7 +3491,7 @@ async function deployDraft( } } - deleteGlobalDraft(workspace, type, path, triggerKind, { preserveLiveDraft: true }) + await deleteGlobalDraft(workspace, type, path, triggerKind, { preserveLiveDraft: true }) // Reload the session preview if it's open on the deployed item. Map the // deploy type to the preview kind — a raw app deploys under 'app' but the @@ -3559,7 +3512,7 @@ async function deployDraft( return JSON.stringify( { success: true, - message: `Deployed local draft ${type} "${path}" to the workspace. Draft removed from the local draft system.`, + message: `Deployed draft ${type} "${path}" to the workspace. Draft removed.`, type, path, triggerKind @@ -3608,7 +3561,7 @@ async function deleteWorkspaceItem( break } - deleteGlobalDraft(workspace, type, path, triggerKind) + await deleteGlobalDraft(workspace, type, path, triggerKind) toolCallbacks.setToolStatus(toolId, { content: `Deleted ${type} "${path}"`, @@ -3617,7 +3570,7 @@ async function deleteWorkspaceItem( return JSON.stringify( { success: true, - message: `Deleted ${type} "${path}" from the workspace. Any matching local draft was also cleared.`, + message: `Deleted ${type} "${path}" from the workspace. Any matching draft was also cleared.`, type, path, triggerKind diff --git a/frontend/src/lib/components/copilot/chat/global/deployRequests.test.ts b/frontend/src/lib/components/copilot/chat/global/deployRequests.test.ts index 88cbafe205..61a6827cbf 100644 --- a/frontend/src/lib/components/copilot/chat/global/deployRequests.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/deployRequests.test.ts @@ -143,6 +143,31 @@ describe('global AI deploy request builders', () => { expect(requestBody.value.groups).toEqual(draftValue.groups) }) + it('deploys a draft-set flow description, overriding the existing one', () => { + const existing = { + path: 'f/demo/flow', + summary: 'existing summary', + description: 'existing description', + value: { modules: [] }, + schema: {} + } as unknown as Flow + + const requestBody = buildFlowDeployRequestBody( + 'f/demo/flow', + undefined, + { + value: { modules: [] }, + schema: null, + groups: null, + description: 'draft-set description' + } as any, + existing, + undefined + ) + + expect(requestBody.description).toBe('draft-set description') + }) + it('falls back to existing flow schema when the draft has no schema', () => { const existing = { path: 'f/demo/flow', diff --git a/frontend/src/lib/components/copilot/chat/global/deployRequests.ts b/frontend/src/lib/components/copilot/chat/global/deployRequests.ts index 9e779779f6..5fe44566c9 100644 --- a/frontend/src/lib/components/copilot/chat/global/deployRequests.ts +++ b/frontend/src/lib/components/copilot/chat/global/deployRequests.ts @@ -84,7 +84,7 @@ export function buildFlowDeployRequestBody( return { path, summary: draftSummary ?? existing?.summary ?? '', - description: existing?.description ?? '', + description: flowDraft.description ?? existing?.description ?? '', value: flowValueWithDraftGroups(flowDraft), schema: flowDraft.schema ?? existing?.schema ?? {}, tag: existing?.tag, diff --git a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts index b9f1ccaee8..009050342e 100644 --- a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts +++ b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts @@ -1,4 +1,8 @@ import type { Flow, NewSchedule, NewScript } from '$lib/gen/types.gen' +import { DraftService } from '$lib/gen' +import { get } from 'svelte/store' +import { userStore } from '$lib/stores' +import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils' import { UserDraft, type UserDraftEntry, type UserDraftItemKind } from '$lib/userDraft.svelte' import { @@ -143,7 +147,8 @@ function flowDraftToWorkspaceItem(path: string, draft: Flow): WorkspaceItem { value: { value: draft.value, schema: draft.schema ?? null, - groups: draft.value.groups ?? null + groups: draft.value.groups ?? null, + description: draft.description ?? null }, isDraft: true } @@ -321,17 +326,178 @@ function getGlobalDraftSlot( return { itemKind, storagePath, displayPath, item } } -export function getGlobalDraft( +// Current user's persisted draft value (+ records the sync baseline so a later +// save detects external conflicts). undefined on 404 (no draft at that path). +async function fetchBackendDraftValue( + workspace: string, + itemKind: UserDraftItemKind, + storagePath: string +): Promise { + try { + const resp = await DraftService.getDraftForUser({ + workspace, + kind: itemKind as any, + path: storagePath, + username: get(userStore)?.username + }) + UserDraftDbSyncer.recordRemoteSync({ workspace, itemKind, path: storagePath }, resp.created_at) + return resp.value ?? undefined + } catch (e) { + // 404 = no draft for this owner at that path (the intended empty case). + // Anything else (403/500/network) MUST propagate: swallowing it would make + // the write merge fall through to the deployed item instead of the user's + // in-progress draft, silently overwriting their draft-only changes. + if ((e as { status?: number } | null | undefined)?.status === 404) return undefined + throw e + } +} + +// Draft VALUE for a write merge: cell-if-present (the user's freshest in-tab +// edits) else the current user's backend draft. +export async function readGlobalDraftValue( workspace: string, type: WorkspaceItemType, path: string, triggerKind?: TriggerKind -): WorkspaceItem | undefined { - return getGlobalDraftSlot(workspace, type, path, triggerKind)?.item +): Promise { + const itemKind = itemKindFor(type, triggerKind) + if (!itemKind) return undefined + const storagePath = resolveDraftStoragePath(workspace, itemKind, path) + const cell = UserDraft.get(itemKind, storagePath, { workspace }) + if (cell !== undefined) return cell + return (await fetchBackendDraftValue(workspace, itemKind, storagePath)) as V | undefined } -export function listGlobalDrafts(workspace: string): WorkspaceItem[] { +export type DraftPersistResult = + | { status: 'saved'; item: WorkspaceItem } + | { status: 'conflict'; item: WorkspaceItem; serverTimestamp?: string } + | { status: 'error'; item: WorkspaceItem; message: string } + +// Persist a built draft value. `UserDraft.seed` reflects it into an open editor's +// cell WITHOUT a double-POST (no-ops if no cell; its seedNextWrite suppresses the +// cell's autosave mirror), then the awaited immediate save is the single source of +// persistence + conflict detection against the shared baseline. force overwrites. +export async function persistGlobalDraft( + workspace: string, + type: WorkspaceItemType, + path: string, + value: unknown, + opts: { triggerKind?: TriggerKind; force?: boolean } = {} +): Promise { + const itemKind = itemKindFor(type, opts.triggerKind) + if (!itemKind) throw new Error(`Unsupported draft type "${type}".`) + const storagePath = resolveDraftStoragePath(workspace, itemKind, path) + UserDraft.seed(itemKind, storagePath, value, { workspace }) + await UserDraftDbSyncer.save({ + workspace, + itemKind, + path: storagePath, + value, + immediate: true, + force: opts.force + }) + const { displayPath, isLiveDraft } = liveDisplayPath(workspace, itemKind, storagePath) + const item = userDraftEntryToWorkspaceItem( + { workspace, itemKind, path: storagePath, value }, + displayPath, + isLiveDraft + ) + if (!item) throw new Error(`Could not synthesize ${type} draft "${path}".`) + // A failed save (network/5xx) is recorded in the syncer's failure map, not + // thrown — so check it before reporting success, else a write tool would tell + // the chat "saved" while the DB-backed source of truth was never updated. + const saveState = UserDraftDbSyncer.getState({ workspace, itemKind, path: storagePath }) + if (saveState.state === 'failed') { + return { status: 'error', item, message: saveState.failureMessage ?? 'Draft save failed' } + } + const conflict = opts.force + ? undefined + : UserDraftDbSyncer.getConflict({ workspace, itemKind, path: storagePath }).conflict + return conflict + ? { status: 'conflict', item, serverTimestamp: conflict.serverTimestamp } + : { status: 'saved', item } +} + +export async function getGlobalDraft( + workspace: string, + type: WorkspaceItemType, + path: string, + triggerKind?: TriggerKind +): Promise { + const slot = getGlobalDraftSlot(workspace, type, path, triggerKind) + if (slot) return slot.item + const itemKind = itemKindFor(type, triggerKind) + if (!itemKind) return undefined + const storagePath = resolveDraftStoragePath(workspace, itemKind, path) + const value = await fetchBackendDraftValue(workspace, itemKind, storagePath) + if (value === undefined || value === null) return undefined + const { displayPath, isLiveDraft } = liveDisplayPath(workspace, itemKind, storagePath) + return userDraftEntryToWorkspaceItem( + { workspace, itemKind, path: storagePath, value }, + displayPath, + isLiveDraft + ) +} + +// Maps a backend `listDrafts` metadata row (no value) to a lightweight item. +// The row's `path` is the storage path; remap it to the live editor's effective +// path (and flag it) when one is open on this key, matching the cell path. +function backendDraftRowToWorkspaceItem( + workspace: string, + row: { + kind: string + path: string + summary?: string + } +): WorkspaceItem | undefined { + if (!(GLOBAL_DRAFT_KINDS as readonly string[]).includes(row.kind)) return undefined + let type: WorkspaceItemType + let triggerKind: TriggerKind | undefined + switch (row.kind) { + case 'script': + case 'flow': + case 'resource': + case 'variable': + type = row.kind + break + case 'raw_app': + type = 'app' + break + case 'trigger_schedule': + type = 'schedule' + break + default: { + const tk = TRIGGER_KIND_BY_DRAFT_KIND[row.kind as UserDraftItemKind] + if (!tk) return undefined + type = 'trigger' + triggerKind = tk + } + } + const { displayPath, isLiveDraft } = liveDisplayPath( + workspace, + row.kind as UserDraftItemKind, + row.path + ) + return { + type, + path: displayPath, + summary: row.summary, + value: undefined, + isDraft: true, + triggerKind, + ...(isLiveDraft ? { isLiveDraft: true } : {}) + } +} + +export async function listGlobalDrafts(workspace: string): Promise { const drafts = new Map() + const rows = await DraftService.listDrafts({ workspace }) + for (const row of rows) { + const item = backendDraftRowToWorkspaceItem(workspace, row) + if (!item) continue + drafts.set(getWorkspaceItemKey(item.type, item.path, item.triggerKind), item) + } + // Overlay live in-tab cells (full values + the user's live edits); cell wins. for (const entry of UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS] })) { const { displayPath, isLiveDraft } = liveDisplayPath(workspace, entry.itemKind, entry.path) const draft = userDraftEntryToWorkspaceItem(entry, displayPath, isLiveDraft) @@ -341,30 +507,27 @@ export function listGlobalDrafts(workspace: string): WorkspaceItem[] { return Array.from(drafts.values()) } -export function saveGlobalAppDraft( +export async function saveGlobalAppDraft( workspace: string, path: string, value: AppDraftValue -): WorkspaceItem { - const storagePath = resolveDraftStoragePath(workspace, 'raw_app', path) - const normalized = normalizeAppDraftValue(value) - UserDraft.save('raw_app', storagePath, normalized, { workspace }) - const stored = getGlobalDraft(workspace, 'app', path) - if (!stored) throw new Error(`Could not read written app draft "${path}".`) - return stored +): Promise { + // Return the full result (not just the item) so app write tools surface a + // conflict / save failure instead of reporting every stale write as saved. + return persistGlobalDraft(workspace, 'app', path, normalizeAppDraftValue(value), {}) } type DeleteGlobalDraftOptions = { preserveLiveDraft?: boolean } -export function deleteGlobalDraft( +export async function deleteGlobalDraft( workspace: string, type: WorkspaceItemType, path: string, triggerKind?: TriggerKind, options: DeleteGlobalDraftOptions = {} -): void { +): Promise { const itemKind = itemKindFor(type, triggerKind) if (!itemKind) return const storagePath = resolveDraftStoragePath(workspace, itemKind, path) @@ -374,6 +537,27 @@ export function deleteGlobalDraft( } else { UserDraft.clear(itemKind, storagePath, { workspace }) } + // `remove`/`clear` only debounce the delete; persist it now so a deploy/discard + // that the caller awaits has actually cleared the server draft on return. + await UserDraftDbSyncer.save({ + workspace, + itemKind, + path: storagePath, + value: null, + immediate: true + }) + // A failed (network/5xx) or conflicted delete is recorded in the syncer state, + // not thrown — surface it so callers don't report the draft as removed while + // the DB-backed source of truth still has it (same guard as the write path). + const state = UserDraftDbSyncer.getState({ workspace, itemKind, path: storagePath }) + if (state.state === 'failed') { + throw new Error(state.failureMessage ?? `Failed to delete draft "${path}".`) + } + if (UserDraftDbSyncer.getConflict({ workspace, itemKind, path: storagePath }).conflict) { + throw new Error( + `Draft "${path}" changed externally since you last read it; it was not removed. Re-read and retry.` + ) + } if (type === 'variable') clearEphemeralSecretVariableDraftValue(workspace, storagePath) } diff --git a/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts b/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts index acc2a5721a..09bbc12da5 100644 --- a/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts +++ b/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts @@ -28,6 +28,7 @@ export type FlowDraftValue = { value: FlowValue schema?: Record | null groups?: NonNullable | null + description?: string | null } export const TRIGGER_KINDS = [ diff --git a/frontend/src/lib/components/flow_builder.ts b/frontend/src/lib/components/flow_builder.ts index 5b8b018665..1fe043707b 100644 --- a/frontend/src/lib/components/flow_builder.ts +++ b/frontend/src/lib/components/flow_builder.ts @@ -17,7 +17,7 @@ export type FlowBuilderProps = { loading?: boolean flowStore: StateStore flowStateStore: StateStore - savedFlow?: Flow | undefined + savedFlow?: Flow & { no_deployed?: boolean } diffDrawer?: DiffDrawerI | undefined customUi?: FlowBuilderWhitelabelCustomUi disableAi?: boolean @@ -33,6 +33,16 @@ export type FlowBuilderProps = { } noInitial?: boolean liveEditorDraftStoragePath?: string + // Indicator-only draft key overrides. When the flow editor is embedded + // (e.g. the sessions preview) its autosave runs under a different + // (workspace, path) than `$workspaceStore`/`liveEditorDraftStoragePath` + // (a forked workspace, and a path this component doesn't own). These let + // the host point the `AutosaveIndicator` at the key its own autosave uses, + // WITHOUT repurposing `liveEditorDraftStoragePath` (which still drives this + // component's setLiveEditorDraft/flush). Undefined → fall back, so the + // full-page editor is unaffected. + autosaveWorkspace?: string + autosavePath?: string onDeploy?: ({ path }: { path: string }) => void onDeployError?: ({ error }: { error: any }) => void onDetails?: ({ path }: { path: string }) => void diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index adafc8256a..07e7c7bb29 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -72,6 +72,8 @@ summary: string policy: any draft_only?: boolean + /** No deployed counterpart exists (draft-only); disables Diff. */ + no_deployed?: boolean custom_path?: string } | undefined @@ -88,6 +90,11 @@ * preference. */ sidebarStorageKey?: string liveEditorDraftStoragePath?: string + /** Indicator-only overrides forwarded to RawAppEditorHeader so the + * sessions preview's AutosaveIndicator watches the session's + * (workspace, path). Undefined on the full-page editor. */ + autosaveWorkspace?: string + autosavePath?: string /** Initial value for the "Split with Preview" tab-bar toggle. Defaults * to `true` (split mode, preview always pinned to the right). Set * `false` when the editor mounts inside a context that wants single- @@ -125,6 +132,8 @@ defaultSidebarCollapsed = false, sidebarStorageKey = 'raw-app-sidebar-collapsed', liveEditorDraftStoragePath = undefined, + autosaveWorkspace = undefined, + autosavePath = undefined, defaultSplitWithPreview = true, pendingDraftPath = $bindable(undefined), onResetToDeployed, @@ -1503,6 +1512,8 @@ {newPath} appPath={path} {liveEditorDraftStoragePath} + {autosaveWorkspace} + {autosavePath} {files} {data} {runnables} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index e21d1cd914..2ecbc9f369 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -101,6 +101,8 @@ summary: string policy: any custom_path?: string + /** No deployed counterpart exists (draft-only); disables Diff. */ + no_deployed?: boolean } | undefined version?: number | undefined @@ -126,6 +128,12 @@ onToggleSidebar?: () => void onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void liveEditorDraftStoragePath?: string + /** Indicator-only overrides for the sessions preview: the AutosaveIndicator + * watches the session's (workspace, path) so it renders + animates on the + * key SessionEditorTarget saves under. Undefined on the full-page editor → + * falls back to `$workspaceStore`/`liveEditorDraftStoragePath`. */ + autosaveWorkspace?: string + autosavePath?: string // Fired after a successful deploy; lets the session preview reload. onDeploy?: (e: { path: string }) => void /** Surfaces the user-typed path (`newEditedPath`) up to the route @@ -168,6 +176,8 @@ onToggleSidebar = undefined, onNavigate = undefined, liveEditorDraftStoragePath = undefined, + autosaveWorkspace = undefined, + autosavePath = undefined, onDeploy = undefined, pendingDraftPath = $bindable(undefined), onResetToDeployed, @@ -176,6 +186,11 @@ onOpenOthersDrafts }: Props = $props() + // The AutosaveIndicator watches these; in the sessions preview they're the + // session's (workspace, path), else the full-page editor's own values. + const indicatorWorkspace = $derived(autosaveWorkspace ?? $workspaceStore) + const indicatorPath = $derived(autosavePath ?? liveEditorDraftStoragePath) + $effect(() => { const typed = newEditedPath const baseline = savedApp?.path ?? '' @@ -184,12 +199,19 @@ }) }) + // `newApp` is true both for a brand-new app AND (in the session preview) for a + // draft-only one that already has a real path — so prefer the real `appPath`, + // but NOT a `draft_{uuid}` storage placeholder (a brand-new app is parked at + // `u/{user}/draft_{uuid}`). A real named path is kept (else its breadcrumb shows + // a random name and deploy createApps under it); a placeholder still falls + // through to the friendly generated suggestion. let newEditedPath = $state( - untrack(() => - newApp - ? newPath || userPathPrefix($userStore?.username) + random_adj() + '_app' + untrack(() => { + const realAppPath = appPath && !appPath.split('/').pop()?.startsWith('draft_') ? appPath : '' + return newApp + ? newPath || realAppPath || userPathPrefix($userStore?.username) + random_adj() + '_app' : newPath || appPath || '' - ) + }) ) $effect(() => { @@ -752,11 +774,11 @@ raw_app onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))} /> - {#if $workspaceStore && liveEditorDraftStoragePath !== undefined} + {#if indicatorWorkspace && indicatorPath !== undefined} - + +
{/if}
+ {#if inputPreface} {@render inputPreface()} {/if} diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index 4fb15b224b..3d9e5c550f 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -188,6 +188,14 @@ focusInput() } + /** Put text back into the textarea (queued-message delete, or restore + * after a cancelled/errored turn), prepended to any draft so nothing + * the user typed is lost. */ + export function prependText(text: string) { + instructions = instructions.trim() ? `${text}\n\n${instructions}` : text + focusInput() + } + function clickOutside(node: HTMLElement) { function handleClick(event: MouseEvent) { if (node && !node.contains(event.target as Node)) { @@ -270,6 +278,17 @@ function sendRequest() { if (aiChatManager.loading) { + // Queue the message instead of silently discarding it — it is + // auto-sent when the streaming turn completes successfully. + // Editing-while-loading keeps the old discard behavior. Paste + // tokens are expanded into the queued text (the queue is plain + // strings), so the full content survives the auto-send. + if (editingMessageIndex === null && instructions.trim()) { + aiChatManager.queueMessage(expanded(chatDraft(instructions, pastes))) + contextTextareaComponent?.clearForSend() + instructions = '' + pastes = [] + } return } if (editingMessageIndex !== null) { diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 94c08019f7..ca8ffd483e 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -89,6 +89,10 @@ import { getLocalSetting, storeLocalSetting } from '$lib/utils' // from mode switches, and the estimate's chars/4 error. const COMPACTION_TRIGGER_RATIO = 0.8 const COMPACTION_TARGET_RATIO = 0.7 +// Abort reason for a deliberate user cancel (Esc / Stop). Programmatic cancels +// (panel teardown, save-and-clear) pass their own reason, so the queued-message +// flush can tell "the user wants to move on" from "the turn was torn down". +const USER_CANCEL_REASON = 'user_cancelled' const AI_AUTONOMY_MODE_STORAGE_KEY = 'ai-chat-autonomy-mode' const LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY = 'ai-chat-yolo-mode' const WEB_SEARCH_ERROR_HINT = @@ -220,6 +224,11 @@ export class AIChatManager { savedSize = $state(0) instructions = $state('') pendingPrompt = $state('') + // Message typed while a turn is streaming. There is only ever one queued + // message; pressing Enter again appends another line to it. Auto-sent when + // the turn finishes (clean completion or user cancel). Ephemeral — never + // saved to displayMessages or history. + queuedMessage = $state('') loading = $state(false) currentReply = $state('') currentReasoning = $state('') @@ -509,6 +518,38 @@ export class AIChatManager { this.aiChatInput = aiChatInput } + /** Queue the message typed while a turn is streaming. There is only ever + * one queued message; pressing Enter again appends the new text as another + * line so it all goes out as a single message. */ + queueMessage(text: string) { + const trimmed = text.trim() + if (!trimmed) { + return + } + this.queuedMessage = this.queuedMessage ? `${this.queuedMessage}\n${trimmed}` : trimmed + } + + /** Remove the queued message and put its text back into the input. */ + dequeueMessage() { + if (!this.queuedMessage) { + return + } + const message = this.queuedMessage + this.queuedMessage = '' + this.restoreToInput(message) + } + + /** Put text the user typed back where they can see it: into the input + * when it's mounted, otherwise back into the queue so it reappears with + * the chat panel instead of being silently dropped. */ + private restoreToInput(text: string) { + if (this.aiChatInput) { + this.aiChatInput.prependText(text) + } else { + this.queuedMessage = text + } + } + focusInput() { if (this.aiChatInput) { this.aiChatInput.focusInput() @@ -789,16 +830,22 @@ export class AIChatManager { } // Roll a turn that produced nothing usable back out of the transcript and - // hand its text back to the composer for editing/resending. + // hand its text back to the composer for editing/resending. `restoreToInput` + // is false when a queued message is about to take over (a user cancel with + // something queued) — then the rolled-back prompt is dropped rather than + // shoved back into the input, so the handoff to the queued message is clean. private restoreUnsentTurn = ( displayLenAfterUser: number, modelLenAfterUser: number, instructions: string, - pastes: PasteAttachment[] + pastes: PasteAttachment[], + restoreToInput: boolean = true ) => { this.displayMessages = this.displayMessages.slice(0, displayLenAfterUser - 1) this.messages = this.messages.slice(0, modelLenAfterUser - 1) - this.aiChatInput?.restoreInstructions(instructions, pastes) + if (restoreToInput) { + this.aiChatInput?.restoreInstructions(instructions, pastes) + } } private chatRequest = async ({ @@ -1003,9 +1050,12 @@ export class AIChatManager { isPreprocessor?: boolean } = {} ) => { + // Returns whether the message was actually turned into a chat turn — + // the queue flush uses this to restore messages dropped by an early + // return instead of silently losing them. const requestedMode = options.mode ?? this.mode if (!isAIModeVisible(requestedMode)) { - return + return false } this.changeMode(requestedMode, undefined, { lang: options.lang, @@ -1015,7 +1065,7 @@ export class AIChatManager { this.instructions = options.instructions } if (!this.instructions.trim()) { - return + return false } if (this.beforeSend) { try { @@ -1032,7 +1082,7 @@ export class AIChatManager { }. Your message was not sent — please try again.`, true ) - return + return false } } const isFirstUserTurn = !this.displayMessages.some((message) => message.role === 'user') @@ -1045,6 +1095,10 @@ export class AIChatManager { // from saveChat) must not make the catch commit the turn a second time. let turnOutcomeHandled = false let webSearchUnavailable = false + // Gates the queued-message flush below: only a cleanly committed turn + // auto-sends the next queued message. Cancel, error, and empty-response + // rollbacks leave it false so queued text is restored to the input. + let turnCommittedCleanly = false try { const oldSelectedContext = this.contextManager?.getSelectedContext() ?? [] if (this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW) { @@ -1313,7 +1367,17 @@ export class AIChatManager { // (or only reasoning) — treat the turn as unsent (matches Claude Code). // contextUsage is left as-is: the turn is rolled back, so the last // report (pre-turn, possibly debited by compaction) still stands. - this.restoreUnsentTurn(displayLenAfterUser, modelLenAfterUser, sentInstructions, sentPastes) + // When the user cancelled with a message queued, that message is + // about to auto-send (see the flush below) — drop the rolled-back + // prompt instead of restoring it to the input so the handoff is clean. + const willAutoSendQueued = this.wasCancelledByUser() && !!this.queuedMessage + this.restoreUnsentTurn( + displayLenAfterUser, + modelLenAfterUser, + sentInstructions, + sentPastes, + !willAutoSendQueued + ) if (this.displayMessages.length === 0) { // saveChat no-ops on an empty transcript; the chat persisted earlier // this turn would linger in history and resurface the rolled-back @@ -1340,6 +1404,10 @@ export class AIChatManager { this.acceptPendingFlowEdits() } await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) + // Only this branch is a clean send: the queued-message flush below + // auto-sends the next message after it (set after saveChat so a + // persistence failure falls through to the restore path instead). + turnCommittedCleanly = true if (isFirstUserTurn && this.afterFirstTurnSaved) { void Promise.resolve(this.afterFirstTurnSaved()).catch((e) => { console.error('AIChatManager afterFirstTurnSaved hook failed', e) @@ -1369,6 +1437,31 @@ export class AIChatManager { } finally { this.loading = false } + // Flush the queued message. Send it after a cleanly committed turn OR a + // deliberate user cancel (Esc / Stop) — in both cases the user is ready + // to move on, so it sends automatically. A genuine error, an + // empty-response rollback, or a programmatic cancel (panel teardown, + // save-and-clear) leaves it in place as a card so it isn't fired into a + // failed or torn-down turn. + if ((turnCommittedCleanly || this.wasCancelledByUser()) && this.queuedMessage) { + const next = this.queuedMessage + this.queuedMessage = '' + const accepted = await this.sendRequest({ instructions: next }) + if (accepted === false) { + // The auto-send bailed before becoming a turn (e.g. beforeSend + // failed); keep it as the queued message instead of losing it. + this.queuedMessage = next + } + } + return true + } + + // True when the current turn's controller was aborted by a deliberate user + // cancel (Esc / Stop), as opposed to a programmatic cancel (panel teardown, + // save-and-clear) or no abort at all. Gates the queued-message auto-send. + private wasCancelledByUser(): boolean { + const signal = this.abortController?.signal + return !!signal?.aborted && signal.reason === USER_CANCEL_REASON } cancel = (reason?: string) => { @@ -1380,7 +1473,7 @@ export class AIChatManager { resolveQuestion(undefined) } this.userQuestionCallbacks.clear() - const cancelReason = reason ?? 'user_cancelled' + const cancelReason = reason ?? USER_CANCEL_REASON console.log('cancelling request:', { reason: cancelReason, abortController: this.abortController @@ -1460,6 +1553,9 @@ export class AIChatManager { saveAndClear = async () => { this.cancel('saveAndClear') + // Drop any message queued in this conversation so it can't auto-send into + // the fresh chat or linger as a card across the switch. + this.queuedMessage = '' await this.historyManager.save(this.displayMessages, this.messages, this.contextUsage) this.displayMessages = [] this.messages = [] @@ -1469,6 +1565,9 @@ export class AIChatManager { loadPastChat = async (id: string) => { const chat = this.historyManager.loadPastChat(id) if (chat) { + // Drop any message queued in the current conversation so it doesn't + // auto-send into the loaded one or linger as a card across the switch. + this.queuedMessage = '' this.displayMessages = chat.displayMessages this.messages = chat.actualMessages this.contextUsage = normalizeContextUsage(chat.contextUsage) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index d61f7ae746..01929cefc7 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -315,6 +315,205 @@ describe('AIChatManager persisted autonomy default', () => { }) }) +describe('AIChatManager queued messages', () => { + const model = { provider: 'openai', model: 'gpt-4o' } + + // The turn-outcome handling rolls back turns with no usable output, so a + // "successful" send must produce a reply to take the clean-commit path + // (which is what gates the queued-message auto-send). + const replyWith = (reply: string) => + mocks.runChatLoop.mockImplementation(async (config: any) => { + const message = { role: 'assistant' as const, content: reply } + config.addedMessages?.push(message) + return { + addedMessages: [message], + tokenUsage: { prompt: 0, completion: 0, total: 0 }, + hitMaxIterations: false + } + }) + + beforeEach(() => { + localStorage.clear() + mocks.getCurrentModel.mockReturnValue(model) + mocks.tryGetCurrentModel.mockReturnValue(model) + }) + + function createInputMock() { + return { + prependText: vi.fn(), + restoreInstructions: vi.fn(), + focusInput: vi.fn() + } + } + + function createManager(input?: ReturnType) { + const manager = new AIChatManager() + manager.mode = AIMode.NAVIGATOR + if (input) { + manager.setAiChatInput(input as unknown as Parameters[0]) + } + return manager + } + + it('queues a single trimmed message and ignores blank input', () => { + const manager = createManager() + manager.queueMessage(' first ') + manager.queueMessage(' ') + expect(manager.queuedMessage).toBe('first') + }) + + it('appends additional lines to the single queued message', () => { + const manager = createManager() + manager.queueMessage('first line') + manager.queueMessage('second line') + expect(manager.queuedMessage).toBe('first line\nsecond line') + }) + + it('dequeues the message and restores it into the input', () => { + const input = createInputMock() + const manager = createManager(input) + manager.queuedMessage = 'line one\nline two' + + manager.dequeueMessage() + + expect(manager.queuedMessage).toBe('') + expect(input.prependText).toHaveBeenCalledWith('line one\nline two') + }) + + it('re-queues instead of dropping when the input is unmounted', () => { + const manager = createManager() + manager.queuedMessage = 'keep me' + + manager.dequeueMessage() + + // no input to restore into → the message stays queued + expect(manager.queuedMessage).toBe('keep me') + }) + + it('auto-sends the queued message on a clean completion', async () => { + replyWith('done') + const manager = createManager(createInputMock()) + + manager.queuedMessage = 'followup' + await manager.sendRequest({ instructions: 'first' }) + + expect(mocks.runChatLoop).toHaveBeenCalledTimes(2) + expect(manager.queuedMessage).toBe('') + const userMessages = manager.displayMessages + .filter((m) => m.role === 'user') + .map((m) => m.content) + expect(userMessages).toEqual(['first', 'followup']) + }) + + it('keeps the queued message as a card (not flushed to input) when the turn errors', async () => { + const input = createInputMock() + const manager = createManager(input) + mocks.runChatLoop.mockRejectedValue(new Error('provider down')) + + manager.queuedMessage = 'followup' + await manager.sendRequest({ instructions: 'first' }) + + expect(mocks.runChatLoop).toHaveBeenCalledTimes(1) + // stays a card, nothing flushed into the input + expect(manager.queuedMessage).toBe('followup') + expect(input.prependText).not.toHaveBeenCalled() + }) + + it('auto-sends the queued message when the user cancels the turn (Esc/Stop)', async () => { + const manager = createManager(createInputMock()) + // the followup turn completes cleanly... + replyWith('done') + // ...but the first turn is cancelled by the user + mocks.runChatLoop.mockImplementationOnce(async ({ abortController }: any) => { + abortController.abort('user_cancelled') + throw new Error('aborted') + }) + + manager.queuedMessage = 'followup' + await manager.sendRequest({ instructions: 'first' }) + + // cancel sends the queued message automatically + expect(manager.queuedMessage).toBe('') + const userMessages = manager.displayMessages + .filter((m) => m.role === 'user') + .map((m) => m.content) + expect(userMessages).toContain('followup') + }) + + it('does NOT auto-send on a programmatic cancel (e.g. save-and-clear / teardown)', async () => { + const manager = createManager(createInputMock()) + replyWith('done') + // the turn is aborted programmatically, not by the user pressing Esc/Stop + mocks.runChatLoop.mockImplementationOnce(async ({ abortController }: any) => { + abortController.abort('saveAndClear') + throw new Error('aborted') + }) + + manager.queuedMessage = 'followup' + await manager.sendRequest({ instructions: 'first' }) + + // a non-user abort must not fire the queued message; it stays a card + expect(manager.queuedMessage).toBe('followup') + expect(mocks.runChatLoop).toHaveBeenCalledTimes(1) + }) + + it('does not restore the cancelled prompt to the input when a queued message takes over', async () => { + const input = createInputMock() + const manager = createManager(input) + replyWith('done') + // cancel before any usable output → the rollback (restoreUnsentTurn) path + mocks.runChatLoop.mockImplementationOnce(async ({ abortController }: any) => { + abortController.abort('user_cancelled') + throw new Error('aborted') + }) + + manager.queuedMessage = 'followup' + await manager.sendRequest({ instructions: 'the long cancelled prompt' }) + + // clean handoff: queued message sent, cancelled prompt NOT shoved back in + expect(manager.queuedMessage).toBe('') + expect(input.restoreInstructions).not.toHaveBeenCalled() + }) + + it('re-queues the message when its auto-send is rejected by beforeSend', async () => { + replyWith('done') + const input = createInputMock() + const manager = createManager(input) + // first turn goes through, the queued auto-send is rejected + manager.beforeSend = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('workspace commit failed')) + + manager.queuedMessage = 'followup' + await manager.sendRequest({ instructions: 'first' }) + + expect(mocks.runChatLoop).toHaveBeenCalledTimes(1) + // the rejected message stays a card rather than being lost or moved to input + expect(manager.queuedMessage).toBe('followup') + expect(input.prependText).not.toHaveBeenCalled() + }) + + it('drops the queued message when switching conversations (no cross-chat leak)', async () => { + const manager = createManager(createInputMock()) + + manager.queuedMessage = 'meant for chat A' + await manager.saveAndClear() + expect(manager.queuedMessage).toBe('') + + manager.queuedMessage = 'still meant for chat A' + vi.spyOn(manager.historyManager, 'loadPastChat').mockReturnValue({ + id: 'chat-b', + title: 'Chat B', + displayMessages: [], + actualMessages: [], + lastModified: 0 + } as unknown as ReturnType) + await manager.loadPastChat('chat-b') + expect(manager.queuedMessage).toBe('') + }) +}) + describe('AIChatManager context compaction', () => { // claude-sonnet-4-6 resolves to a known 1M window (modelConfig is // unmocked): compaction triggers at a projected 800k and drops head @@ -708,7 +907,7 @@ describe('AIChatManager sendRequest lifecycle', () => { vi.mocked(runChatLoop).mockImplementation(async (config) => { config.callbacks.onNewToken('Here is the partial ') config.callbacks.onNewToken('answer') - config.abortController.abort('user_cancelled') + config.abortController.abort() throw new Error('aborted') }) @@ -733,7 +932,7 @@ describe('AIChatManager sendRequest lifecycle', () => { vi.mocked(runChatLoop).mockImplementation(async (config) => { config.callbacks.onReasoningStart?.() config.callbacks.onReasoningDelta?.('still thinking...') - config.abortController.abort('user_cancelled') + config.abortController.abort() throw new Error('aborted') }) @@ -764,7 +963,7 @@ describe('AIChatManager sendRequest lifecycle', () => { vi.mocked(runChatLoop).mockImplementation(async (config) => { config.callbacks.onNewToken('Partial from Claude') config.callbacks.onMessageEnd() - config.abortController.abort('user_cancelled') + config.abortController.abort() throw new Error('aborted') }) @@ -790,7 +989,7 @@ describe('AIChatManager sendRequest lifecycle', () => { config.callbacks.onNewToken('The full answer') config.addedMessages!.push({ role: 'assistant', content: 'The full answer' }) config.callbacks.onMessageEnd() - config.abortController.abort('user_cancelled') + config.abortController.abort() throw new Error('aborted') }) diff --git a/frontend/src/lib/components/copilot/chat/QueuedMessageChip.svelte b/frontend/src/lib/components/copilot/chat/QueuedMessageChip.svelte new file mode 100644 index 0000000000..3d40225360 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/QueuedMessageChip.svelte @@ -0,0 +1,33 @@ + + +{#if aiChatManager.queuedMessage} +
+
+

+ {aiChatManager.queuedMessage} +

+
+
+{/if} From f4425fca9fb0d02b845bd72888ade54905c5a30b Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:01:03 +0200 Subject: [PATCH 072/246] feat(ai-chat): self-hosted docs tools via windmill.dev llms.txt + ask benchmark (#9578) * feat(ai-chat): add self-hosted docs tools fetching from windmill.dev llms.txt Co-Authored-By: Claude Fable 5 * test(ai-evals): add ask benchmark mode comparing inkeep vs llms.txt docs tools Co-Authored-By: Claude Fable 5 * test(ai-chat): fix docs link sanitizer tests to match skip-all-`../` guard Co-Authored-By: Claude Fable 5 * feat(ai-chat): add hybrid full-text docs search tool and ask variant Co-Authored-By: Claude Fable 5 * feat(ai-chat): expose docs search tools in the global workspace assistant Co-Authored-By: Claude Fable 5 * refactor(ai-chat): drop inkeep/llmstxt arms, keep only hybrid docs search Co-Authored-By: Claude Fable 5 * docs(ai-chat): remove docs-tool benchmark write-up Co-Authored-By: Claude Fable 5 * refactor(ai-evals): remove ask mode, cover docs search via global mode Co-Authored-By: Claude Fable 5 * nits * refactor(ai-chat): swap navigator + api copilots from inkeep to search_docs Co-Authored-By: Claude Fable 5 * fix(ai-chat): point read_docs_page empty-path hint at search_docs Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- ai_evals/adapters/frontend/benchmarkRunner.ts | 7 +- .../adapters/frontend/vitestAdapter.test.ts | 3 +- ai_evals/cases/global.yaml | 73 ++ ai_evals/core/cases.test.ts | 15 + ai_evals/core/runSuite.ts | 4 +- ai_evals/core/types.ts | 11 +- .../copilot/chat/AIChatManager.svelte.ts | 5 +- .../lib/components/copilot/chat/api/core.ts | 7 +- .../lib/components/copilot/chat/ask/core.ts | 16 +- .../components/copilot/chat/docs/core.test.ts | 464 ++++++++++ .../lib/components/copilot/chat/docs/core.ts | 863 ++++++++++++++++++ .../components/copilot/chat/global/core.ts | 10 + .../components/copilot/chat/navigator/core.ts | 97 +- 13 files changed, 1471 insertions(+), 104 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/docs/core.test.ts create mode 100644 frontend/src/lib/components/copilot/chat/docs/core.ts 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/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index ebbbac8d11..2739eedce2 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -434,5 +434,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/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index ca8ffd483e..cb775a6084 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -49,6 +49,7 @@ import { get } from 'svelte/store' import { BROWSER } from 'esm-env' import { workspaceStore, type DBSchemas } from '$lib/stores' import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core' +import { readDocsPageTool, searchDocsTool } from './docs/core' import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte' import { createAppBackendRunnableContextElement, @@ -400,7 +401,7 @@ export class AIChatManager { try { this.apiTools = await loadApiTools() if (this.mode === AIMode.API) { - this.tools = [...this.apiTools] + this.tools = [searchDocsTool, readDocsPageTool, ...this.apiTools] } } catch (err) { console.error('Error loading api tools', err) @@ -666,7 +667,7 @@ export class AIChatManager { } else if (mode === AIMode.API) { const customPrompt = getCombinedCustomPrompt(mode) this.systemMessage = prepareApiSystemMessage(customPrompt) - this.tools = [...this.apiTools] + this.tools = [searchDocsTool, readDocsPageTool, ...this.apiTools] this.helpers = {} } else if (mode === AIMode.GLOBAL) { const customPrompt = getCombinedCustomPrompt(mode) diff --git a/frontend/src/lib/components/copilot/chat/api/core.ts b/frontend/src/lib/components/copilot/chat/api/core.ts index 4e47baea72..e4c2ab07c7 100644 --- a/frontend/src/lib/components/copilot/chat/api/core.ts +++ b/frontend/src/lib/components/copilot/chat/api/core.ts @@ -4,7 +4,6 @@ import type { } from 'openai/resources/index.mjs' import type { Tool } from '../shared' import { loadApiTools } from './apiTools' -import { getDocumentationTool } from '../navigator/core' import { userStore } from '$lib/stores' import { get } from 'svelte/store' @@ -14,13 +13,13 @@ You are Windmill's intelligent assistant, designed to interact with the platform Windmill is an open-source developer platform for building internal tools, API integrations, background jobs, workflows, and user interfaces. It offers a unified system where scripts are automatically turned into sharable UIs and can be composed into flows or embedded in custom applications. You have access to these tools: -1. Get documentation for user requests (get_documentation) +1. Search the documentation (search_docs) and read a documentation page (read_docs_page) 2. A comprehensive list of API endpoints to interact with the Windmill backend INSTRUCTIONS: - You can directly query, list, create, update, and delete various Windmill resources like scripts, flows, jobs, resources, variables, schedules, and workers through the provided API tools. - When users ask about specific data or want to perform operations, use the appropriate API endpoints to fulfill their requests. -- Use get_documentation to retrieve accurate information about features, concepts, and best practices when needed. +- Use search_docs (then read_docs_page on a returned Source URL) to retrieve accurate information about features, concepts, and best practices when needed. - Always present API results in a clear, readable format for the user. - If you need to make multiple related API calls to fulfill a request, do so systematically and explain what you're doing. - When showing lists of items, provide meaningful summaries rather than overwhelming the user with raw data. @@ -55,8 +54,6 @@ export async function getApiTools(): Promise[]> { return apiToolsCache } -export const apiTools: Tool<{}>[] = [getDocumentationTool] - export function prepareApiSystemMessage(customPrompt?: string): ChatCompletionSystemMessageParam { let content = CHAT_SYSTEM_PROMPT(get(userStore)?.username ?? '') diff --git a/frontend/src/lib/components/copilot/chat/ask/core.ts b/frontend/src/lib/components/copilot/chat/ask/core.ts index f9ba219599..b93ace1179 100644 --- a/frontend/src/lib/components/copilot/chat/ask/core.ts +++ b/frontend/src/lib/components/copilot/chat/ask/core.ts @@ -3,19 +3,23 @@ import type { ChatCompletionUserMessageParam } from 'openai/resources/index.mjs' import type { Tool } from '../shared' -import { getDocumentationTool } from '../navigator/core' +import { readDocsPageTool, searchDocsTool } from '../docs/core' export const CHAT_SYSTEM_PROMPT = ` You are Windmill's intelligent assistant, designed to answer questions about its functionality. It is your only purpose to help the user in the context of the windmill application. Windmill is an open-source developer platform for building internal tools, API integrations, background jobs, workflows, and user interfaces. It offers a unified system where scripts are automatically turned into sharable UIs and can be composed into flows or embedded in custom applications. You have access to these tools: -1. Get documentation for user requests (get_documentation) +1. Search the documentation (search_docs) +2. Read a documentation page (read_docs_page) INSTRUCTIONS: -- When user asks about something, use the get_documentation tool to retrieve accurate information about how to fulfill the user's request. -- Complete your response with precisions about how it works based on the documentation. Also drop a link to the relevant documentation if possible. -- If the user asks about something that you are unsure about, say that you are not sure about the answer and suggest to ask the question to the windmill team. +- Call search_docs FIRST with a few distinctive keywords from the user's question to find the most relevant documentation pages and matching snippets. +- If the snippets already answer the question, answer directly. Otherwise call read_docs_page with one of the returned Source URLs to read the full page; if read_docs_page returns a list of section headings, call it again with the same path and a \`section\` argument to read the relevant section. +- If the first search returns nothing useful, retry with different or broader keywords before giving up. +- Answer based ONLY on what you find in the documentation. Do not invent features, flags, syntax, or behavior that you did not see in the docs. +- Always include the documentation URL(s) you consulted in your answer. Cite the exact "Source" URL shown in the search results (or the "Source page" URL at the top of a read page) — never reconstruct a URL from a link inside the page body. +- If the documentation does not cover the user's question, say so clearly rather than inventing an answer, and suggest asking the Windmill team. GENERAL PRINCIPLES: - Be concise but thorough @@ -23,7 +27,7 @@ GENERAL PRINCIPLES: - If you encounter an error or can't complete a request, explain why and suggest alternatives ` -export const askTools: Tool<{}>[] = [getDocumentationTool] +export const askTools: Tool<{}>[] = [searchDocsTool, readDocsPageTool] export function prepareAskSystemMessage(customPrompt?: string): ChatCompletionSystemMessageParam { let content = CHAT_SYSTEM_PROMPT diff --git a/frontend/src/lib/components/copilot/chat/docs/core.test.ts b/frontend/src/lib/components/copilot/chat/docs/core.test.ts new file mode 100644 index 0000000000..da0e7f0880 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/docs/core.test.ts @@ -0,0 +1,464 @@ +import { describe, expect, it } from 'vitest' +import { + buildDocsOutline, + canonicalDocsPageUrl, + extractDocsSection, + formatDocsSearchResults, + makeSnippet, + mergeDocsSearchResults, + normalizeDocsUrl, + parseDocsFullText, + parseDocsHeadings, + parseDocsIndex, + renderDocsPageResult, + sanitizeDocsMarkdownLinks, + searchDocsIndex, + searchDocsPages +} from './core' + +const SAMPLE = `# Jobs + +Intro text about jobs. + +## Job kinds + +Some kinds. + +## Result + +### Result of jobs that failed + +\`\`\` +{ "error": "boom" } +\`\`\` + +### Result streaming + +#### Returning a stream directly + +\`\`\`python +# Returning a stream directly is a comment heading that must be ignored +def main(): + pass +\`\`\` + +## Retention policy + +Final section. +` + +describe('parseDocsHeadings', () => { + it('parses headings with their levels and ignores headings inside fenced code blocks', () => { + const headings = parseDocsHeadings(SAMPLE) + const titles = headings.map((h) => `${h.level}:${h.title}`) + + expect(titles).toEqual([ + '1:Jobs', + '2:Job kinds', + '2:Result', + '3:Result of jobs that failed', + '3:Result streaming', + '4:Returning a stream directly', + '2:Retention policy' + ]) + // The "# Returning a stream directly is a comment..." line inside the + // python fence must not be parsed as a heading. + expect(titles).not.toContain('1:Returning a stream directly is a comment heading that must be ignored') + }) + + it('returns startIndex offsets that point at the heading line', () => { + const headings = parseDocsHeadings(SAMPLE) + for (const heading of headings) { + expect(SAMPLE.slice(heading.startIndex)).toMatch( + new RegExp(`^#{${heading.level}}\\s+${heading.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`) + ) + } + }) + + it('handles tilde fences', () => { + const content = '# Title\n\n~~~\n# not a heading\n~~~\n\n## Real\n' + const headings = parseDocsHeadings(content) + expect(headings.map((h) => h.title)).toEqual(['Title', 'Real']) + }) +}) + +describe('extractDocsSection', () => { + it('extracts a section from its heading up to the next same-or-higher level heading', () => { + const section = extractDocsSection(SAMPLE, 'Result') + expect(section).toBeDefined() + expect(section).toContain('## Result') + expect(section).toContain('### Result of jobs that failed') + expect(section).toContain('### Result streaming') + // Stops before the next level-2 heading. + expect(section).not.toContain('## Retention policy') + }) + + it('matches case-insensitively and tolerates punctuation differences', () => { + const section = extractDocsSection(SAMPLE, 'retention-policy!') + expect(section).toBeDefined() + expect(section).toContain('## Retention policy') + expect(section).toContain('Final section.') + }) + + it('returns the deepest section bounded by the next same-level heading', () => { + const section = extractDocsSection(SAMPLE, 'Result streaming') + expect(section).toBeDefined() + expect(section).toContain('### Result streaming') + expect(section).toContain('#### Returning a stream directly') + expect(section).not.toContain('## Retention policy') + }) + + it('returns undefined when no heading matches', () => { + expect(extractDocsSection(SAMPLE, 'Nonexistent section')).toBeUndefined() + }) +}) + +describe('buildDocsOutline', () => { + it('lists headings with approximate per-section sizes and indentation', () => { + const outline = buildDocsOutline(SAMPLE) + expect(outline).toContain('- Jobs (~') + expect(outline).toContain(' - Job kinds (~') + expect(outline).toContain(' - Result of jobs that failed (~') + }) + + it('handles pages with no headings', () => { + expect(buildDocsOutline('just some text\nwith no headings')).toBe( + '(no markdown headings found on this page)' + ) + }) +}) + +describe('normalizeDocsUrl', () => { + it('appends .md to a bare path', () => { + expect(normalizeDocsUrl('/docs/core_concepts/jobs')).toBe( + 'https://www.windmill.dev/docs/core_concepts/jobs.md' + ) + }) + + it('accepts a path without a leading slash', () => { + expect(normalizeDocsUrl('docs/core_concepts/jobs')).toBe( + 'https://www.windmill.dev/docs/core_concepts/jobs.md' + ) + }) + + it('accepts a full URL and strips anchors and query strings', () => { + expect( + normalizeDocsUrl('https://www.windmill.dev/docs/core_concepts/jobs#result?foo=bar') + ).toBe('https://www.windmill.dev/docs/core_concepts/jobs.md') + }) + + it('does not double-append .md', () => { + expect(normalizeDocsUrl('/docs/core_concepts/jobs.md')).toBe( + 'https://www.windmill.dev/docs/core_concepts/jobs.md' + ) + }) + + it('strips a trailing slash before appending .md', () => { + expect(normalizeDocsUrl('/docs/core_concepts/jobs/')).toBe( + 'https://www.windmill.dev/docs/core_concepts/jobs.md' + ) + }) + + it('strips docusaurus numeric ordering prefixes from path segments', () => { + expect(normalizeDocsUrl('/docs/flows/13_flow_branches')).toBe( + 'https://www.windmill.dev/docs/flows/flow_branches.md' + ) + }) + + it('converts a .mdx source suffix to .md', () => { + expect(normalizeDocsUrl('/docs/flows/13_flow_branches.mdx')).toBe( + 'https://www.windmill.dev/docs/flows/flow_branches.md' + ) + }) +}) + +describe('sanitizeDocsMarkdownLinks', () => { + const PAGE = 'https://www.windmill.dev/docs/flows/flow_editor.md' + + it('rewrites a relative .mdx source link to a canonical published URL', () => { + expect(sanitizeDocsMarkdownLinks('See [retries](./14_retries.mdx) for more.', PAGE)).toBe( + 'See [retries](https://www.windmill.dev/docs/flows/retries) for more.' + ) + }) + + it('strips numeric prefixes from same-directory links', () => { + expect(sanitizeDocsMarkdownLinks('[handling](./8_error_handling.mdx)', PAGE)).toBe( + '[handling](https://www.windmill.dev/docs/flows/error_handling)' + ) + }) + + it('preserves anchors when rewriting', () => { + expect(sanitizeDocsMarkdownLinks('[branch all](./13_flow_branches.mdx#branch-all)', PAGE)).toBe( + '[branch all](https://www.windmill.dev/docs/flows/flow_branches#branch-all)' + ) + }) + + it('leaves image and external links untouched', () => { + const input = + '![diagram](./assets/flow_example.png) and [site](https://example.com/page.md)' + expect(sanitizeDocsMarkdownLinks(input, PAGE)).toBe(input) + }) + + it('leaves bare anchor links untouched', () => { + expect(sanitizeDocsMarkdownLinks('[top](#introduction)', PAGE)).toBe('[top](#introduction)') + }) + + // `../` links are authored against the docusaurus source tree, whose directory + // depth differs from the published URL on slug-flattened pages, so resolving + // them against the page URL is unreliable (a single `../` can over-escape just + // as a double one does). All `../` links are left untouched and disambiguated + // by the canonical "Source page" header instead. + it('leaves single ../ cross-directory links untouched', () => { + const input = '[handling](../core_concepts/8_error_handling.mdx)' + expect(sanitizeDocsMarkdownLinks(input, PAGE)).toBe(input) + }) + + it('leaves double ../../ cross-directory links untouched', () => { + const input = '[retries](../../flows/14_retries.md)' + expect(sanitizeDocsMarkdownLinks(input, PAGE)).toBe(input) + }) +}) + +describe('canonicalDocsPageUrl', () => { + it('returns the published URL without the .md suffix', () => { + expect(canonicalDocsPageUrl('/docs/flows/flow_editor')).toBe( + 'https://www.windmill.dev/docs/flows/flow_editor' + ) + }) + + it('strips numeric prefixes so a source-style path maps to the published URL', () => { + expect(canonicalDocsPageUrl('/docs/flows/14_retries.md')).toBe( + 'https://www.windmill.dev/docs/flows/retries' + ) + }) +}) + +describe('renderDocsPageResult', () => { + it('returns the whole page when small and no section requested', () => { + expect(renderDocsPageResult(SAMPLE)).toBe(SAMPLE) + }) + + it('returns an outline for large pages with no section requested', () => { + const large = `# Big\n\n${'x'.repeat(25_000)}\n\n## Tail\n\nmore` + const result = renderDocsPageResult(large) + expect(result).toContain('This documentation page is large') + expect(result).toContain('- Big (~') + expect(result).toContain('- Tail (~') + }) + + it('returns the requested section content when found', () => { + const result = renderDocsPageResult(SAMPLE, 'Job kinds') + expect(result).toContain('## Job kinds') + expect(result).toContain('Some kinds.') + }) + + it('returns the outline with a note when the requested section is missing', () => { + const result = renderDocsPageResult(SAMPLE, 'Does not exist') + expect(result).toContain('No section matching "Does not exist" was found') + expect(result).toContain('- Jobs (~') + }) +}) + +// Mirrors the llms-full.txt layout: a corpus preamble, then per-page blocks each +// introduced by a `---` + `## ` lead-in followed by a `Source:` line. +const SAMPLE_FULL = `# Windmill + +> Preamble blurb that precedes the first Source line and must be ignored. + +## Browser automation + +Source: https://www.windmill.dev/docs/advanced/browser_automation + +# Browser automation + +By default, a worker group named \`reports\` handles jobs with the \`chromium\` tag. +The chromium binary will be available on these workers at /usr/bin/chromium. +You can disable the sandbox by passing the --no-sandbox flag. + +--- + +## Worker groups + +Source: https://www.windmill.dev/docs/core_concepts/worker_groups + +# Worker groups + +Worker groups let you assign tags to workers. +Set the chromium tag on a worker so it can run browser jobs. + +--- + +## Scheduling + +Source: https://www.windmill.dev/docs/core_concepts/scheduling + +# Scheduling + +Use cron expressions to schedule scripts and flows. +` + +describe('parseDocsFullText', () => { + it('splits the corpus into pages keyed by Source URL, dropping the preamble', () => { + const pages = parseDocsFullText(SAMPLE_FULL) + expect(pages.map((p) => p.url)).toEqual([ + 'https://www.windmill.dev/docs/advanced/browser_automation', + 'https://www.windmill.dev/docs/core_concepts/worker_groups', + 'https://www.windmill.dev/docs/core_concepts/scheduling' + ]) + }) + + it('uses each page first heading as its title', () => { + const pages = parseDocsFullText(SAMPLE_FULL) + expect(pages.map((p) => p.title)).toEqual([ + 'Browser automation', + 'Worker groups', + 'Scheduling' + ]) + }) + + it('strips the trailing category lead-in so it is not mis-attributed to the previous page', () => { + const pages = parseDocsFullText(SAMPLE_FULL) + const browser = pages.find((p) => p.url.endsWith('/browser_automation')) + // "## Worker groups" introduces the *next* page and must not leak into this body. + expect(browser?.body).not.toContain('Worker groups') + expect(browser?.body).not.toContain('---') + }) +}) + +describe('searchDocsPages', () => { + const pages = parseDocsFullText(SAMPLE_FULL) + + it('ranks the page with more occurrences of the term first', () => { + const results = searchDocsPages(pages, 'chromium') + expect(results.map((r) => r.url)).toEqual([ + 'https://www.windmill.dev/docs/advanced/browser_automation', + 'https://www.windmill.dev/docs/core_concepts/worker_groups' + ]) + expect(results[0].snippets.length).toBeGreaterThan(0) + expect(results[0].snippets.join('\n')).toContain('chromium') + }) + + it('prefers pages that cover every query term over partial matches', () => { + // Only browser_automation mentions both "chromium" and "sandbox". + const results = searchDocsPages(pages, 'chromium sandbox') + expect(results.map((r) => r.url)).toEqual([ + 'https://www.windmill.dev/docs/advanced/browser_automation' + ]) + }) + + it('returns nothing when no term matches', () => { + expect(searchDocsPages(pages, 'kubernetes helm chart')).toEqual([]) + }) + + it('respects the maxPages cap', () => { + const results = searchDocsPages(pages, 'worker', { maxPages: 1 }) + expect(results.length).toBe(1) + }) +}) + +describe('makeSnippet', () => { + it('returns short lines unchanged after collapsing whitespace', () => { + expect(makeSnippet(' hello world ', ['world'], 200)).toBe('hello world') + }) + + it('windows a long line around the first matched term with ellipses', () => { + const line = `${'a '.repeat(200)}NEEDLE${' b'.repeat(200)}` + const snippet = makeSnippet(line, ['needle'], 60) + expect(snippet.length).toBeLessThanOrEqual(62) // 60 + two ellipsis chars + expect(snippet.toLowerCase()).toContain('needle') + expect(snippet.startsWith('…')).toBe(true) + expect(snippet.endsWith('…')).toBe(true) + }) +}) + +describe('formatDocsSearchResults', () => { + it('renders Source URLs, snippet bullets and a citation instruction', () => { + const results = searchDocsPages(parseDocsFullText(SAMPLE_FULL), 'chromium') + const rendered = formatDocsSearchResults('chromium', results) + expect(rendered).toContain('Source: https://www.windmill.dev/docs/advanced/browser_automation') + expect(rendered).toContain(' - ') + expect(rendered).toContain('Cite the exact "Source" URL') + }) + + it('returns a no-match message when there are no results', () => { + expect(formatDocsSearchResults('zzz', [])).toContain('No documentation pages matched "zzz"') + }) +}) + +const SAMPLE_INDEX = `# Windmill + +> Blurb. + +## Documentation structure + +### Core concepts +- [AI agents](https://www.windmill.dev/docs/core_concepts/ai_agents.md): How do I build AI agents in Windmill? Add agent steps to flows. Connect to OpenAI, Anthropic and more. +- [Retries](https://www.windmill.dev/docs/flows/retries.md): How do I retry a failing flow step automatically with exponential backoff? +- [Persistent storage](https://www.windmill.dev/docs/core_concepts/persistent_storage/within_windmill.md): How do I persist state between runs in Windmill? +` + +describe('parseDocsIndex', () => { + it('parses index entries into title, url and description', () => { + const entries = parseDocsIndex(SAMPLE_INDEX) + expect(entries).toHaveLength(3) + expect(entries[0]).toEqual({ + title: 'AI agents', + url: 'https://www.windmill.dev/docs/core_concepts/ai_agents.md', + description: + 'How do I build AI agents in Windmill? Add agent steps to flows. Connect to OpenAI, Anthropic and more.' + }) + }) + + it('ignores lines that are not docs links', () => { + expect(parseDocsIndex('## Heading\n> blurb\nplain text')).toEqual([]) + }) +}) + +describe('searchDocsIndex', () => { + const entries = parseDocsIndex(SAMPLE_INDEX) + + it('surfaces a named feature from its title/description when body grep would miss it', () => { + // The branch-centric phrasing a model used that failed body search; the + // index entry still matches on "agent"/"LLM"-adjacent terms. + const results = searchDocsIndex(entries, 'AI agent step decide') + expect(results[0].url).toBe('https://www.windmill.dev/docs/core_concepts/ai_agents.md') + expect(results[0].snippets[0]).toContain('agent steps') + }) + + it('ranks title matches above description-only matches', () => { + const results = searchDocsIndex(entries, 'retries') + expect(results[0].url).toBe('https://www.windmill.dev/docs/flows/retries.md') + }) + + it('returns nothing when no term matches', () => { + expect(searchDocsIndex(entries, 'kubernetes helm')).toEqual([]) + }) +}) + +describe('mergeDocsSearchResults', () => { + const body: ReturnType = [ + { url: 'https://www.windmill.dev/docs/openflow', title: 'OpenFlow', score: 10, snippets: ['x'] } + ] + const index: ReturnType = [ + // Same page as a body hit but as the index `.md` URL — must dedupe. + { + url: 'https://www.windmill.dev/docs/openflow.md', + title: 'OpenFlow', + score: 5, + snippets: ['desc'] + }, + { url: 'https://www.windmill.dev/docs/flows/retries.md', title: 'Retries', score: 4, snippets: ['desc'] } + ] + + it('keeps body results first and appends index-only matches, deduping by canonical URL', () => { + const merged = mergeDocsSearchResults(body, index) + expect(merged.map((r) => r.url)).toEqual([ + 'https://www.windmill.dev/docs/openflow', + 'https://www.windmill.dev/docs/flows/retries.md' + ]) + }) + + it('respects the maxPages cap', () => { + expect(mergeDocsSearchResults(body, index, 1)).toHaveLength(1) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/docs/core.ts b/frontend/src/lib/components/copilot/chat/docs/core.ts new file mode 100644 index 0000000000..0329d2025d --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/docs/core.ts @@ -0,0 +1,863 @@ +import type { Tool } from '../shared' +import type { ChatCompletionTool } from 'openai/resources/index.mjs' + +const DOCS_ORIGIN = 'https://www.windmill.dev' +const LLMS_TXT_URL = `${DOCS_ORIGIN}/llms.txt` +const LLMS_FULL_TXT_URL = `${DOCS_ORIGIN}/llms-full.txt` +const CACHE_TTL_MS = 15 * 60 * 1000 +// Above this size, return an outline of the page's headings instead of the full +// content, prompting the model to request a specific section. +const FULL_PAGE_CHAR_LIMIT = 20_000 + +// search_docs result caps — keep the returned payload small (the whole point of +// search vs. dumping the index or full pages is token economy). +const SEARCH_MAX_PAGES = 8 +const SEARCH_MAX_SNIPPETS_PER_PAGE = 3 +const SEARCH_MAX_SNIPPET_CHARS = 200 + +interface CacheEntry { + expiresAt: number + promise: Promise +} + +let llmsTxtCache: CacheEntry | undefined +let llmsFullTxtCache: CacheEntry | undefined +const pageCache = new Map() + +/** + * Fetches the docs index (llms.txt) listing every documentation page. Cached at + * module level with a TTL so repeated tool calls within a session reuse it. + */ +export async function fetchDocsIndex(): Promise { + const now = Date.now() + if (llmsTxtCache && llmsTxtCache.expiresAt > now) { + return llmsTxtCache.promise + } + + const promise = fetchText(LLMS_TXT_URL).catch((error) => { + // Drop the failed promise from the cache so the next call retries. + if (llmsTxtCache?.promise === promise) { + llmsTxtCache = undefined + } + throw error + }) + llmsTxtCache = { expiresAt: now + CACHE_TTL_MS, promise } + return promise +} + +/** + * Fetches the full documentation corpus (llms-full.txt): every page concatenated + * into one document, each delimited by a `Source: ` line. ~2 MB. Cached at + * module level with a TTL. Mirrors fetchDocsIndex; used by search_docs to grep + * the whole corpus in a single fetch. + */ +export async function fetchDocsFullText(): Promise { + const now = Date.now() + if (llmsFullTxtCache && llmsFullTxtCache.expiresAt > now) { + return llmsFullTxtCache.promise + } + + const promise = fetchText(LLMS_FULL_TXT_URL).catch((error) => { + if (llmsFullTxtCache?.promise === promise) { + llmsFullTxtCache = undefined + } + throw error + }) + llmsFullTxtCache = { expiresAt: now + CACHE_TTL_MS, promise } + return promise +} + +/** + * Fetches a single docs page as raw markdown. `path` may be a full URL or a + * /docs/... path; it is normalized to a `.md` URL. Cached per resolved URL. + */ +export async function fetchDocsPage(path: string): Promise { + const url = normalizeDocsUrl(path) + const now = Date.now() + const cached = pageCache.get(url) + if (cached && cached.expiresAt > now) { + return cached.promise + } + + const promise = fetchText(url) + .then((content) => sanitizeDocsMarkdownLinks(content, url)) + .catch((error) => { + if (pageCache.get(url)?.promise === promise) { + pageCache.delete(url) + } + throw error + }) + pageCache.set(url, { expiresAt: now + CACHE_TTL_MS, promise }) + return promise +} + +async function fetchText(url: string): Promise { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Request to ${url} failed with status ${response.status}`) + } + return await response.text() +} + +/** + * Normalizes a user/model-supplied docs reference to a fully-qualified `.md` + * URL on the docs origin. Accepts: + * - `https://www.windmill.dev/docs/core_concepts/jobs` + * - `/docs/core_concepts/jobs.md` + * - `docs/core_concepts/jobs` + */ +export function normalizeDocsUrl(input: string): string { + let value = input.trim() + + if (/^https?:\/\//i.test(value)) { + // Strip the origin so we can re-anchor to DOCS_ORIGIN and normalize the path. + try { + const parsed = new URL(value) + value = parsed.pathname + } catch { + // Fall through and treat as a path. + } + } + + // Drop any query string or hash fragment. + value = value.split('#')[0].split('?')[0] + + if (!value.startsWith('/')) { + value = `/${value}` + } + + // Strip a trailing slash (but keep the leading one). + if (value.length > 1 && value.endsWith('/')) { + value = value.slice(0, -1) + } + + // Relative links inside the raw markdown reference docusaurus source files + // (e.g. `13_flow_branches.mdx`), but the published routes drop the numeric + // ordering prefixes and use `.md`. + value = stripDocsPathPrefixes(value) + if (value.endsWith('.mdx')) { + value = value.slice(0, -1) + } + + if (!value.endsWith('.md')) { + value = `${value}.md` + } + + return `${DOCS_ORIGIN}${value}` +} + +/** + * The canonical published URL a model should cite for a docs page (the `.md` + * fetch URL without the suffix), e.g. `https://www.windmill.dev/docs/flows/retries`. + */ +export function canonicalDocsPageUrl(path: string): string { + return normalizeDocsUrl(path).replace(/\.md$/i, '') +} + +/** + * Strips docusaurus numeric ordering prefixes (`13_`, `8-`) from each segment of + * a docs path so it matches the published route. Operates on the path only. + */ +function stripDocsPathPrefixes(path: string): string { + return path + .split('/') + .map((segment) => segment.replace(/^\d+[_-]/, '')) + .join('/') +} + +/** + * Rewrites relative/source-file doc links inside raw page markdown to canonical + * published URLs, so the model never echoes a docusaurus source path (e.g. + * `./13_flow_branches.mdx`) into its answer as a broken link. Resolves each link + * relative to the page it came from, strips numeric ordering prefixes, and drops + * the `.md`/`.mdx` extension. Non-doc links (external, images, anchors) are left + * untouched. + */ +export function sanitizeDocsMarkdownLinks(content: string, pageUrl: string): string { + return content.replace(/\]\(([^)\s]+?)(\s+"[^"]*")?\)/g, (match, target: string, title) => { + if (!/\.mdx?($|[#?])/i.test(target)) { + // Only rewrite links to docusaurus source files (.md/.mdx); leave + // images, external URLs and bare anchors untouched. + return match + } + if (/(^|\/)\.\.\//.test(target)) { + // `../` cross-directory links are authored against the docusaurus + // source tree, whose depth differs from the published URL, so strict + // resolution is unreliable. Leave them for the canonical-URL header to + // disambiguate rather than risk rewriting to a wrong path. + return match + } + let resolved: URL + try { + resolved = new URL(target, pageUrl) + } catch { + return match + } + if (resolved.origin !== DOCS_ORIGIN || !resolved.pathname.startsWith('/docs/')) { + return match + } + const pathname = stripDocsPathPrefixes(resolved.pathname).replace(/\.mdx?$/i, '') + return `](${DOCS_ORIGIN}${pathname}${resolved.hash}${title ?? ''})` + }) +} + +export interface DocsHeading { + level: number + title: string + /** Character offset of the start of the heading line within the document. */ + startIndex: number +} + +/** + * Parses the markdown headings (`#`–`####`) of a docs page, ignoring any + * heading-like lines that appear inside fenced code blocks (``` fences), which + * are common in docs pages (e.g. `# comment` inside a python sample). + */ +export function parseDocsHeadings(content: string): DocsHeading[] { + const headings: DocsHeading[] = [] + let offset = 0 + let inFence = false + let fenceMarker = '' + + const lines = content.split('\n') + for (const line of lines) { + const fence = matchFence(line) + if (fence) { + if (!inFence) { + inFence = true + fenceMarker = fence + } else if (line.trimStart().startsWith(fenceMarker)) { + inFence = false + fenceMarker = '' + } + offset += line.length + 1 + continue + } + + if (!inFence) { + const match = /^(#{1,4})\s+(.*\S)\s*$/.exec(line) + if (match) { + headings.push({ + level: match[1].length, + title: match[2].trim(), + startIndex: offset + }) + } + } + + offset += line.length + 1 + } + + return headings +} + +function matchFence(line: string): string | undefined { + const trimmed = line.trimStart() + const match = /^(`{3,}|~{3,})/.exec(trimmed) + return match ? match[1] : undefined +} + +/** + * Builds a human-readable outline of a page's headings, including an approximate + * character size for each section. Used when a page is too large to return whole. + */ +export function buildDocsOutline(content: string): string { + const headings = parseDocsHeadings(content) + if (headings.length === 0) { + return '(no markdown headings found on this page)' + } + + const lines = headings.map((heading, index) => { + const sectionEnd = sectionEndIndex(content, headings, index) + const approxChars = sectionEnd - heading.startIndex + const indent = ' '.repeat(Math.max(0, heading.level - 1)) + return `${indent}- ${heading.title} (~${approxChars} chars)` + }) + + return lines.join('\n') +} + +function sectionEndIndex(content: string, headings: DocsHeading[], index: number): number { + const heading = headings[index] + // A section ends at the next heading of the same or higher (shallower) level. + for (let i = index + 1; i < headings.length; i++) { + if (headings[i].level <= heading.level) { + return headings[i].startIndex + } + } + return content.length +} + +/** Normalizes a heading title for tolerant, case/punctuation-insensitive matching. */ +function normalizeHeadingTitle(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() +} + +/** + * Extracts the content of the section whose heading matches `section`, from the + * matching heading up to the next heading of the same or higher level. Matching + * is case-insensitive and tolerant of minor punctuation differences. Returns + * `undefined` when no heading matches. + */ +export function extractDocsSection(content: string, section: string): string | undefined { + const headings = parseDocsHeadings(content) + const target = normalizeHeadingTitle(section) + if (target.length === 0) { + return undefined + } + + let matchIndex = headings.findIndex( + (heading) => normalizeHeadingTitle(heading.title) === target + ) + if (matchIndex === -1) { + // Fall back to a contains match so "Result streaming" matches "Result". + matchIndex = headings.findIndex((heading) => + normalizeHeadingTitle(heading.title).includes(target) + ) + } + if (matchIndex === -1) { + return undefined + } + + const start = headings[matchIndex].startIndex + const end = sectionEndIndex(content, headings, matchIndex) + return content.slice(start, end).trim() +} + +const READ_DOCS_PAGE_TOOL: ChatCompletionTool = { + type: 'function', + function: { + name: 'read_docs_page', + description: + 'Fetch the raw markdown of a single Windmill documentation page. Provide the `path` (or full URL) of a page found via search_docs. If the page is large, this returns its list of section headings instead of the full content; call again with the `section` argument set to one of those headings to read that section.', + parameters: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'The docs page to read, as a path (e.g. /docs/core_concepts/jobs) or full URL (e.g. https://www.windmill.dev/docs/core_concepts/jobs).' + }, + section: { + type: 'string', + description: + 'Optional. A heading title from the page outline to read just that section instead of the full page.' + } + }, + required: ['path'] + } + } +} + +export const readDocsPageTool: Tool<{}> = { + def: READ_DOCS_PAGE_TOOL, + fn: async ({ args, toolId, toolCallbacks }) => { + const path = typeof args?.path === 'string' ? args.path : '' + const section = typeof args?.section === 'string' && args.section.trim() ? args.section : undefined + toolCallbacks.setToolStatus(toolId, { + content: section ? `Reading docs section "${section}"...` : 'Reading documentation page...' + }) + try { + if (!path.trim()) { + return 'No documentation page path was provided. Provide a `path` — e.g. a `Source` URL returned by search_docs.' + } + const content = await fetchDocsPage(path) + toolCallbacks.setToolStatus(toolId, { content: 'Read documentation page' }) + const canonicalUrl = canonicalDocsPageUrl(path) + const header = `Source page — cite this URL when referencing this page: ${canonicalUrl}\n\n` + return header + renderDocsPageResult(content, section) + } catch (error) { + toolCallbacks.setToolStatus(toolId, { + content: 'Error reading documentation page', + error: 'Error reading documentation page' + }) + console.error('Error reading documentation page:', error) + const errorMessage = + error instanceof Error ? error.message : 'An error occurred while reading the documentation page' + return `Failed to read documentation page: ${errorMessage}, pursuing with the user request...` + } + } +} + +/** + * Decides what to return for read_docs_page: a requested section, the full page, + * or an outline asking the model to pick a section. + */ +export function renderDocsPageResult(content: string, section?: string): string { + if (section) { + const extracted = extractDocsSection(content, section) + if (extracted !== undefined) { + return extracted + } + return [ + `No section matching "${section}" was found on this page. Available sections:`, + '', + buildDocsOutline(content) + ].join('\n') + } + + // Gate on the page body only; the caller may prepend a short "Source page" + // header, so the returned payload can exceed this limit by that header's + // length. This threshold only decides whole-page vs. outline, so the small + // overshoot is immaterial. + if (content.length <= FULL_PAGE_CHAR_LIMIT) { + return content + } + + return [ + 'This documentation page is large. Below is its list of sections with approximate sizes.', + 'Call read_docs_page again with the same path and a `section` set to one of these headings to read that section.', + '', + buildDocsOutline(content) + ].join('\n') +} + +// --------------------------------------------------------------------------- +// Full-text docs search (search_docs) +// +// Discovery primitive for the `search` ask variant: instead of dumping the whole +// llms.txt index, grep the full corpus (llms-full.txt) for the user's keywords +// and return only small matching snippets plus each page's `Source:` URL. The +// model then cites that URL directly or passes it to read_docs_page for more. +// --------------------------------------------------------------------------- + +const SOURCE_LINE_RE = /^Source:\s*(\S+)\s*$/ +// In llms-full.txt every page's `Source:` line is preceded by a category-header +// lead-in: `...page body...\n\n---\n\n## \n\nSource: `. Splitting +// on `Source:` lines leaves that lead-in on the *previous* page, so strip a +// trailing `---` + level-2-heading block to avoid mis-attributing the next +// page's category title to the previous page. +const TRAILING_LEAD_IN_RE = /\n+-{3,}[ \t]*\n+#{2}[ \t]+.*[ \t]*\n*$/ + +export interface DocsFullPage { + url: string + title: string + body: string +} + +export interface DocsSearchResult { + url: string + title: string + /** Higher = more relevant. Distinct query terms matched dominate raw occurrences. */ + score: number + snippets: string[] +} + +/** + * Splits the llms-full.txt corpus into per-page records keyed by the `Source:` + * URL. Content before the first `Source:` line (the corpus preamble) is dropped. + */ +export function parseDocsFullText(fullText: string): DocsFullPage[] { + const pages: DocsFullPage[] = [] + let url: string | undefined + let buffer: string[] = [] + + const flush = () => { + if (url === undefined) { + return + } + const body = buffer.join('\n').replace(TRAILING_LEAD_IN_RE, '').trim() + if (body.length > 0) { + pages.push({ url, title: firstHeading(body) ?? url, body }) + } + } + + for (const line of fullText.split('\n')) { + const match = SOURCE_LINE_RE.exec(line) + if (match) { + flush() + url = match[1] + buffer = [] + continue + } + if (url !== undefined) { + buffer.push(line) + } + } + flush() + return pages +} + +function firstHeading(body: string): string | undefined { + for (const line of body.split('\n')) { + const match = /^#{1,6}\s+(.*\S)\s*$/.exec(line) + if (match) { + return match[1].trim() + } + } + return undefined +} + +/** + * Ranks docs pages for a keyword query. The query is split into distinct terms; + * a page's score is `distinctTermsMatched` (dominant) then total occurrences. + * Pages covering every term are preferred over partial matches. Each result + * carries up to `maxSnippetsPerPage` of its most term-dense lines. + */ +export function searchDocsPages( + pages: DocsFullPage[], + query: string, + opts: { maxPages?: number; maxSnippetsPerPage?: number; maxSnippetChars?: number } = {} +): DocsSearchResult[] { + const maxPages = opts.maxPages ?? SEARCH_MAX_PAGES + const maxSnippetsPerPage = opts.maxSnippetsPerPage ?? SEARCH_MAX_SNIPPETS_PER_PAGE + const maxSnippetChars = opts.maxSnippetChars ?? SEARCH_MAX_SNIPPET_CHARS + + const terms = tokenizeQuery(query) + if (terms.length === 0) { + return [] + } + + interface Scored extends DocsSearchResult { + distinctTerms: number + order: number + } + const scored: Scored[] = [] + + pages.forEach((page, order) => { + const lowerBody = page.body.toLowerCase() + let distinctTerms = 0 + let occurrences = 0 + for (const term of terms) { + const count = countOccurrences(lowerBody, term) + if (count > 0) { + distinctTerms += 1 + occurrences += count + } + } + if (distinctTerms === 0) { + return + } + scored.push({ + url: page.url, + title: page.title, + // distinctTerms dominates so a page matching all terms always outranks + // one matching fewer, regardless of raw occurrence counts. + score: distinctTerms * 1_000_000 + occurrences, + distinctTerms, + order, + snippets: selectSnippets(page.body, terms, maxSnippetsPerPage, maxSnippetChars) + }) + }) + + // Prefer pages that cover every query term; fall back to partial matches only + // when nothing covers all of them. + const fullCoverage = scored.filter((entry) => entry.distinctTerms === terms.length) + const pool = fullCoverage.length > 0 ? fullCoverage : scored + + pool.sort((a, b) => b.score - a.score || a.order - b.order) + + return pool + .slice(0, maxPages) + .map(({ url, title, score, snippets }) => ({ url, title, score, snippets })) +} + +/** Splits a query into distinct, lowercased, non-empty terms. */ +function tokenizeQuery(query: string): string[] { + return Array.from( + new Set( + query + .toLowerCase() + .split(/\s+/) + .map((term) => term.trim()) + .filter((term) => term.length > 0) + ) + ) +} + +function countOccurrences(haystack: string, needle: string): number { + if (needle.length === 0) { + return 0 + } + let count = 0 + let index = haystack.indexOf(needle) + while (index !== -1) { + count += 1 + index = haystack.indexOf(needle, index + needle.length) + } + return count +} + +/** + * Picks the most term-dense lines of a page body as snippets, in document order, + * deduped, each trimmed to `maxChars` around the first matched term. + */ +function selectSnippets( + body: string, + terms: string[], + maxSnippets: number, + maxChars: number +): string[] { + interface LineHit { + text: string + distinct: number + order: number + } + const hits: LineHit[] = [] + + body.split('\n').forEach((line, order) => { + const lower = line.toLowerCase() + let distinct = 0 + for (const term of terms) { + if (lower.includes(term)) { + distinct += 1 + } + } + if (distinct === 0) { + return + } + const text = makeSnippet(line, terms, maxChars) + if (text.length > 0) { + hits.push({ text, distinct, order }) + } + }) + + hits.sort((a, b) => b.distinct - a.distinct || a.order - b.order) + + const seen = new Set() + const result: string[] = [] + for (const hit of hits) { + if (seen.has(hit.text)) { + continue + } + seen.add(hit.text) + result.push(hit.text) + if (result.length >= maxSnippets) { + break + } + } + return result +} + +/** + * Collapses a matched line to a single-line snippet of at most `maxChars`, + * windowed around the first matched term (with ellipses) when the line is long. + */ +export function makeSnippet(line: string, terms: string[], maxChars: number): string { + const collapsed = line.replace(/\s+/g, ' ').trim() + if (collapsed.length <= maxChars) { + return collapsed + } + + const lower = collapsed.toLowerCase() + let firstIndex = -1 + for (const term of terms) { + const index = lower.indexOf(term) + if (index !== -1 && (firstIndex === -1 || index < firstIndex)) { + firstIndex = index + } + } + if (firstIndex === -1) { + return `${collapsed.slice(0, maxChars).trimEnd()}…` + } + + const start = Math.max(0, firstIndex - Math.floor(maxChars / 3)) + const end = Math.min(collapsed.length, start + maxChars) + const prefix = start > 0 ? '…' : '' + const suffix = end < collapsed.length ? '…' : '' + return `${prefix}${collapsed.slice(start, end).trim()}${suffix}` +} + +export interface DocsIndexEntry { + title: string + url: string + description: string +} + +// A line in llms.txt: `- [Title](https://.../page.md): question-phrased description`. +const INDEX_ENTRY_RE = /^\s*-\s*\[([^\]]+)\]\(([^)\s]+)\)\s*:?\s*(.*)$/ + +/** Parses the llms.txt index into per-page entries (title, URL, description). */ +export function parseDocsIndex(indexText: string): DocsIndexEntry[] { + const entries: DocsIndexEntry[] = [] + for (const line of indexText.split('\n')) { + const match = INDEX_ENTRY_RE.exec(line) + if (!match) { + continue + } + const [, title, url, description] = match + if (!url.includes('/docs/')) { + continue + } + entries.push({ title: title.trim(), url: url.trim(), description: description.trim() }) + } + return entries +} + +/** + * Ranks index entries for a query by matching its terms against each entry's + * title and description. Title matches weigh more than description matches. + * The description becomes the result's single snippet. This recovers the + * "named feature" discovery that full-text grep misses when the model searches + * the wrong keywords (e.g. finding "AI agents" for "LLM decides which script"). + */ +export function searchDocsIndex( + entries: DocsIndexEntry[], + query: string, + opts: { maxPages?: number } = {} +): DocsSearchResult[] { + const maxPages = opts.maxPages ?? SEARCH_MAX_PAGES + const terms = tokenizeQuery(query) + if (terms.length === 0) { + return [] + } + + interface Scored extends DocsSearchResult { + distinctTerms: number + order: number + } + const scored: Scored[] = [] + + entries.forEach((entry, order) => { + const title = entry.title.toLowerCase() + const description = entry.description.toLowerCase() + let distinctTerms = 0 + let score = 0 + for (const term of terms) { + const inTitle = title.includes(term) + const inDescription = description.includes(term) + if (inTitle || inDescription) { + distinctTerms += 1 + score += (inTitle ? 5 : 0) + (inDescription ? 1 : 0) + } + } + if (distinctTerms === 0) { + return + } + scored.push({ + url: entry.url, + title: entry.title, + score: distinctTerms * 1_000_000 + score, + distinctTerms, + order, + snippets: entry.description ? [entry.description] : [] + }) + }) + + const fullCoverage = scored.filter((entry) => entry.distinctTerms === terms.length) + const pool = fullCoverage.length > 0 ? fullCoverage : scored + pool.sort((a, b) => b.score - a.score || a.order - b.order) + + return pool + .slice(0, maxPages) + .map(({ url, title, score, snippets }) => ({ url, title, score, snippets })) +} + +/** Strips the `.md` suffix and trailing slash so index/body URLs dedupe. */ +function canonicalSearchUrl(url: string): string { + return url.replace(/\.md$/i, '').replace(/\/$/, '') +} + +/** + * Merges full-text (body) results with index-description results. Body matches + * come first (concrete content hits), then index-only matches fill remaining + * slots — so a named feature surfaced only by its index entry still appears even + * when body grep landed on the wrong pages. + */ +export function mergeDocsSearchResults( + bodyResults: DocsSearchResult[], + indexResults: DocsSearchResult[], + maxPages = SEARCH_MAX_PAGES +): DocsSearchResult[] { + const seen = new Set(bodyResults.map((result) => canonicalSearchUrl(result.url))) + const merged = [...bodyResults] + for (const entry of indexResults) { + const key = canonicalSearchUrl(entry.url) + if (seen.has(key)) { + continue + } + seen.add(key) + merged.push(entry) + } + return merged.slice(0, maxPages) +} + +/** Renders search results as the string returned to the model. */ +export function formatDocsSearchResults(query: string, results: DocsSearchResult[]): string { + if (results.length === 0) { + return `No documentation pages matched "${query}". Try fewer or more general keywords (a single distinctive term often works best).` + } + + const blocks = results.map((result) => { + const lines = [`## ${result.title}`, `Source: ${result.url}`] + for (const snippet of result.snippets) { + lines.push(` - ${snippet}`) + } + return lines.join('\n') + }) + + return [ + `Found ${results.length} documentation page(s) matching "${query}", most relevant first:`, + '', + blocks.join('\n\n'), + '', + 'Cite the exact "Source" URL when referencing a page. If these snippets are not enough, call read_docs_page with a Source URL to read the full page or a section.' + ].join('\n') +} + +const SEARCH_DOCS_TOOL: ChatCompletionTool = { + type: 'function', + function: { + name: 'search_docs', + description: + 'Full-text search across the entire Windmill documentation. Provide one or more keywords; returns the most relevant docs pages, each with its Source URL and short matching snippets. Use this FIRST to find relevant pages by their content (a flag, function, error message, config key or concept). If the snippets answer the question, answer directly; otherwise call read_docs_page with a returned Source URL to read more.', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: + 'Keywords to search for in the documentation body, e.g. "chromium worker tag" or "retry exponential backoff". Fewer, more distinctive words match better.' + } + }, + required: ['query'] + } + } +} + +export const searchDocsTool: Tool<{}> = { + def: SEARCH_DOCS_TOOL, + fn: async ({ args, toolId, toolCallbacks }) => { + const query = typeof args?.query === 'string' ? args.query.trim() : '' + toolCallbacks.setToolStatus(toolId, { + content: query ? `Searching documentation for "${query}"...` : 'Searching documentation...' + }) + try { + if (!query) { + return 'No search query was provided. Provide a `query` of one or more keywords.' + } + const bodyResults = searchDocsPages(parseDocsFullText(await fetchDocsFullText()), query, { + maxPages: 5 + }) + // Also match the (small) index titles/descriptions to surface named + // features that body grep misses. Best-effort: a failed index fetch + // still leaves full-text results. + let indexResults: DocsSearchResult[] = [] + try { + indexResults = searchDocsIndex(parseDocsIndex(await fetchDocsIndex()), query, { + maxPages: 4 + }) + } catch (indexError) { + console.error('Error searching documentation index:', indexError) + } + const results = mergeDocsSearchResults(bodyResults, indexResults) + toolCallbacks.setToolStatus(toolId, { + content: + results.length > 0 ? `Found ${results.length} matching page(s)` : 'No matching pages found' + }) + return formatDocsSearchResults(query, results) + } catch (error) { + toolCallbacks.setToolStatus(toolId, { + content: 'Error searching documentation', + error: 'Error searching documentation' + }) + console.error('Error searching documentation:', error) + const errorMessage = + error instanceof Error ? error.message : 'An error occurred while searching the documentation' + return `Failed to search documentation: ${errorMessage}, pursuing with the user request...` + } + } +} diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 724af63e42..284e7aafda 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -72,6 +72,7 @@ import { type ToolCallbacks, type ToolDisplayAction } from '../shared' +import { searchDocsTool, readDocsPageTool } from '../docs/core' import type { ContextElement } from '../context' import { getDatatableTools } from '../datatableTools' import { UserDraft } from '$lib/userDraft.svelte' @@ -677,6 +678,13 @@ Rules: : '' } +Documentation: +- Use search_docs to look up how a Windmill feature works in the official documentation (a flag, concept, function, or "does Windmill support X") instead of guessing about product behavior. It returns matching doc snippets with their Source URL; call read_docs_page with a Source URL to read the full page (or a section, if it returns headings). Cite the Source URL when you rely on it. +- Complete your response with precisions about how it works based on the documentation. Also drop a link to the relevant documentation if possible. +- If the user asks about something that you are unsure about, say that you are not sure about the answer and suggest to ask the question to the windmill team. +- If the first search returns nothing useful, retry with different or broader keywords before giving up. +- If the documentation does not cover the user's question, say so clearly rather than inventing an answer, and suggest asking the Windmill team. + Flows: - read_workspace_item returns compact flow JSON. Inline script bodies appear as "inline_script.". - Use read_flow_module_code and set_flow_module_code for inline script bodies. @@ -1494,6 +1502,8 @@ export const globalTools: Tool<{}>[] = [ } }, createSearchHubScriptsTool(false), + searchDocsTool, + readDocsPageTool, { def: createToolDef( askUserQuestionSchema, diff --git a/frontend/src/lib/components/copilot/chat/navigator/core.ts b/frontend/src/lib/components/copilot/chat/navigator/core.ts index 6da158e6de..945f994862 100644 --- a/frontend/src/lib/components/copilot/chat/navigator/core.ts +++ b/frontend/src/lib/components/copilot/chat/navigator/core.ts @@ -4,6 +4,7 @@ import type { ChatCompletionUserMessageParam } from 'openai/resources/index.mjs' import { createSearchWorkspaceTool, createGetRunnableDetailsTool, type Tool } from '../shared' +import { readDocsPageTool, searchDocsTool } from '../docs/core' import { ResourceService } from '$lib/gen' import { workspaceStore } from '$lib/stores' import { get } from 'svelte/store' @@ -16,13 +17,14 @@ Windmill is an open-source developer platform for building internal tools, API i You have access to these tools: 1. View current buttons and inputs on the page (get_triggerable_components) 2. Execute buttons and inputs (trigger_component) -3. Get documentation for user requests (get_documentation) -4. Change the AI mode to the one specified (change_mode) -5. Search for scripts and flows in the workspace (search_workspace) -6. Get detailed information about a specific script or flow (get_runnable_details) +3. Search the documentation (search_docs) +4. Read a documentation page (read_docs_page) +5. Change the AI mode to the one specified (change_mode) +6. Search for scripts and flows in the workspace (search_workspace) +7. Get detailed information about a specific script or flow (get_runnable_details) INSTRUCTIONS: -- When users ask about application features or concepts, first use get_documentation internally to retrieve accurate information about how to fulfill the user's request. +- When users ask about application features or concepts, first use search_docs (with a few keywords) and, when a snippet is not enough, read_docs_page on a returned Source URL to retrieve accurate information about how to fulfill the user's request. - Then immediately use the available tools to guide the user through the application. Do not wait for the user's confirmation before taking action. - If you detect a confirmation modal that needs user confirmation, stop the navigation and let the user know that the action is pending confirmation. - Use get_triggerable_components to understand available options, and then trigger the components using trigger_component. Then wait a moment before rescanning the current page, and then continue with the next step. Do this 5 times max. @@ -59,30 +61,12 @@ When you complete the user's request, do not say "I created..." or "I updated... Example of good behavior: - User: "How can I set my AI providers?" -- You: +- You: - You: - You: - You: "" ` -const GET_DOCUMENTATION_TOOL: ChatCompletionTool = { - type: 'function', - function: { - name: 'get_documentation', - description: 'Get the documentation for the user request', - parameters: { - type: 'object', - properties: { - request: { - type: 'string', - description: 'The user request' - } - }, - required: ['request'] - } - } -} - // Tool definitions const GET_TRIGGERABLE_COMPONENTS_TOOL: ChatCompletionTool = { type: 'function', @@ -234,47 +218,6 @@ function triggerComponent(args: { id: string; value: string }): string { } } -async function getDocumentation(args: { request: string }): Promise { - const retrieval = await fetch('/api/inkeep', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - query: args.request - }) - }) - - if (!retrieval.ok) { - const errorText = await retrieval.text() - throw new Error(errorText) - } - - const data = await retrieval.json() - if (!data.choices?.[0]?.message?.content) { - return 'No documentation found for this request' - } - - // Parse the raw response - const raw = data.choices[0].message.content - const parsed = JSON.parse(raw) - - // Clean up the response to include only essential information - if (parsed.content && Array.isArray(parsed.content)) { - const cleanedContent = parsed.content.map((item: any) => ({ - title: item.title, - url: item.url, - content: item.source?.content.map((c: any) => c.text).join('\n') || [] - })) - // Limit the response to 30000 characters max - const stringified = JSON.stringify({ content: cleanedContent }).slice(0, 30000) - - return stringified - } - - return data.choices[0].message.content -} - async function getAvailableResources(args: { resource_type: string }): Promise { const resources = await ResourceService.listResource({ workspace: get(workspaceStore) as string, @@ -318,27 +261,6 @@ const getCurrentPageNameTool: Tool<{}> = { } } -export const getDocumentationTool: Tool<{}> = { - def: GET_DOCUMENTATION_TOOL, - fn: async ({ args, toolId, toolCallbacks }) => { - toolCallbacks.setToolStatus(toolId, { content: 'Getting documentation...' }) - try { - const docResult = await getDocumentation(args) - toolCallbacks.setToolStatus(toolId, { content: 'Retrieved documentation' }) - return docResult - } catch (error) { - toolCallbacks.setToolStatus(toolId, { - content: 'Error getting documentation', - error: 'Error getting documentation' - }) - console.error('Error getting documentation:', error) - const errorMessage = - error instanceof Error ? error.message : 'An error occurred while getting documentation' - return `Failed to get documentation: ${errorMessage}, pursuing with the user request...` - } - } -} - const getAvailableResourcesTool: Tool<{}> = { def: GET_AVAILABLE_RESOURCES_TOOL, fn: async ({ args, toolId, toolCallbacks }) => { @@ -361,7 +283,8 @@ const getAvailableResourcesTool: Tool<{}> = { export const navigatorTools: Tool<{}>[] = [ getTriggerableComponentsTool, triggerComponentTool, - getDocumentationTool, + searchDocsTool, + readDocsPageTool, getCurrentPageNameTool, getAvailableResourcesTool, createSearchWorkspaceTool(), From b67c8cf42b477575fc1bc448058ec0d3b7e54fee Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:32:26 +0200 Subject: [PATCH 073/246] fix(frontend): render Modal2 dialogs above the AI chat panel (#9636) Co-authored-by: Claude Opus 4.8 (1M context) --- frontend/src/lib/components/common/modal/Modal2.svelte | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/common/modal/Modal2.svelte b/frontend/src/lib/components/common/modal/Modal2.svelte index fcce008ad7..c00bf758d0 100644 --- a/frontend/src/lib/components/common/modal/Modal2.svelte +++ b/frontend/src/lib/components/common/modal/Modal2.svelte @@ -8,6 +8,8 @@ import { X } from 'lucide-svelte' import List from '$lib/components/common/layout/List.svelte' import { fade } from 'svelte/transition' + import { zIndexes } from '$lib/zIndexes' + import { chatState } from '$lib/components/copilot/chat/sharedChatState.svelte' interface Props { title: string @@ -85,6 +87,11 @@ function fadeFast(node: HTMLElement) { return fade(node, { duration: 200 }) } + + // Elevate above the AI chat panel (zIndexes.aiChat) while chat is open so + // the dialog isn't hidden behind it; otherwise keep the default modal + // stacking just above disposables (zIndexes.disposables). + const overlayZIndex = $derived(chatState.size > 0 ? zIndexes.aiChat + 1 : zIndexes.disposables + 10) @@ -92,7 +99,8 @@ {#if isOpen}
From e09cd5862cb636e143027fe8d9a5be9c7097b031 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 17 Jun 2026 15:49:36 +0200 Subject: [PATCH 074/246] feat: per-user draft review & deploy page (gating, badges, rename, raw-app deploy fixes) (#9625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: per-user draft gating, badges and rename display on deploy page Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): don't strike the path when a draft adds a summary to a summary-less item Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): don't strike draft-only items' auto-generated path against the pretty path Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): deploy raw-app drafts from top-level files so the bundle isn't dropped Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(frontend): share raw-app source→draft-value projection across chat and deploy page Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): deploy renamed/new flow, app and raw-app drafts at draft_path, not the temp storage path Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(frontend): add a design-system Checkbox and use it for deploy-page row/select-all checkboxes Co-Authored-By: Claude Opus 4.8 (1M context) * feat: "Show all drafts" toggle on the deploy-drafts page Replace the deploy-drafts page's legacy-hiding "Only my drafts" toggle with a "Show all drafts" toggle that switches the listing scope between the current user's own drafts (+ legacy no-owner rows) and every user's drafts in the workspace. Backend (`drafts.rs`, `openapi.yaml`): - `/drafts/list` gains an `all_users` query param that drops the owner filter, and a per-row `mine` flag (own draft or legacy no-owner row). `DISTINCT ON` now prefers the user's own row, then the legacy row, then another user's, so `mine`/`legacy_draft` describe the kept row. Frontend (`CompareDrafts.svelte`, `workspaceDrafts.svelte.ts`): - "Show all drafts" toggle (default off). The all-users superset is fetched lazily via the shared resource only while the toggle is on, so the page's fork draft-count (own drafts) is unaffected. - Other users' drafts are view-only: disabled checkbox + Discard with a "belongs to another user" tooltip; Show diff stays enabled. Selection, select-all and the deploy count only ever include the user's own drafts. The multi-user warning triangle shows on owned rows only. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(backend): gate all_users draft listing by read permission Addresses the PR review on the per-user deploy-drafts page: - `/drafts/list?all_users=true` previously had only `WHERE workspace_id = $1` with no read-permission check, so any non-operator could enumerate every draft's path, summary and authors — including items they can't read. Now rows the caller doesn't own (`mine = false`) are gated through `require_can_read_path` (the same gate `/drafts/get` uses) and dropped when unreadable; both its `NotFound` and `NotAuthorized` denials are treated as "not visible". - Skip the per-row `require_can_write_path` probe on those non-owned rows (they're never selectable — `isSelectable` requires `mine`): set `can_write = false` directly, removing a redundant N RLS write-probes when `all_users` is on. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): only confirm destructive draft discards on the deploy page Discarding a draft is non-destructive in every case except removing the last draft of a never-deployed item (`draft_only` with no other user's draft), which permanently deletes it. Confirm only that case; reverting a draft over a deployed item, or discarding your copy while another user still holds a draft, now runs immediately (the ⚠️ already signals the multi-user case). Drops the redundant "other users still have a draft" / "deployed version unaffected" confirmation branches. Harden the destructive check: it keyed off `otherDraftUsers()`, which subtracts `currentUsername`; while `$userStore.username` is unhydrated, your own draft looked like another user's, flipping a draft-only item to "non-destructive" and deleting it with no confirmation. Now: deployed counterpart → never destructive; `draft_only` with unknown `currentUsername` → treated as destructive (confirm). The delete modal also shows the friendly `draft_path` instead of the raw `draft_{uuid}` storage path. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): deploy low-code app drafts (value + summary persistence) A visual (low-code) app draft is autosaved as the *bare* App value (grid/theme/... plus a draft-only `draft_path`), not wrapped in { value, summary, policy } like script/flow drafts. The Review & Deploy page read `requestBody.value = d.value` — undefined for that shape — so deploying any low-code app draft (created or edited) sent no value and failed. Read the value from the draft object itself, strip the draft-only `draft_path` from it, and use that as the deploy path. Also persist the app summary, which was dropped entirely: the autosave stores the bare App value (the summary normally lives only in the `app` table column, set on deploy), so a draft never carried it — reopening a draft or deploying it lost the summary. Mirror the summary onto the autosaved App (like `draft_path`), read it back when loading a draft, and on deploy send it as the summary column while stripping it (and `draft_path`) from the deployed value so the value stays clean. Verified end-to-end: a new low-code app with a summary deploys at its pretty path with the summary set, content intact, and no draft_path/summary leaked into the deployed value; the draft is cleaned up. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- backend/windmill-api/openapi.yaml | 25 +- backend/windmill-api/src/drafts.rs | 108 +++++++- .../src/lib/components/CompareDrafts.svelte | 250 +++++++++++++----- frontend/src/lib/components/DraftBadge.svelte | 17 +- .../components/WorkspaceDeployLayout.svelte | 67 +++-- .../apps/editor/AppEditorHeader.svelte | 14 + frontend/src/lib/components/apps/types.ts | 9 + .../common/checkbox/Checkbox.svelte | 38 +++ .../lib/components/common/table/Row.svelte | 24 +- .../components/copilot/chat/global/core.ts | 33 +-- .../components/raw_apps/rawAppDraftValue.ts | 51 ++++ frontend/src/lib/rawAppDeploy.ts | 39 +-- frontend/src/lib/utils_draft_deploy.ts | 52 ++-- frontend/src/lib/workspaceDrafts.svelte.ts | 34 ++- .../(logged)/apps/edit/[...path]/+page.svelte | 12 +- 15 files changed, 583 insertions(+), 190 deletions(-) create mode 100644 frontend/src/lib/components/common/checkbox/Checkbox.svelte create mode 100644 frontend/src/lib/components/raw_apps/rawAppDraftValue.ts diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 784a5be78a..6bc1b560f2 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -7898,6 +7898,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 +7932,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: diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index c6146fb47a..ffa78b1358 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}, }; @@ -50,6 +50,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 @@ -59,7 +83,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 @@ -67,20 +93,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 { @@ -101,15 +165,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`; @@ -124,10 +208,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"# ) } diff --git a/frontend/src/lib/components/CompareDrafts.svelte b/frontend/src/lib/components/CompareDrafts.svelte index 7269e54a3b..24e554ee65 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,46 @@ })) ) + 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. + function isSelectable(item: Row): boolean { + return deploymentStatus[item.key]?.status !== 'deployed' && item.can_write && item.mine + } + + // 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 +216,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) @@ -197,29 +252,23 @@ }) $effect(() => { - if (!hasAutoSelected && items.length > 0) { - selectedItems = items - .filter((i) => deploymentStatus[i.key]?.status !== 'deployed') - .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) && deploymentStatus[i.key]?.status !== 'deployed' - ).length + visibleItems.filter((i) => selectedItems.includes(i.key) && isSelectable(i)).length ) let allSelected = $derived( - items.length > 0 && - items - .filter((i) => deploymentStatus[i.key]?.status !== 'deployed') - .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 }) { @@ -231,9 +280,7 @@ } function selectAll() { - selectedItems = items - .filter((i) => deploymentStatus[i.key]?.status !== 'deployed') - .map((i) => i.key) + selectedItems = visibleItems.filter(isSelectable).map((i) => i.key) } function deselectAll() { @@ -272,8 +319,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' } @@ -301,12 +349,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, @@ -323,6 +393,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, @@ -388,16 +464,33 @@
deploymentStatus[item.key]?.status !== 'deployed'} + 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}
@@ -443,28 +536,58 @@ {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'} + {@const discardBlock = blockedReason(draftItem)} @@ -503,23 +628,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/DraftBadge.svelte b/frontend/src/lib/components/DraftBadge.svelte index 46c24578a4..a3e6d5d048 100644 --- a/frontend/src/lib/components/DraftBadge.svelte +++ b/frontend/src/lib/components/DraftBadge.svelte @@ -29,6 +29,9 @@ workspace?: string itemKind?: UserDraftItemKind path?: string + /** Offer "Fork" alongside "View JSON" on other users' rows. The deploy + * page sets this false: forking a new item is meaningless there. */ + allowFork?: boolean } let { @@ -38,7 +41,8 @@ currentUsername = undefined, workspace = undefined, itemKind = undefined, - path = undefined + path = undefined, + allowFork = true }: Props = $props() // Authed user lands first; everyone else keeps the backend's ordering. @@ -163,7 +167,14 @@ {#if showBadge} - + + {#snippet trigger()} {#if orderedUsers.length > 0} @@ -244,7 +255,7 @@ View JSON - {#if !$userStore?.operator} + {#if allowFork && !$userStore?.operator} + + {#if draftItem.mine} + + {/if}
@@ -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/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/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 @@