From 9e3c0decf95378c66055d82215c15cd3bf4a69cb Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 16 Jun 2026 12:01:28 +0200 Subject: [PATCH 01/20] fix(frontend): seed detached user-draft handles so new-item drawers render (#9608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Add a variable" drawer (and other editors built on `UserDraft.useMany`) opened empty: for a brand-new item `editPath` is undefined so the spec path is empty, which routes through `useMany`'s empty-path branch. That branch handed out a `makeDetachedHandle()` whose cell was initialized to `undefined`, ignoring the spec's `defaultValue`. The editor binds its form behind `{#if current}` where `current = states[ws]?.draft`, so an undefined cell left the drawer with just the title and a Save button. Seed the detached handle with `defaultValue`, and re-seed it when the caller supplies a fresh `defaultValue` reference (reopening the drawer clones a new default) so a reopened editor starts clean instead of replaying the previous session's edits — the reference is stable within a session, so live edits are never clobbered. Also drop detached handles that fall out of the specs so they don't leak. Regression from #9351 (db-backed user drafts). Fixes WIN-2054 Co-authored-by: Claude Opus 4.8 (1M context) --- frontend/src/lib/userDraft.svelte.ts | 35 +++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 5b3bbb37a8..ab455050b0 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -477,6 +477,11 @@ export const UserDraft = { const handles = $state[]>([]) const acquired = new Set() const handleCache = new Map>() + // `defaultValue` reference last used to seed each detached (empty-path) + // handle. The reference is stable within an editing session but swapped + // for a fresh clone each time the caller restarts (e.g. reopening the + // new-item drawer) — so a change here means "re-seed", not "live edit". + const detachedSeeds = new Map() function reconcile() { const specs = getSpecs() @@ -493,10 +498,24 @@ export const UserDraft = { // `POST /drafts/update/kind/` (permanent "Save failed"). // Hand out a detached, local-only handle instead. if (!spec.path) { + seen.add(mk) let handle = handleCache.get(mk) + // Drop the cached handle when the caller hands in a fresh + // `defaultValue` reference (reopening the new-item drawer seeds a + // new clone) so the rebuilt handle re-seeds instead of replaying + // the previous session's edits. Stable reference within a session + // means live edits are never clobbered. + if (handle && detachedSeeds.get(mk) !== spec.defaultValue) { + handleCache.delete(mk) + handle = undefined + } if (!handle) { - handle = makeDetachedHandle() + // Seed with `defaultValue` so consumers (e.g. the new-variable + // drawer, whose path is empty until the user types one) get a + // populated cell to bind their form to instead of `undefined`. + handle = makeDetachedHandle(spec.defaultValue) handleCache.set(mk, handle) + detachedSeeds.set(mk, spec.defaultValue) } next.push(handle) continue @@ -530,6 +549,16 @@ export const UserDraft = { } } + // Detached handles (empty-path) live only in `handleCache` — they're + // never in `acquired`. Drop any that fell out of the specs so they + // don't leak and a later reappearance rebuilds from scratch. + for (const mk of [...handleCache.keys()]) { + if (!acquired.has(mk) && !seen.has(mk)) { + handleCache.delete(mk) + detachedSeeds.delete(mk) + } + } + // Skip no-op mutations (cached handles → reference-equal arrays). // `untrack` so this effect doesn't subscribe to its own `handles` // write — otherwise it self-loops (`effect_update_depth_exceeded`). @@ -705,8 +734,8 @@ function releaseEntry(mk: string): void { * `bind:` but is wired to nothing (no entry, no sync, no POSTs). For views * that bind an editor value with no draftable item behind it. */ -function makeDetachedHandle(): UserDraftHandle { - let val = $state(undefined) +function makeDetachedHandle(defaultValue?: V): UserDraftHandle { + let val = $state(snapshotDraftValue(defaultValue)) return { get draft(): V | undefined { return val From 51e82d7c6d30c66c84236feb743c09929934e564 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 16 Jun 2026 12:25:33 +0200 Subject: [PATCH 02/20] fix(frontend): make UserDraft read-after-write work without live entry (#9609) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/copilot/chat/global/core.ts | 54 +++++++------- frontend/src/lib/userDraft.svelte.ts | 70 +++++++++++++++++-- 2 files changed, 91 insertions(+), 33 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index d3e1e6889d..581ac3ca4b 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -138,10 +138,10 @@ const ACTIVE_GLOBAL_EDITOR_DRAFTS: readonly { itemKind: LiveEditorDraftKind type: ActiveGlobalEditorType }[] = [ - { itemKind: 'script', type: 'script' }, - { itemKind: 'flow', type: 'flow' }, - { itemKind: 'raw_app', type: 'app' } - ] + { itemKind: 'script', type: 'script' }, + { itemKind: 'flow', type: 'flow' }, + { itemKind: 'raw_app', type: 'app' } +] export type GlobalActiveEditorContext = { type: ActiveGlobalEditorType @@ -351,14 +351,8 @@ const getJobLogsSchema = z.object({ }) const listRunsSchema = z.object({ - path: z - .string() - .optional() - .describe('Filter to runs of this exact script or flow path.'), - created_by: z - .string() - .optional() - .describe('Filter by the username that started the run.'), + path: z.string().optional().describe('Filter to runs of this exact script or flow path.'), + created_by: z.string().optional().describe('Filter by the username that started the run.'), label: z.string().optional().describe('Filter by job label.'), success: z .boolean() @@ -656,13 +650,14 @@ Rules: - After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer local drafts, so testing does not require deployment. - Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_job_logs with a returned id to inspect a specific run's logs — without starting a new test run. - When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. -- Keep context targeted.${previewTools +- Keep context targeted.${ + previewTools ? ` - After writing or substantially editing a script / flow / app draft, show it via open_preview(kind, path) so the user sees the editor and live preview right next to the chat. First check whether it is already shown: if unsure, call get_preview_status. Only call open_preview (or offer to) when no preview is open or it is showing a different item — don't re-open a preview already showing the item you just edited. - When debugging a running raw app, call get_app_runtime_logs to read the live preview's browser console output. It needs the raw app preview open (open_preview kind="raw_app"). - get_app_runtime_logs only shows the app's browser console. For the server-side logs of a backend runnable the app invoked (a backend. call), call list_app_runs to get that run's job_id from the live preview, then get_job_logs with it. Use this when a backend call errors or returns something unexpected.` : '' - } +} Flows: - read_workspace_item returns compact flow JSON. Inline script bodies appear as "inline_script.". @@ -891,11 +886,11 @@ function buildPersistedRunnable( ): PersistedRunnable { const fields = input.staticInputs ? Object.fromEntries( - Object.entries(input.staticInputs).map(([k, v]) => [ - k, - { type: 'static', value: v, fieldType: 'object' } - ]) - ) + Object.entries(input.staticInputs).map(([k, v]) => [ + k, + { type: 'static', value: v, fieldType: 'object' } + ]) + ) : (existing?.fields ?? {}) if (input.type === 'inline') { @@ -2046,7 +2041,7 @@ export const globalTools: Tool<{}>[] = [ const result = await getSessionRuntimeLogs(parsed.limit ?? 10, sessionIdFromCtx(ctx)) ctx.toolCallbacks.setToolStatus(ctx.toolId, { content: result.uiMessage, - result: result.toolResult, + result: result.toolResult }) return result.aiResult } @@ -2055,7 +2050,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( listAppRunsSchema, 'list_app_runs', - "List the backend runnable executions (jobs) the raw app preview currently open in this AI session has triggered, newest first." + 'List the backend runnable executions (jobs) the raw app preview currently open in this AI session has triggered, newest first.' ), showDetails: true, fn: async (ctx) => { @@ -2378,10 +2373,15 @@ function getRequiredGlobalDraft( function finishDraftWrite(stored: WorkspaceItem, existed: boolean, ctx: WriteDraftCtx): string { 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 + // echo a redacted item; everything else round-trips its small payload. const serializedItem = - stored.type === 'variable' || stored.type === 'flow' - ? serializeWorkspaceItemForRead(stored) - : stored + stored.type === 'flow' + ? undefined + : stored.type === 'variable' + ? serializeWorkspaceItemForRead(stored) + : stored ctx.toolCallbacks.setToolStatus(ctx.toolId, { content: `${verb} ${stored.type} "${stored.path}" as a draft`, @@ -2530,9 +2530,9 @@ async function writeScheduleDraft(args: NewSchedule, ctx: WriteDraftCtx): Promis ? existingDraft : backendExists ? ((await ScheduleService.getSchedule({ - workspace, - path: args.path - })) as ScheduleDraftConfig) + workspace, + path: args.path + })) as ScheduleDraftConfig) : undefined const draft = mergeDraftConfig(base, args as DraftConfig, args.path) diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index ab455050b0..75bef6901b 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -120,6 +120,35 @@ const liveEditorDrafts = new Map() * Consumed by `acquireEntry`; cleared by the matching `restartSync`. */ const pendingSuspensions = new Set() +/** + * Synchronous read-through cache for values written via `save` while no live + * editor entry exists. That branch persists through the debounced + * `UserDraftDbSyncer` (async, fire-and-forget), so without this a same-tab + * `save(...)` followed by `get(...)` would miss its own write: the global AI + * chat writes a draft then immediately reads it back to return the result and + * would otherwise throw "Could not read written draft". A live entry shadows + * the cache (the entry is authoritative) and a release drops the key; a delete + * (`remove`/`discard`) evicts it. + */ +const writtenCache = new Map< + string, + { workspace: string; itemKind: UserDraftItemKind; path: string; val: unknown } +>() + +function rememberWrite( + workspace: string, + itemKind: UserDraftItemKind, + path: string, + val: unknown +): void { + const mk = mapKey(workspace, itemKind, path) + if (val === undefined) { + writtenCache.delete(mk) + } else { + writtenCache.set(mk, { workspace, itemKind, path, val: snapshotDraftValue(val) }) + } +} + function resolveWorkspace(opts?: UserDraftOptions): string { const ws = opts?.workspace ?? get(workspaceStore) if (!ws) { @@ -207,8 +236,10 @@ export const UserDraft = { // and POSTs it. entry.state.val = value } else { - // No live handle: push straight to the syncer. The next editor - // mount re-fetches the draft from the backend. + // No live handle: remember the value so a same-tab read-after-write + // observes it synchronously (the syncer POST below is debounced), + // then persist. The next editor mount re-fetches from the backend. + rememberWrite(ws, itemKind, path, value) void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value }) } }, @@ -225,8 +256,10 @@ export const UserDraft = { const ws = resolveWorkspace(opts) const mk = mapKey(ws, itemKind, path) const entry = entries.get(mk) - if (!entry) return undefined - return snapshotDraftValue(entry.state.val as V | undefined) + if (entry) return snapshotDraftValue(entry.state.val as V | undefined) + const cached = writtenCache.get(mk) + if (cached) return snapshotDraftValue(cached.val as V | undefined) + return undefined }, /** @@ -237,8 +270,8 @@ export const UserDraft = { const ws = resolveWorkspace(opts) const mk = mapKey(ws, itemKind, path) const entry = entries.get(mk) - if (!entry) return false - return entry.state.val !== undefined + if (entry) return entry.state.val !== undefined + return writtenCache.get(mk)?.val !== undefined }, remove(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void { @@ -251,6 +284,7 @@ export const UserDraft = { entry.skipNextSync = true entry.state.val = undefined } + writtenCache.delete(mk) void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value: null }) }, @@ -319,10 +353,12 @@ export const UserDraft = { const ws = resolveWorkspace(opts) const itemKinds = opts?.itemKinds ?? USER_DRAFT_ITEM_KINDS const out: UserDraftEntry[] = [] + const seen = new Set() for (const entry of entries.values()) { if (entry.workspace !== ws || !itemKinds.includes(entry.itemKind)) continue const val = untrack(() => entry.state.val as V | undefined) if (val === undefined) continue + seen.add(mapKey(entry.workspace, entry.itemKind, entry.path)) out.push({ workspace: entry.workspace, itemKind: entry.itemKind, @@ -330,6 +366,19 @@ export const UserDraft = { value: snapshotDraftValue(val) }) } + // Drafts written without a live entry (e.g. global AI chat) live only in + // `writtenCache`; surface them too so the list matches what `get` returns. + for (const cached of writtenCache.values()) { + if (cached.workspace !== ws || !itemKinds.includes(cached.itemKind)) continue + const mk = mapKey(cached.workspace, cached.itemKind, cached.path) + if (seen.has(mk)) continue + out.push({ + workspace: cached.workspace, + itemKind: cached.itemKind, + path: cached.path, + value: snapshotDraftValue(cached.val as V | undefined) + }) + } return out }, @@ -391,6 +440,10 @@ export const UserDraft = { entry.skipNextSync = true entry.state.val = safeFallback } + // The draft is deleted server-side (the `null` POST below); the fallback + // only resets the live handle's UI. Drop the cache so a no-entry read + // reports "no draft" rather than the discarded value. + writtenCache.delete(mk) void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value: null, auto: opts?.auto }) }, @@ -724,6 +777,10 @@ function releaseEntry(mk: string): void { if (!entry) return entry.count-- if (entry.count <= 0) { + // The live entry was authoritative while mounted; once gone, drop any + // cached write for this key so a later read falls back to the server + // rather than a value the editor may have changed in the meantime. + writtenCache.delete(mk) entry.destroyRoot?.() entries.delete(mk) } @@ -771,4 +828,5 @@ function makeHandle( export function __resetUserDraftForTesting(): void { entries.clear() liveEditorDrafts.clear() + writtenCache.clear() } From a44fc89eba84882eab22e82a8964a966668beb73 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:34:59 +0200 Subject: [PATCH 03/20] fix(frontend): open draft-only apps in editor from home list (#9610) A draft-only app (one that exists only in the `draft` table and was never deployed) failed to load when opened from the home list: the row linked to the viewer `/get/` route, whose `get_app_lite` backend handler 404s when there is no deployed version. Route `draft_only` apps to the `/edit/` route instead, matching the existing behavior in ScriptRow and FlowRow. Covers both raw and regular draft-only apps. Co-authored-by: Claude Opus 4.8 (1M context) --- frontend/src/lib/components/common/table/AppRow.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index 5682bd47e0..d7bd3390e9 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -102,7 +102,9 @@ {/if} Date: Tue, 16 Jun 2026 12:35:50 +0200 Subject: [PATCH 04/20] chore(main): release 1.727.0 (#9605) * chore(main): release 1.727.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 17 ++ backend/Cargo.lock | 160 +++++++++--------- 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, 137 insertions(+), 120 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 119e051c3b..8441043de0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [1.727.0](https://github.com/windmill-labs/windmill/compare/v1.726.1...v1.727.0) (2026-06-16) + + +### Features + +* support temp_script_refs in wmill dev for local relative imports ([#9554](https://github.com/windmill-labs/windmill/issues/9554)) ([33ac287](https://github.com/windmill-labs/windmill/commit/33ac287065742df53f363a5fe09f54f5584a85a6)) + + +### Bug Fixes + +* **cli:** harden legacy flow lock migration ordering and collision guard ([#9557](https://github.com/windmill-labs/windmill/issues/9557)) ([cd09870](https://github.com/windmill-labs/windmill/commit/cd098700c2cd8d7e9150f760938f4eaf34d188ec)) +* **cli:** include __mod/ folder in gitSyncIncludePattern for scripts ([#9606](https://github.com/windmill-labs/windmill/issues/9606)) ([252c1b3](https://github.com/windmill-labs/windmill/commit/252c1b35fc716c3486109d89615127c588bbe90a)) +* **frontend:** allow same-origin redirects in isValidLogoutRedirect ([#9568](https://github.com/windmill-labs/windmill/issues/9568)) ([8500435](https://github.com/windmill-labs/windmill/commit/8500435e82231e13a1b8a874fd0545f0a0a73fee)) +* **frontend:** make UserDraft read-after-write work without live entry ([#9609](https://github.com/windmill-labs/windmill/issues/9609)) ([51e82d7](https://github.com/windmill-labs/windmill/commit/51e82d7c6d30c66c84236feb743c09929934e564)) +* **frontend:** seed detached user-draft handles so new-item drawers render ([#9608](https://github.com/windmill-labs/windmill/issues/9608)) ([9e3c0de](https://github.com/windmill-labs/windmill/commit/9e3c0decf95378c66055d82215c15cd3bf4a69cb)) +* **frontend:** strip server-managed fields from value diffs ([#9599](https://github.com/windmill-labs/windmill/issues/9599)) ([c213801](https://github.com/windmill-labs/windmill/commit/c213801b5aee54d801c14b9eb31422f2a312ef7e)) + ## [1.726.1](https://github.com/windmill-labs/windmill/compare/v1.726.0...v1.726.1) (2026-06-15) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 1e446dc468..910e3b24ba 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1855,9 +1855,9 @@ dependencies = [ [[package]] name = "byte-unit" -version = "5.2.0" +version = "5.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d" +checksum = "37bcaa4a0975bed4a760af3efe4368825098ce5f9d37a30c5a021d635dc63d8f" dependencies = [ "rust_decimal", "schemars 1.2.1", @@ -13792,7 +13792,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "async-nats", @@ -13874,7 +13874,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.726.1" +version = "1.727.0" dependencies = [ "async-stream", "async-trait", @@ -13907,7 +13907,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.726.1" +version = "1.727.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13920,7 +13920,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "argon2", @@ -14058,7 +14058,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.726.1" +version = "1.727.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14081,7 +14081,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.726.1" +version = "1.727.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14094,7 +14094,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14120,7 +14120,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.726.1" +version = "1.727.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14130,7 +14130,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.726.1" +version = "1.727.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14147,7 +14147,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.726.1" +version = "1.727.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14169,7 +14169,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14192,7 +14192,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.726.1" +version = "1.727.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14208,7 +14208,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.726.1" +version = "1.727.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14229,7 +14229,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.726.1" +version = "1.727.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14250,7 +14250,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.726.1" +version = "1.727.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14264,7 +14264,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "async-nats", @@ -14299,7 +14299,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14324,7 +14324,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.726.1" +version = "1.727.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14342,7 +14342,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14364,7 +14364,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.726.1" +version = "1.727.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14384,7 +14384,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.726.1" +version = "1.727.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14415,7 +14415,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14443,7 +14443,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.726.1" +version = "1.727.0" dependencies = [ "lazy_static", "serde", @@ -14455,7 +14455,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.726.1" +version = "1.727.0" dependencies = [ "argon2", "axum 0.8.9", @@ -14480,7 +14480,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.726.1" +version = "1.727.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14494,7 +14494,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.726.1" +version = "1.727.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14527,7 +14527,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.726.1" +version = "1.727.0" dependencies = [ "chrono", "lazy_static", @@ -14541,7 +14541,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14560,7 +14560,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.726.1" +version = "1.727.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14662,7 +14662,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.726.1" +version = "1.727.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14681,7 +14681,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.726.1" +version = "1.727.0" dependencies = [ "regex", "serde", @@ -14696,7 +14696,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14720,7 +14720,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "futures", @@ -14737,7 +14737,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.726.1" +version = "1.727.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14753,7 +14753,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "async-trait", @@ -14774,7 +14774,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "async-trait", @@ -14805,7 +14805,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "arc-swap", @@ -14830,7 +14830,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "async-stream", @@ -14864,7 +14864,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "futures", @@ -14882,7 +14882,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.726.1" +version = "1.727.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14891,7 +14891,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "lazy_static", @@ -14903,7 +14903,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "serde_json", @@ -14915,7 +14915,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "gosyn", @@ -14927,7 +14927,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "lazy_static", @@ -14939,7 +14939,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "serde_json", @@ -14951,7 +14951,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "nu-parser", @@ -14962,7 +14962,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14973,7 +14973,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14985,7 +14985,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "rustpython-ast", @@ -14996,7 +14996,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "async-recursion", @@ -15018,7 +15018,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "serde_json", @@ -15030,7 +15030,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "lazy_static", @@ -15044,7 +15044,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15061,7 +15061,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "lazy_static", @@ -15074,7 +15074,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "serde", @@ -15086,7 +15086,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "lazy_static", @@ -15104,7 +15104,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15120,7 +15120,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15136,7 +15136,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "serde", @@ -15147,7 +15147,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "async-recursion", @@ -15185,7 +15185,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "const_format", @@ -15223,7 +15223,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.726.1" +version = "1.727.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15234,7 +15234,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "async-recursion", @@ -15266,7 +15266,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "async-trait", @@ -15290,7 +15290,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "async-trait", @@ -15323,7 +15323,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "async-trait", @@ -15356,7 +15356,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "async-trait", @@ -15376,7 +15376,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "async-trait", @@ -15410,7 +15410,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "async-trait", @@ -15446,7 +15446,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "async-trait", @@ -15469,7 +15469,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "async-trait", @@ -15493,7 +15493,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "async-nats", @@ -15517,7 +15517,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "async-trait", @@ -15552,7 +15552,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "async-trait", @@ -15580,7 +15580,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "async-trait", @@ -15605,7 +15605,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -15624,7 +15624,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "async-once-cell", @@ -15734,7 +15734,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.726.1" +version = "1.727.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 44bdedcd11..de302ce748 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.726.1" +version = "1.727.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.726.1" +version = "1.727.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 576e8fc89c..8f646c0503 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.726.1" +version = "1.727.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.726.1" +version = "1.727.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.726.1" +version = "1.727.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.726.1" +version = "1.727.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 5425aa9b35..3039b52af4 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.726.1" +version = "1.727.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 61fd303d36..fa5abc4ddc 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.726.1 + version: 1.727.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index a82663c132..3610ad0602 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.726.1"; +export const VERSION = "v1.727.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 b30d6f2f92..b2c6688154 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.726.1"; +export const VERSION = "1.727.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 84478a16e1..54b326cff7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.726.1", + "version": "1.727.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.726.1", + "version": "1.727.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 80d846cdfd..f750d6a2ad 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.726.1", + "version": "1.727.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 4d9099fb22..1f1f43f7b9 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.726.1" +wmill = ">=1.727.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index ef2aeb1670..51613e5e73 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.726.1 + version: 1.727.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 05f82abbe2..0efc3d6930 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.726.1' + ModuleVersion = '1.727.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index ecf03251b6..94a6aae2b0 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.726.1" +version = "1.727.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 1e4a705037..2ef900d8dd 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.726.1", + "version": "1.727.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 bf23f638e2..f9595d1a7e 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.726.1", + "version": "1.727.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 c12e7a3844..06327a1ec7 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.726.1 +1.727.0 From 5a2405743b4622fc1021109114d007057abd5dfd Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:41:04 +0200 Subject: [PATCH 05/20] fix(ResourceForm): initialize JSON editor when resource type schema is unavailable (#9611) When editing a resource whose type definition does not exist in the workspace (e.g. custom types not yet synced), the JSON fallback editor rendered empty. The pre-refactor ResourceEditor seeded rawCode from the resource args in its loadResourceType() catch block; the new ResourceForm only populated rawCode when the user toggled viewJsonSchema. Add a reactive effect that seeds rawCode from args when the resource type schema is unavailable, restoring the old behavior so the resource data is visible in the JSON editor. Fixes WIN-2045 Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Ruben Fiszel --- frontend/src/lib/components/ResourceForm.svelte | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/src/lib/components/ResourceForm.svelte b/frontend/src/lib/components/ResourceForm.svelte index 90ba39275b..ced60bf4dc 100644 --- a/frontend/src/lib/components/ResourceForm.svelte +++ b/frontend/src/lib/components/ResourceForm.svelte @@ -101,6 +101,14 @@ } }) + // Seed the JSON editor when the resource type schema is missing + // (restores the old ResourceEditor's catch-block behavior) + $effect(() => { + if (resource_type && !loadingSchema && !resourceSchema && rawCode === undefined) { + rawCode = JSON.stringify(args, null, 2) + } + }) + $effect(() => { if (textFileContent) parseTextFileContent() }) From bc0d5bf241df3633921bd9d43d171e91034fbfcf Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:12:08 +0200 Subject: [PATCH 06/20] feat(frontend): consolidate draft-migration errors into a single toast + modal (#9612) * Draft migration error modal * nits --- .../DraftMigrationErrorModal.svelte | 124 ++++++++++++++++++ frontend/src/lib/userDraftDbMigration.ts | 30 +++-- .../lib/userDraftMigrationErrors.svelte.ts | 75 +++++++++++ .../src/routes/(root)/(logged)/+layout.svelte | 4 +- 4 files changed, 218 insertions(+), 15 deletions(-) create mode 100644 frontend/src/lib/components/DraftMigrationErrorModal.svelte create mode 100644 frontend/src/lib/userDraftMigrationErrors.svelte.ts diff --git a/frontend/src/lib/components/DraftMigrationErrorModal.svelte b/frontend/src/lib/components/DraftMigrationErrorModal.svelte new file mode 100644 index 0000000000..ccbc5eb501 --- /dev/null +++ b/frontend/src/lib/components/DraftMigrationErrorModal.svelte @@ -0,0 +1,124 @@ + + + +
+

+ Drafts are now user-scoped and synced to the database. + {#if draftMigrationErrors.list.length} + These local storage drafts could not be + migrated to the server — view their contents before deciding, then delete the ones you no + longer need. + {/if} + Learn more. +

+ + {#if draftMigrationErrors.list.length === 0} +

All issues resolved.

+ {:else} +
+ +
+
    + {#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 07/20] 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 08/20] 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} - + +